msos/tools/seo_inject.py

591 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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__)))
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), curated once from the English pages (dates are
# language-independent). Emitted as datePublished/dateModified in Article schema.
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"},
}
# 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.",
},
"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,
"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}/"},
"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()