# -*- coding: utf-8 -*- """Inject a technical-SEO 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, json 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__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import content_index # noqa: E402 (dates from content/ front-matter, U3) 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"} # Section (first path segment) display names, for Article breadcrumbs. # Any 2-segment path "section/slug" whose section is here is treated as an article. SECTION_NAMES = { "blog": {"en": "Blog", "mk": "Блог", "si": "Blog"}, "news": {"en": "News", "mk": "Новости", "si": "Novice"}, "projects": {"en": "Events & projects", "mk": "Настани и проекти", "si": "Dogodki in projekti"}, "get-to-know-slovenia": {"en": "Get to know Slovenia", "mk": "Запознај ја Словенија", "si": "Spoznaj Slovenijo"}, "get-to-know-macedonia": {"en": "Get to know Macedonia", "mk": "Запознај ја Македонија", "si": "Spoznaj Makedonijo"}, "student-welcome-guide": {"en": "Student Welcome Guide", "mk": "Водич за добредојде", "si": "Vodič za bruce"}, } HOME_CRUMB = {"en": "Home", "mk": "Дома", "si": "Domov"} # Article dates (ISO). Emitted as datePublished/dateModified in Article schema. # Source of truth is now content/ front-matter (see content_index / U3); this # static table is only a fallback for any entry not present in content/. _STATIC_ARTICLE_DATES = { "blog/how-to-open-slovenian-bank-account": {"modified": "2026-08-01"}, "blog/where-to-search-for-accommodation-in-slovenia": {"published": "2026-08-03"}, "blog/learning-slovene-where-to-start": {"published": "2026-07-28"}, "blog/study-in-north-macedonia-government-scholarship-2026-2027": {"published": "2026-07-30"}, "blog/ad-futura-scholarship-slovenia-2026": {"published": "2026-08-02"}, "blog/welcome-days-slovenian-universities-2026-2027": {"published": "2026-08-03"}, "blog/how-i-coped-with-leaving-home": {"published": "2024-04-24"}, "news/proof-of-means-updated-2026": {"published": "2026-08-01"}, "projects/humanitarian-tournament-in-maribor": {"published": "2025-04-05"}, "projects/in-memory-of-frosina-kulakova": {"published": "2025-02-08"}, "projects/in-memory-of-the-kochani-victims": {"published": "2025-03-21"}, "projects/macedonian-student-night-in-ljubljana": {"published": "2022-10-08"}, "projects/meet-student-slovenia-2023": {"published": "2023-09-18"}, "projects/meet-student-slovenia-2024": {"published": "2024-09-16"}, "projects/morning-coffee-in-front-of-ctk": {"published": "2024-06-08"}, "projects/paint-and-wine-at-sunset": {"published": "2025-03-08"}, "projects/watching-handball-together-in-slovenia": {"published": "2025-01-25"}, } # Content front-matter is authoritative; it overrides the static fallback above. # A new post published via the CMS therefore needs no manual date entry here. try: ARTICLE_DATES = {**_STATIC_ARTICLE_DATES, **content_index.load_dates()} except Exception: ARTICLE_DATES = dict(_STATIC_ARTICLE_DATES) # OPTIONAL location overrides for Event schema on /projects/ pages. # By default a project's city is auto-detected from its English page body # (see detect_city), so NEW projects need NO entry here — just publish them. # Add an entry only to pin a venue name or to force country-level ({} = whole # of Slovenia, used for multi-city campaigns like "Meet Student Slovenia"). PROJECT_LOCATIONS = { # Venue names (auto-detect finds the city, but not the specific venue): "projects/humanitarian-tournament-in-maribor": {"city": "Maribor", "venue": "Leon Štukelj Sports Hall"}, "projects/morning-coffee-in-front-of-ctk": {"city": "Ljubljana", "venue": "Central Technical Library (CTK)"}, # Multi-city welcome campaigns — force country-level (auto would pin one city): "projects/meet-student-slovenia-2023": {}, "projects/meet-student-slovenia-2024": {}, # All other projects (incl. paint-and-wine → Nova Gorica) auto-detect their city. } # Slovenian cities we auto-detect in event bodies (whitelist keeps Macedonian # place names like Skopje/Kočani out). Order = tie-break priority. SLO_CITIES = ["Ljubljana", "Maribor", "Celje", "Kranj", "Koper", "Novo Mesto", "Velenje", "Nova Gorica", "Ptuj", "Murska Sobota", "Domžale", "Kamnik", "Škofja Loka", "Izola", "Piran", "Bled"] MONTHS_EN = {m: i for i, m in enumerate( ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"], start=1)} PUBDATE_RE = re.compile( r'article-publish-date"[^>]*>\s*Published on\s+([A-Za-z]+)\s+(\d{1,2}),\s+(\d{4})') # EN sibling pages are the single source of truth for auto-detected dates/cities # (their format is language-independent). Read once, cached. _EN_CACHE = {} def en_content_for(path, current_lang=None, current_content=None): if current_lang == "en" and current_content is not None: return current_content if path not in _EN_CACHE: fp = os.path.join(ROOT, "en", *path.split("/"), "index.html") if path \ else os.path.join(ROOT, "en", "index.html") try: with open(fp, "r", encoding="utf-8", newline="") as f: _EN_CACHE[path] = f.read() except OSError: _EN_CACHE[path] = "" return _EN_CACHE[path] def extract_publish_date(en_content): """'Published on April 5, 2025' -> '2025-04-05', or None.""" m = PUBDATE_RE.search(en_content or "") if not m: return None mon = MONTHS_EN.get(m.group(1).lower()) if not mon: return None return f"{int(m.group(3)):04d}-{mon:02d}-{int(m.group(2)):02d}" def detect_city(en_content): """Most-mentioned Slovenian city in the body, or None. The injected SEO block is stripped first so we count only real page copy (otherwise a location we emit would feed back into the next detection).""" body = BLOCK_RE.sub("", en_content or "") best, best_n = None, 0 for city in SLO_CITIES: n = len(re.findall(r"\b" + re.escape(city) + r"\b", body)) if n > best_n: best, best_n = city, n return best 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/og-default.jpg" # Entity signals for the site-wide Organization/NGO schema. # alternateName lists the ways people (and AI assistants) actually refer to us, # including the common "Organization" spelling and the native-language names. ORG_ALT_NAMES = [ "MSOS", "Macedonian Students Organization Slovenia", "Macedonian Student Organisation in Slovenia", "Македонска студентска организација во Словенија", "Makedonska študentska organizacija v Sloveniji", ] ORG_SAME_AS = [ "https://www.facebook.com/msos.org", "https://www.instagram.com/msosorg_com", "https://www.linkedin.com/company/msos-org", "https://www.youtube.com/@msosorg", ] # ISO date the org was founded — 5 Mar 2023, when MSOS began as a student # initiative (official NGO registration followed on 9 Sep 2024). Emitted only if set. FOUNDING_DATE = "2023-03-05" # Favicons are ACTUAL resources the browser loads for the current host, so they # stay root-relative (resolve on the temp host, local server, and final domain). FAVICON_LINES = [ ' ', ' ', ' ', ' ', ' ', ] # Google Analytics 4 (gtag.js) with Consent Mode v2 defaulting to DENIED # (EEA-compliant: tag loads but sets no tracking cookies until consent is # granted). A consent banner should call gtag('consent','update',{...}). GA_ID = "G-WVYV807VSS" GA_LINES = [ ' ', f' ', ' ', ' ', ' ', " ", ' ', ] # ---- 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č.", }, "gallery": { "en": "A look back at the events, gatherings and moments that make the MSOS community — browse photo albums from Macedonian student life in Slovenia.", "mk": "Поглед наназад на настаните, дружењата и моментите што ја градат заедницата на МСОС — прелистајте фото-албуми од македонскиот студентски живот во Словенија.", "si": "Pogled nazaj na dogodke, druženja in trenutke, ki gradijo skupnost MSOS — prebrskajte foto-albume iz makedonskega študentskega življenja v Sloveniji.", }, "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.", }, "news/meet-student-slovenia-2026": { "en": "Future students and their parents talk directly with Macedonian students already studying in Slovenia. Meet Student Slovenia 2026 — 4 September in Skopje and online.", "mk": "Идни студенти и родители разговараат директно со македонски студенти кои веќе студираат во Словенија. „Запознај ја студентска Словенија 2026“ — 4 септември, во живо и онлајн.", "si": "Bodoči študenti in starši se pogovorijo z makedonskimi študenti, ki že študirajo v Sloveniji. Spoznaj študentsko Slovenijo 2026 — 4. septembra v Skopju in na spletu.", }, "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'
.*?]*?src="(.*?)"', r'
\s*]*?src="(.*?)"', ] def extract_og_image(content): for pat in HERO_PATTERNS: m = re.search(pat, content, re.DOTALL) if m: src = m.group(1) # normalise ../../../images/x -> /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)) H1_RE = re.compile(r"]*>(.*?)", re.DOTALL) def extract_h1(content): m = H1_RE.search(content) return clean_text(m.group(1)) if m else None def canonical_for(lang, path): return f"{DOMAIN}/{lang}/" + (f"{path}/" if path else "") def article_schema(lang, path, content, canon, desc, ogimg): """Article + BreadcrumbList JSON-LD for 2-segment 'section/slug' pages.""" parts = path.split("/") if len(parts) != 2 or parts[0] not in SECTION_NAMES: return [] section, _ = parts headline = extract_h1(content) or title_text(content).split(" - ")[0] org = {"@type": "Organization", "name": SITE_NAME[lang], "url": f"{DOMAIN}/{lang}/"} article = { "@context": "https://schema.org", "@type": "Article", "headline": headline, "description": desc or "", "image": ogimg, "inLanguage": HREF[lang], "author": org, "publisher": {"@type": "Organization", "name": SITE_NAME[lang], "logo": {"@type": "ImageObject", "url": f"{DOMAIN}/images/1-logo.png"}}, "mainEntityOfPage": canon, } if not article["description"]: del article["description"] dates = ARTICLE_DATES.get(path) if dates: pub = dates.get("published") or dates.get("modified") mod = dates.get("modified") or dates.get("published") else: # Auto-read the publish date from the English page (no manual curation). pub = mod = extract_publish_date(en_content_for(path, lang, content)) if pub: article["datePublished"] = pub article["dateModified"] = mod crumbs = { "@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ {"@type": "ListItem", "position": 1, "name": HOME_CRUMB[lang], "item": f"{DOMAIN}/{lang}/"}, {"@type": "ListItem", "position": 2, "name": SECTION_NAMES[section][lang], "item": f"{DOMAIN}/{lang}/{section}/"}, {"@type": "ListItem", "position": 3, "name": headline, "item": canon}, ], } dump = lambda o: ' " return [dump(article), dump(crumbs)] FAQ_BLOCK_RE = re.compile( r'
\s*(.*?)\s*' r'
(.*?)
\s*
', re.DOTALL) FAQ_ANSWER_P_RE = re.compile(r"

(.*?)

", re.DOTALL) def faq_schema(lang, path, content): """FAQPage JSON-LD, parsed from the
blocks. Only the /faq/ page carries these blocks. The answer is the first

inside .faq-a (the self-contained sentence), dropping the trailing "Read more in the guide" link so the schema answer stands alone — which is exactly what AI answer engines lift verbatim. """ if path != "faq": return [] qas = [] for m in FAQ_BLOCK_RE.finditer(content): q = clean_text(m.group(1)) ans_html = m.group(2) pm = FAQ_ANSWER_P_RE.search(ans_html) a = clean_text(pm.group(1)) if pm else clean_text(ans_html) if q and a: qas.append({ "@type": "Question", "name": q, "acceptedAnswer": {"@type": "Answer", "text": a}, }) if not qas: return [] faq = { "@context": "https://schema.org", "@type": "FAQPage", "inLanguage": HREF[lang], "mainEntity": qas, } return [' "] def event_schema(lang, path, content, canon, desc, ogimg): """Event JSON-LD for /projects/ recap pages (added alongside Article). These are past events; the schema accurately records what happened and where, which helps AI answer engines associate MSOS with real events in Slovenia. Autonomous: any 2-segment projects/ page qualifies automatically. Date and city are auto-read from the English sibling page; ARTICLE_DATES and PROJECT_LOCATIONS are consulted first only as optional overrides.""" parts = path.split("/") if len(parts) != 2 or parts[0] != "projects": return [] en = en_content_for(path, lang, content) dates = ARTICLE_DATES.get(path) or {} start = dates.get("published") or dates.get("modified") or extract_publish_date(en) if not start: return [] headline = extract_h1(content) or title_text(content).split(" - ")[0] if path in PROJECT_LOCATIONS: loc = PROJECT_LOCATIONS[path] # manual override ({} = country-level) else: city = detect_city(en) # auto-detect for new projects loc = {"city": city} if city else {} address = {"@type": "PostalAddress", "addressCountry": "SI"} if loc.get("city"): address["addressLocality"] = loc["city"] place = {"@type": "Place", "name": loc.get("venue") or loc.get("city") or "Slovenia", "address": address} event = { "@context": "https://schema.org", "@type": "Event", "name": headline, "startDate": start, "endDate": start, "eventStatus": "https://schema.org/EventScheduled", "eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode", "location": place, "image": ogimg, "inLanguage": HREF[lang], "organizer": {"@type": "Organization", "name": SITE_NAME[lang], "url": f"{DOMAIN}/{lang}/"}, "performer": {"@type": "Organization", "name": SITE_NAME[lang], "url": f"{DOMAIN}/{lang}/"}, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR", "availability": "https://schema.org/InStock", "url": canon, "validFrom": start}, "url": canon, } if desc: event["description"] = desc return [' "] 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] lines.extend(GA_LINES) 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). Typed as NGO (a schema.org subtype of # Organization) with entity-disambiguation fields — alternateName spellings, # areaServed, knowsLanguage and a description — so AI answer engines can # confidently match "Macedonian students organisation in Slovenia" to MSOS. org_obj = { "@context": "https://schema.org", "@type": "NGO", "name": SITE_NAME[lang], "alternateName": ORG_ALT_NAMES, "description": DESC_OVERRIDES[""][lang], "url": f"{DOMAIN}/{lang}/", "logo": f"{DOMAIN}/images/1-logo.png", "email": "info@msosorg.com", "address": {"@type": "PostalAddress", "streetAddress": "Masarykova cesta 24", "postalCode": "1000", "addressLocality": "Ljubljana", "addressCountry": "SI"}, "areaServed": {"@type": "Country", "name": "Slovenia"}, "knowsLanguage": ["mk", "sl", "en"], "sameAs": ORG_SAME_AS, } if FOUNDING_DATE: org_obj["foundingDate"] = FOUNDING_DATE org = (' ") lines.append(org) lines.extend(article_schema(lang, path, content, canon, desc, ogimg)) lines.extend(event_schema(lang, path, content, canon, desc, ogimg)) lines.extend(faq_schema(lang, path, content)) lines.extend(FAVICON_LINES) 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()