msos/tools/seo_inject.py

396 lines
20 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"},
"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"},
}
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"
# 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")
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)]
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)
org = (
' <script type="application/ld+json">'
'{"@context":"https://schema.org","@type":"Organization",'
f'"name":"{SITE_NAME[lang]}","alternateName":"MSOS",'
f'"url":"{DOMAIN}/{lang}/",'
f'"logo":"{DOMAIN}/images/1-logo.png",'
'"email":"info@msosorg.com",'
'"address":{"@type":"PostalAddress","streetAddress":"Masarykova cesta 24","postalCode":"1000","addressLocality":"Ljubljana","addressCountry":"SI"},'
'"sameAs":["https://www.facebook.com/msos.org","https://www.instagram.com/msosorg_com","https://www.linkedin.com/company/msos-org","https://www.youtube.com/@msosorg"]}'
'</script>'
)
lines.append(org)
lines.extend(article_schema(lang, path, content, canon, desc, ogimg))
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()