msos/tools/build_listings.py

201 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""
build_listings.py -- interconnect Blog + Events/Projects + News + homepage.
Single source of truth = the actual detail pages under <lang>/blog/, <lang>/projects/
and <lang>/news/. This script reads them and regenerates, between AUTO markers:
<lang>/news/index.html the News hub grid (all items, newest first)
<lang>/index.html the homepage "Latest news" block (top 3)
So adding a blog post or an event/project and running this script makes it appear
in News AND on the homepage automatically, with the correct date from the central
ARTICLE_DATES registry (tools/seo_inject.py). Run after adding/removing content:
python tools/build_listings.py
Idempotent. Blocks are wrapped in <!-- AUTO-NEWS:START/END --> and
<!-- AUTO-LATEST-NEWS:START/END -->.
"""
import io, os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import seo_inject
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LANGS = ("en", "mk", "si")
DATES = seo_inject.ARTICLE_DATES
N_LATEST = 3
# folder -> (data-type, badge modifier, icon, {lang: label})
TYPES = {
"news": ("news", "news", "fa-bullhorn", {"en": "News", "mk": "Вест", "si": "Novica"}),
"blog": ("blog", "blog", "fa-pen-nib", {"en": "Blog", "mk": "Блог", "si": "Blog"}),
"projects": ("project", "project", "fa-calendar-day", {"en": "Event", "mk": "Настан", "si": "Dogodek"}),
}
MONTHS = {
"en": ["January","February","March","April","May","June","July","August","September","October","November","December"],
"mk": ["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],
"si": ["januar","februar","marec","april","maj","junij","julij","avgust","september","oktober","november","december"],
}
H1_RE = re.compile(r"<h1>(.*?)</h1>", re.DOTALL)
DESC_RE = re.compile(r'<meta name="description" content="(.*?)"')
FIG_RE = re.compile(r'<figure class="(?:article-main-image|news-article-hero)">(.*?)</figure>', re.DOTALL)
IMG_RE = re.compile(r'<img\b([^>]*)>')
ATTR_RE = lambda a, s: (re.search(a + r'="([^"]*)"', s) or [None, None])[1]
def strip_tags(s):
return re.sub(r"<[^>]+>", "", s).strip()
def fmt_date(lang, iso):
y, m, d = iso.split("-")
mon = MONTHS[lang][int(m) - 1]
return f"{int(d)}. {mon} {y}" if lang == "si" else f"{int(d)} {mon} {y}"
def rootrel_img(src):
m = re.search(r'(images/.*)$', src)
return "/" + m.group(1) if m else src
def collect_items(lang):
items = []
for folder in ("news", "blog", "projects"):
base = os.path.join(ROOT, lang, folder)
if not os.path.isdir(base):
continue
for slug in sorted(os.listdir(base)):
page = os.path.join(base, slug, "index.html")
if not os.path.exists(page):
continue
key = f"{folder}/{slug}"
date = DATES.get(key, {})
iso = date.get("published") or date.get("modified")
if not iso:
# 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
desc = (DESC_RE.search(html) or [None, ""])[1]
img = None
fig = FIG_RE.search(html)
if fig:
im = IMG_RE.search(fig.group(1))
if im:
a = im.group(1)
# data-card, when present, is a promo thumbnail variant to
# use in listings instead of the in-article hero image.
src = ATTR_RE("data-card", a) or ATTR_RE("src", a)
if src:
img = {"src": rootrel_img(src), "w": ATTR_RE("width", a),
"h": ATTR_RE("height", a), "alt": ATTR_RE("alt", a) or title}
items.append({"folder": folder, "slug": slug, "iso": iso, "title": title,
"excerpt": desc, "img": img, "url": f"/{lang}/{folder}/{slug}/"})
items.sort(key=lambda x: x["iso"], reverse=True)
return items
def badge(folder, lang):
dtype, mod, icon, labels = TYPES[folder]
return dtype, f'<span class="news-badge news-badge--{mod}"><i class="fas {icon}" aria-hidden="true"></i> {labels[lang]}</span>'
def esc_attr(s):
return s.replace('"', "&quot;")
def media(it, folder, lang):
_, _, icon, _ = TYPES[folder]
if it["img"]:
i = it["img"]
wh = (f' width="{i["w"]}"' if i["w"] else "") + (f' height="{i["h"]}"' if i["h"] else "")
return f'<img{wh} src="{i["src"]}" alt="{esc_attr(i["alt"])}" loading="lazy">'
return f'<div class="news-ph"><i class="fas {icon}" aria-hidden="true"></i></div>'
def news_hub_card(it, lang):
dtype, badge_html = badge(it["folder"], lang)
return (
f' <article class="newshub-card" data-type="{dtype}">\n'
f' <a class="newshub-media" href="{it["url"]}">\n'
f' {media(it, it["folder"], lang)}\n'
f' {badge_html}\n'
f' </a>\n'
f' <div class="newshub-body">\n'
f' <p class="newshub-date">{fmt_date(lang, it["iso"])}</p>\n'
f' <h3><a href="{it["url"]}">{it["title"]}</a></h3>\n'
f' <p class="newshub-excerpt">{it["excerpt"]}</p>\n'
f' </div>\n'
f' </article>'
)
def landing_card(it, lang):
dtype, badge_html = badge(it["folder"], lang)
return (
f' <article class="news-card" data-type="{dtype}">\n'
f' <a class="news-card-media" href="{it["url"]}">\n'
f' {media(it, it["folder"], lang)}\n'
f' {badge_html}\n'
f' </a>\n'
f' <div class="card-content">\n'
f' <p class="author">{fmt_date(lang, it["iso"])}</p>\n'
f' <h3><a href="{it["url"]}">{it["title"]}</a></h3>\n'
f' </div>\n'
f' </article>'
)
def replace_block(html, start, end, inner, insert_pat=None):
"""Replace content between markers; if markers absent, use insert_pat regex
(group1)(...content...)(group3) to seed them."""
block = f'{start}\n{inner}\n {end}'
if start in html and end in html:
return re.sub(re.escape(start) + r".*?" + re.escape(end), block, html, count=1, flags=re.DOTALL)
if insert_pat:
m = insert_pat.search(html)
if m:
return html[:m.start()] + m.group(1) + "\n" + block + "\n " + m.group(3) + html[m.end():]
return html
NEWS_GRID_PAT = re.compile(r'(<div class="newshub-grid">)(.*)(</div>\s*<nav class="pagination newshub-pagination")', re.DOTALL)
LANDING_PAT = re.compile(r'(<div class="news-articles">)(.*?)(</div>\s*<a href="[^"]*" class="btn btn-secondary">)', re.DOTALL)
def run():
for lang in LANGS:
items = collect_items(lang)
if not items:
print(f" [{lang}] no dated content found"); continue
hub = os.path.join(ROOT, lang, "news", "index.html")
if os.path.exists(hub):
html = io.open(hub, encoding="utf-8").read()
inner = "\n".join(news_hub_card(it, lang) for it in items)
html = replace_block(html, "<!-- AUTO-NEWS:START -->", "<!-- AUTO-NEWS:END -->",
inner, NEWS_GRID_PAT)
io.open(hub, "w", encoding="utf-8").write(html)
home = os.path.join(ROOT, lang, "index.html")
if os.path.exists(home):
html = io.open(home, encoding="utf-8").read()
inner = "\n".join(landing_card(it, lang) for it in items[:N_LATEST])
html = replace_block(html, "<!-- AUTO-LATEST-NEWS:START -->", "<!-- AUTO-LATEST-NEWS:END -->",
inner, LANDING_PAT)
io.open(home, "w", encoding="utf-8").write(html)
print(f" [{lang}] News hub: {len(items)} items | homepage: top {min(N_LATEST, len(items))}")
if __name__ == "__main__":
print("Rebuilding News hub + homepage 'Latest news' from Blog/Projects/News pages:")
run()
print("Done.")