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:
1 parent
43847ba935
commit
0d49293693
239 files changed
+825
-319
No files matched your search
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user