block into every language page.
Idempotent: the block lives between and ,
so re-running replaces it in place. Absolute URLs point at the FINAL domain
(msosorg.com), not the temporary host, so nothing needs rewriting at launch.
Usage:
python seo_inject.py # pilot only (prints injected block)
python seo_inject.py --all # write every page
python seo_inject.py --all --sitemap # also (re)build sitemap + robots
"""
import os, re, sys, html
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# Repo root = parent of this tools/ directory (portable; no hardcoded path).
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOMAIN = "https://msosorg.com"
SITE_NAME = {"en": "Macedonian Student Organisation in Slovenia",
"mk": "Македонска студентска организација во Словенија",
"si": "Makedonska študentska organizacija v Sloveniji"}
LANGS = ["en", "mk", "si"]
# folder -> ISO-639-1 hreflang code (Slovene is "sl", not "si")
HREF = {"en": "en", "mk": "mk", "si": "sl"}
OG_LOCALE = {"en": "en_GB", "mk": "mk_MK", "si": "sl_SI"}
START, END = "", ""
BLOCK_RE = re.compile(re.escape(START) + r".*?" + re.escape(END) + r"\s*", re.DOTALL)
TITLE_RE = re.compile(r"(.*?)", re.DOTALL)
DEFAULT_OG_IMAGE = DOMAIN + "/images/1-logo.png"
# ---- page trees (for hreflang parity) ----
def build_trees():
trees = {}
for L in LANGS:
s = set()
base = os.path.join(ROOT, L)
for root, _, files in os.walk(base):
if "index.html" in files:
rel = os.path.relpath(root, base).replace("\\", "/")
s.add("" if rel == "." else rel)
trees[L] = s
return trees
TREES = build_trees()
def clean_text(s):
s = re.sub(r"<[^>]+>", "", s) # strip tags
s = html.unescape(s)
s = re.sub(r"\s+", " ", s).strip()
return s
def truncate(s, keep=175, n=160):
# Keep concise leads whole (subtitles are naturally ~150-170 chars);
# only truncate genuinely long text, at a word boundary.
if len(s) <= keep:
return s
cut = s[:n].rsplit(" ", 1)[0].rstrip(" ,.;:—-")
return cut + "…"
DESC_PATTERNS = [
r'
(.*?)
',
r'
(.*?)
',
r'
(.*?)
',
r'
(.*?)
',
r'
\s*
(.*?)
',
]
# Curated descriptions for pages with no extractable lead paragraph.
# Keyed by page path (""=home) then folder lang code (en/mk/si).
# MK/SI copy is AI-drafted — flag for native-speaker review.
DESC_OVERRIDES = {
"": {
"en": "The first official Macedonian student organisation in Slovenia — support, events, community and a voice for Macedonian students from application to graduation.",
"mk": "Првата официјална македонска студентска организација во Словенија — поддршка, настани, заедница и глас за македонските студенти од пријава до дипломирање.",
"si": "Prva uradna makedonska študentska organizacija v Sloveniji — podpora, dogodki, skupnost in glas makedonskih študentov od prijave do diplome.",
},
"become-a-member": {
"en": "Join MSOS and find your people in Slovenia. Meet students, attend events, get support and help create projects across the Macedonian student community.",
"mk": "Придружете се на МСОС и најдете ги вашите луѓе во Словенија. Запознајте студенти, посетувајте настани, добијте поддршка и создавајте проекти во македонската студентска заедница.",
"si": "Pridružite se MSOS in najdite svoje ljudi v Sloveniji. Spoznajte študente, obiskujte dogodke, prejmite podporo in soustvarjajte projekte makedonske študentske skupnosti.",
},
"faq": {
"en": "Short answers to the questions Macedonian students ask most about studying and living in Slovenia — applications, residence, work, housing and more.",
"mk": "Кратки одговори на прашањата што најчесто ги поставуваат македонските студенти за студирање и живеење во Словенија — пријави, престој, работа, сместување и повеќе.",
"si": "Kratki odgovori na vprašanja, ki jih makedonski študenti najpogosteje postavljajo o študiju in življenju v Sloveniji — prijave, prebivanje, delo, nastanitev in več.",
},
"news": {
"en": "Everything new at MSOS in one place — events and projects, blog stories, announcements and official updates for Macedonian students in Slovenia.",
"mk": "Сè ново кај МСОС на едно место — настани и проекти, блог приказни, соопштенија и официјални новости за македонските студенти во Словенија.",
"si": "Vse novo pri MSOS na enem mestu — dogodki in projekti, blog zgodbe, obvestila in uradne novice za makedonske študente v Sloveniji.",
},
"student-welcome-guide/my-route": {
"en": "Your personal study route to Slovenia — follow the Student Welcome Guide steps for Macedonian students, from choosing a programme to enrolment and residence.",
"mk": "Вашиот личен студиски пат до Словенија — следете ги чекорите од Водичот за добредојде за македонските студенти, од избор на програма до упис и престој.",
"si": "Vaša osebna študijska pot v Slovenijo — sledite korakom Vodnika za bruce za makedonske študente, od izbire programa do vpisa in prebivanja.",
},
}
def extract_description(content, lang=None, path=None):
if path is not None and path in DESC_OVERRIDES:
ov = DESC_OVERRIDES[path].get(lang)
if ov:
return ov
for pat in DESC_PATTERNS:
m = re.search(pat, content, re.DOTALL)
if m:
txt = clean_text(m.group(1))
if len(txt) >= 40:
return truncate(txt)
return None
HERO_PATTERNS = [
r'.*?\s* /images/x
idx = src.find("images/")
if idx != -1:
return DOMAIN + "/" + src[idx:]
return DEFAULT_OG_IMAGE
def title_text(content):
m = TITLE_RE.search(content)
if not m:
return SITE_NAME["en"]
return clean_text(m.group(1))
def canonical_for(lang, path):
return f"{DOMAIN}/{lang}/" + (f"{path}/" if path else "")
def build_block(lang, path, content):
canon = canonical_for(lang, path)
title = title_text(content)
desc = extract_description(content, lang, path)
ogimg = extract_og_image(content)
lines = [START]
if desc:
lines.append(f' ')
lines.append(f' ')
# hreflang alternates (only langs that actually have this path)
have = [L for L in LANGS if path in TREES[L]]
for L in have:
lines.append(f' ')
xdef = "en" if "en" in have else have[0]
lines.append(f' ')
# Open Graph
lines.append(f' ')
lines.append(f' ')
lines.append(f' ')
if desc:
lines.append(f' ')
lines.append(f' ')
lines.append(f' ')
lines.append(f' ')
for L in have:
if L != lang:
lines.append(f' ')
# Twitter
lines.append(f' ')
lines.append(f' ')
if desc:
lines.append(f' ')
lines.append(f' ')
# Organization JSON-LD (site-wide)
org = (
' '
)
lines.append(org)
lines.append(" " + END)
return "\n".join(lines)
def inject(content, lang, path):
block = build_block(lang, path, content)
# remove any existing block first (idempotent)
content = BLOCK_RE.sub("", content)
nl = "\r\n" if "\r\n" in content else "\n"
block = block.replace("\n", nl)
def repl(m):
return m.group(1) + nl + " " + block
return TITLE_RE.sub(repl, content, count=1)
def page_path_of(fp, lang):
base = os.path.join(ROOT, lang)
rel = os.path.relpath(os.path.dirname(fp), base).replace("\\", "/")
return "" if rel == "." else rel
def all_pages():
for L in LANGS:
base = os.path.join(ROOT, L)
for root, _, files in os.walk(base):
if "index.html" in files:
yield L, os.path.join(root, "index.html")
PILOT = [
("en", os.path.join(ROOT, "en", "index.html")),
("en", os.path.join(ROOT, "en", "blog", "how-to-open-slovenian-bank-account", "index.html")),
("mk", os.path.join(ROOT, "mk", "blog", "how-to-open-slovenian-bank-account", "index.html")),
("si", os.path.join(ROOT, "si", "blog", "how-to-open-slovenian-bank-account", "index.html")),
]
def write_sitemap():
urls = []
for L, fp in all_pages():
path = page_path_of(fp, L)
urls.append((L, path))
# group by path so we can emit xhtml:link alternates
from collections import defaultdict
bypath = defaultdict(list)
for L, path in urls:
bypath[path].append(L)
lines = ['',
'']
lines[1] = lines[1].replace("www.sitemap.org", "www.sitemaps.org")
for path in sorted(bypath):
have = [L for L in LANGS if L in bypath[path]]
for L in have:
lines.append(" ")
lines.append(f" {canonical_for(L, path)}")
for A in have:
lines.append(f' ')
xdef = "en" if "en" in have else have[0]
lines.append(f' ')
lines.append(" ")
lines.append("")
with open(os.path.join(ROOT, "sitemap.xml"), "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(lines) + "\n")
robots = f"User-agent: *\nAllow: /\n\nSitemap: {DOMAIN}/sitemap.xml\n"
with open(os.path.join(ROOT, "robots.txt"), "w", encoding="utf-8", newline="\n") as f:
f.write(robots)
print(f"sitemap.xml: {sum(len(v) for v in bypath.values())} urls; robots.txt written")
def main():
args = sys.argv[1:]
if "--all" in args:
n = 0
for L, fp in all_pages():
with open(fp, "r", encoding="utf-8", newline="") as f:
content = f.read()
path = page_path_of(fp, L)
out = inject(content, L, path)
with open(fp, "w", encoding="utf-8", newline="") as f:
f.write(out)
n += 1
print(f"injected SEO block into {n} pages")
if "--sitemap" in args:
write_sitemap()
else:
# pilot: print blocks only
for L, fp in PILOT:
with open(fp, "r", encoding="utf-8", newline="") as f:
content = f.read()
path = page_path_of(fp, L)
print(f"\n===== {L} /{path or '(home)'} =====")
print(build_block(L, path, content))
if __name__ == "__main__":
main()