Add technical SEO foundation, MK/SI about-us + timeline, content fixes
SEO (all 201 pages, en/mk/si): - Inject canonical, hreflang (en/mk/sl + x-default), Open Graph, Twitter cards and Organization JSON-LD via idempotent SEO:START/END block - Generate multilingual sitemap.xml (xhtml:link alternates) + robots.txt - Curated meta descriptions for lead-less pages (home, become-a-member, faq, news hub, my-route); every page now has a description - Absolute URLs target final domain msosorg.com, not the temp host - Add reusable tools/seo_inject.py (+ README) Content fixes: - Translate about-us and timeline-and-milestones into MK + SI, wire up their pages, and repair the previously-dead "Who We Are" nav links across all mk/si pages - Remove duplicate en/projects/makedonska-studentska-vecer-vo-ljubljana (unreferenced; already covered by macedonian-student-night-in-ljubljana) Locale correctness: - Normalise Slovene pages to <html lang="sl"> (correct ISO 639-1) - Map sl->si in main.js so the language-switcher highlight and newsletter i18n keep working on Slovene pages (data-lang="si" still maps to /si/) MK/SI translations and descriptions are AI-drafted; flag for native review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
31487ad83e
commit
b393859467
208 files changed
+8319
-1388
No files matched your search
@@ -0,0 +1,26 @@
|
||||
# tools/
|
||||
|
||||
## seo_inject.py
|
||||
|
||||
Injects a technical-SEO block into every language page (`en|mk|si/**/index.html`)
|
||||
and regenerates `sitemap.xml` + `robots.txt`. Safe to re-run — the block lives
|
||||
between `<!-- SEO:START -->` and `<!-- SEO:END -->` and is replaced in place.
|
||||
|
||||
The block adds: `meta description` (auto-extracted from the page's lead/subtitle
|
||||
where available), `canonical`, `hreflang` alternates (en / mk / **sl** / x-default),
|
||||
Open Graph, Twitter Card, and an Organization JSON-LD.
|
||||
|
||||
All absolute URLs point at the **final** domain `https://msosorg.com` (not the
|
||||
temporary host), so nothing needs rewriting at launch. Note: the Slovene folder
|
||||
is `/si/` but the ISO code is `sl` — the script maps `si → sl` for hreflang/locale.
|
||||
|
||||
### Usage
|
||||
|
||||
```sh
|
||||
python tools/seo_inject.py # dry run: print the block for pilot pages
|
||||
python tools/seo_inject.py --all # write the block into every page
|
||||
python tools/seo_inject.py --all --sitemap # also rebuild sitemap.xml + robots.txt
|
||||
```
|
||||
|
||||
Run `--all --sitemap` after adding, renaming, or deleting any page so canonical
|
||||
tags, hreflang alternates, and the sitemap stay in sync.
|
||||
@@ -0,0 +1,282 @@
|
||||
# -*- 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
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Repo root = parent of this tools/ directory (portable; no hardcoded path).
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DOMAIN = "https://msosorg.com"
|
||||
SITE_NAME = {"en": "Macedonian Student Organisation in Slovenia",
|
||||
"mk": "Македонска студентска организација во Словенија",
|
||||
"si": "Makedonska študentska organizacija v Sloveniji"}
|
||||
LANGS = ["en", "mk", "si"]
|
||||
# folder -> ISO-639-1 hreflang code (Slovene is "sl", not "si")
|
||||
HREF = {"en": "en", "mk": "mk", "si": "sl"}
|
||||
OG_LOCALE = {"en": "en_GB", "mk": "mk_MK", "si": "sl_SI"}
|
||||
|
||||
START, END = "<!-- 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/1-logo.png"
|
||||
|
||||
# ---- page trees (for hreflang parity) ----
|
||||
def build_trees():
|
||||
trees = {}
|
||||
for L in LANGS:
|
||||
s = set()
|
||||
base = os.path.join(ROOT, L)
|
||||
for root, _, files in os.walk(base):
|
||||
if "index.html" in files:
|
||||
rel = os.path.relpath(root, base).replace("\\", "/")
|
||||
s.add("" if rel == "." else rel)
|
||||
trees[L] = s
|
||||
return trees
|
||||
|
||||
TREES = build_trees()
|
||||
|
||||
def clean_text(s):
|
||||
s = re.sub(r"<[^>]+>", "", s) # strip tags
|
||||
s = html.unescape(s)
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
return s
|
||||
|
||||
def truncate(s, keep=175, n=160):
|
||||
# Keep concise leads whole (subtitles are naturally ~150-170 chars);
|
||||
# only truncate genuinely long text, at a word boundary.
|
||||
if len(s) <= keep:
|
||||
return s
|
||||
cut = s[:n].rsplit(" ", 1)[0].rstrip(" ,.;:—-")
|
||||
return cut + "…"
|
||||
|
||||
DESC_PATTERNS = [
|
||||
r'<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č.",
|
||||
},
|
||||
"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))
|
||||
|
||||
def canonical_for(lang, path):
|
||||
return f"{DOMAIN}/{lang}/" + (f"{path}/" if path else "")
|
||||
|
||||
def build_block(lang, path, content):
|
||||
canon = canonical_for(lang, path)
|
||||
title = title_text(content)
|
||||
desc = extract_description(content, lang, path)
|
||||
ogimg = extract_og_image(content)
|
||||
|
||||
lines = [START]
|
||||
if desc:
|
||||
lines.append(f' <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.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()
|
||||
Reference in new issue
Block a user