GEO: FAQPage/NGO/Event schema + autonomous generation for new projects

Generative Engine Optimization pass so AI answer engines (ChatGPT, Gemini,
Perplexity, AI Overviews) can confidently identify and cite MSOS.

Schema (tools/seo_inject.py, propagated to all 231 pages):
- FAQPage JSON-LD parsed from the /faq/ Q&A blocks (33 Q&A x EN/MK/SI)
- Organization upgraded to NGO with entity-disambiguation fields:
  alternateName (all spellings), description, areaServed Slovenia,
  knowsLanguage, and foundingDate 2023-03-05
- Event JSON-LD on all 9 /projects/ recap pages (date + real location)

Autonomous for future content (no code edits to add a project):
- Event/Article dates auto-read from the English page's publish date;
  event city auto-detected from the body (Slovenian-city whitelist).
  ARTICLE_DATES/PROJECT_LOCATIONS are now optional overrides only.
  (Auto-detect corrected Paint & Wine to Nova Gorica.)
- build_listings.py: auto-date fallback so undated new content still lists
- pre-commit hook now runs seo_inject.py --all --sitemap first, then
  listings/botlinks, and re-stages changed pages (idempotent)

Docs: docs/geo-offsite-worklist.md - copy-paste-ready off-site plan
(Wikidata, Wikipedia fixes, directories, Google Business, AI-visibility tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
popovskikandClaude Opus 4.8 committed 2026-08-07 22:17:03 +02:00
1 parent 43847ba935
commit 0d49293693
239 files changed
+825 -319

No files matched your search

Binary file not shown.
+5 -1
View File
@@ -74,7 +74,11 @@ def collect_items(lang):
date = DATES.get(key, {})
iso = date.get("published") or date.get("modified")
if not iso:
continue # only list dated content
# Autonomous fallback: read the date straight off the English
# page, so new content lists without an ARTICLE_DATES entry.
iso = seo_inject.extract_publish_date(seo_inject.en_content_for(key))
if not iso:
continue # only list genuinely dated content
html = io.open(page, encoding="utf-8").read()
h1 = H1_RE.search(html)
title = strip_tags(h1.group(1)) if h1 else slug
+15 -7
View File
@@ -3,10 +3,14 @@
# MSOS pre-commit hook — keeps derived files in sync automatically.
#
# On every commit it regenerates and stages:
# - the technical-SEO <head> block on every page + sitemap.xml/robots.txt
# (tools/seo_inject.py) — canonical, hreflang, meta, and the JSON-LD schema
# (Organization/NGO, Article, Event, FAQPage). Idempotent: unchanged pages
# produce no diff, so it only ever stages pages whose content actually moved.
# - the News hub grid + homepage "Latest news" (tools/build_listings.py)
# - /llms.txt and /links-for-bots/ (tools/build_botlinks.py)
# so adding a blog post or an event/project flows through to News, the homepage
# and the bot pages without any manual command.
# so adding a blog post or an event/project flows through to its schema, the
# sitemap, News, the homepage and the bot pages without any manual command.
#
# It never blocks a commit: if Python or a generator fails, it warns and lets
# the commit proceed.
@@ -25,15 +29,19 @@ fi
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
cd "$ROOT" || exit 0
# SEO/schema first, so build_listings sees fresh dates and new pages are in the
# sitemap. --all rewrites every page's SEO block idempotently; --sitemap also
# refreshes sitemap.xml + robots.txt.
"$PY" tools/seo_inject.py --all --sitemap >/dev/null 2>&1 \
|| echo "pre-commit: seo_inject.py failed — SEO/schema not refreshed." >&2
"$PY" tools/build_listings.py >/dev/null 2>&1 \
|| echo "pre-commit: build_listings.py failed — News/homepage not refreshed." >&2
"$PY" tools/build_botlinks.py >/dev/null 2>&1 \
|| echo "pre-commit: build_botlinks.py failed — bot pages not refreshed." >&2
# Stage only the generated outputs (no-op if unchanged).
git add -- \
en/news/index.html mk/news/index.html si/news/index.html \
en/index.html mk/index.html si/index.html \
llms.txt links-for-bots >/dev/null 2>&1 || true
# Re-stage generated/derived outputs (no-op when unchanged). `git add -u` only
# touches already-tracked files, so an unrelated commit stages nothing new.
git add -u en mk si >/dev/null 2>&1 || true
git add -- sitemap.xml robots.txt llms.txt links-for-bots >/dev/null 2>&1 || true
exit 0
+201 -12
View File
@@ -61,12 +61,97 @@ ARTICLE_DATES = {
"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 = [
@@ -242,6 +327,10 @@ def article_schema(lang, path, content, canon, desc, ogimg):
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 = {
@@ -255,6 +344,92 @@ def article_schema(lang, path, content, canon, desc, ogimg):
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)
@@ -294,20 +469,34 @@ def build_block(lang, path, content):
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>'
)
# 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)