609 lines
30 KiB
Python
609 lines
30 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Inject a technical-SEO <head> block into every language page.
|
||
|
||
Idempotent: the block lives between <!-- SEO:START --> and <!-- SEO:END -->,
|
||
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 = "<!-- SEO:START -->", "<!-- SEO:END -->"
|
||
BLOCK_RE = re.compile(re.escape(START) + r".*?" + re.escape(END) + r"\s*", re.DOTALL)
|
||
TITLE_RE = re.compile(r"(<title>.*?</title>)", 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 = [
|
||
' <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">',
|
||
' <link rel="icon" type="image/svg+xml" href="/favicon.svg">',
|
||
' <link rel="shortcut icon" href="/favicon.ico">',
|
||
' <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">',
|
||
' <link rel="manifest" href="/site.webmanifest">',
|
||
]
|
||
|
||
# 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 = [
|
||
' <!-- Google tag (gtag.js) with Consent Mode v2 (default: denied) -->',
|
||
f' <script async src="https://www.googletagmanager.com/gtag/js?id={GA_ID}"></script>',
|
||
' <script>',
|
||
' window.dataLayer = window.dataLayer || [];',
|
||
' function gtag(){dataLayer.push(arguments);}',
|
||
" gtag('consent', 'default', {",
|
||
" ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied',",
|
||
" analytics_storage: 'denied', wait_for_update: 500",
|
||
" });",
|
||
" // Restore a returning visitor's prior choice before the first hit.",
|
||
" try { if (localStorage.getItem('msos-consent') === 'granted') {",
|
||
" gtag('consent', 'update', { ad_storage:'granted', ad_user_data:'granted',",
|
||
" ad_personalization:'granted', analytics_storage:'granted' }); } } catch(e){}",
|
||
" gtag('js', new Date());",
|
||
f" gtag('config', '{GA_ID}');",
|
||
' </script>',
|
||
' <script src="/consent.js" defer></script>',
|
||
]
|
||
|
||
# ---- 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'<p class="article-subtitle">(.*?)</p>',
|
||
r'<p class="guide-lead">(.*?)</p>',
|
||
r'<p class="page-description">(.*?)</p>',
|
||
r'<p class="blog-lead">(.*?)</p>',
|
||
r'<div class="news-article-body">\s*<p>(.*?)</p>',
|
||
]
|
||
|
||
# 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'<figure class="article-main-image">.*?<img[^>]*?src="(.*?)"',
|
||
r'<figure class="news-article-hero">\s*<img[^>]*?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"<h1[^>]*>(.*?)</h1>", 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: ' <script type="application/ld+json">' + json.dumps(o, ensure_ascii=False, separators=(",", ":")) + "</script>"
|
||
return [dump(article), dump(crumbs)]
|
||
|
||
FAQ_BLOCK_RE = re.compile(
|
||
r'<details class="faq-q">\s*<summary>(.*?)</summary>\s*'
|
||
r'<div class="faq-a">(.*?)</div>\s*</details>',
|
||
re.DOTALL)
|
||
FAQ_ANSWER_P_RE = re.compile(r"<p>(.*?)</p>", re.DOTALL)
|
||
|
||
def faq_schema(lang, path, content):
|
||
"""FAQPage JSON-LD, parsed from the <details class="faq-q"> blocks.
|
||
|
||
Only the /faq/ page carries these blocks. The answer is the first
|
||
<p> 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 [' <script type="application/ld+json">'
|
||
+ json.dumps(faq, ensure_ascii=False, separators=(",", ":"))
|
||
+ "</script>"]
|
||
|
||
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/<slug> 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 [' <script type="application/ld+json">'
|
||
+ json.dumps(event, ensure_ascii=False, separators=(",", ":"))
|
||
+ "</script>"]
|
||
|
||
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' <meta name="description" content="{html.escape(desc, quote=True)}">')
|
||
lines.append(f' <link rel="canonical" href="{canon}">')
|
||
|
||
# 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' <link rel="alternate" hreflang="{HREF[L]}" href="{canonical_for(L, path)}">')
|
||
xdef = "en" if "en" in have else have[0]
|
||
lines.append(f' <link rel="alternate" hreflang="x-default" href="{canonical_for(xdef, path)}">')
|
||
|
||
# Open Graph
|
||
lines.append(f' <meta property="og:type" content="website">')
|
||
lines.append(f' <meta property="og:site_name" content="{html.escape(SITE_NAME[lang], quote=True)}">')
|
||
lines.append(f' <meta property="og:title" content="{html.escape(title, quote=True)}">')
|
||
if desc:
|
||
lines.append(f' <meta property="og:description" content="{html.escape(desc, quote=True)}">')
|
||
lines.append(f' <meta property="og:url" content="{canon}">')
|
||
lines.append(f' <meta property="og:image" content="{ogimg}">')
|
||
lines.append(f' <meta property="og:locale" content="{OG_LOCALE[lang]}">')
|
||
for L in have:
|
||
if L != lang:
|
||
lines.append(f' <meta property="og:locale:alternate" content="{OG_LOCALE[L]}">')
|
||
|
||
# Twitter
|
||
lines.append(f' <meta name="twitter:card" content="summary_large_image">')
|
||
lines.append(f' <meta name="twitter:title" content="{html.escape(title, quote=True)}">')
|
||
if desc:
|
||
lines.append(f' <meta name="twitter:description" content="{html.escape(desc, quote=True)}">')
|
||
lines.append(f' <meta name="twitter:image" content="{ogimg}">')
|
||
|
||
# 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 = (' <script type="application/ld+json">'
|
||
+ json.dumps(org_obj, ensure_ascii=False, separators=(",", ":"))
|
||
+ "</script>")
|
||
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 = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||
'<urlset xmlns="http://www.sitemap.org/schemas/sitemap/0.9"',
|
||
' xmlns:xhtml="http://www.w3.org/1999/xhtml">']
|
||
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(" <url>")
|
||
lines.append(f" <loc>{canonical_for(L, path)}</loc>")
|
||
for A in have:
|
||
lines.append(f' <xhtml:link rel="alternate" hreflang="{HREF[A]}" href="{canonical_for(A, path)}"/>')
|
||
xdef = "en" if "en" in have else have[0]
|
||
lines.append(f' <xhtml:link rel="alternate" hreflang="x-default" href="{canonical_for(xdef, path)}"/>')
|
||
lines.append(" </url>")
|
||
lines.append("</urlset>")
|
||
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()
|