SEO polish: OG image, favicons, 404, article schema, image optimization

- Add branded 1200x630 og-default.jpg; article pages use their hero as
  og:image, others use the default
- Wire the existing (previously unlinked) favicon set into every page
- Add branded 404.html with root-absolute paths + noindex
- Emit Article + localized BreadcrumbList JSON-LD on all article pages
- Convert 74 heavy served photos to WebP (28MB -> 8.5MB, -70%), rewrite
  references, add loading=lazy/decoding=async to below-fold images and
  fetchpriority=high to heroes (protect LCP)
- Stop tracking images/original/ (458MB of unused source masters; kept on
  disk, gitignored) and ignore .playwright-mcp/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
popovskikandClaude Opus 4.8 committed 2026-08-02 12:15:53 +02:00
1 parent b393859467
commit 314667f692
429 files changed
+2559 -1106

No files matched your search

+65 -4
View File
@@ -10,7 +10,7 @@ Usage:
python seo_inject.py --all # write every page
python seo_inject.py --all --sitemap # also (re)build sitemap + robots
"""
import os, re, sys, html
import os, re, sys, html, json
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
@@ -27,11 +27,33 @@ LANGS = ["en", "mk", "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"}
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"
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">',
]
# ---- page trees (for hreflang parity) ----
def build_trees():
@@ -115,8 +137,8 @@ def extract_description(content, lang=None, path=None):
return None
HERO_PATTERNS = [
r'<figure class="article-main-image">.*?<img src="(.*?)"',
r'<figure class="news-article-hero">\s*<img src="(.*?)"',
r'<figure class="article-main-image">.*?<img[^>]*?src="(.*?)"',
r'<figure class="news-article-hero">\s*<img[^>]*?src="(.*?)"',
]
def extract_og_image(content):
@@ -136,9 +158,46 @@ def title_text(content):
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"]
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)
@@ -190,6 +249,8 @@ def build_block(lang, path, content):
'</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)