Files
esh-pfi-infrastructure/stacks/news-digest/digest.py
T
vh 40f1e0ee00 news-digest: real article summaries + per-desk collapse
Two upgrades to make the digest actually readable:

1) Article-grounded 2-3 sentence summaries (everywhere)

   The old prompt got just the title + miniflux's content excerpt,
   which for HN/Lobsters/wire feeds is barely more than the title
   itself — so summaries paraphrased the title and added nothing.

   Now every URL gets fetched and main-content-extracted via
   trafilatura on a parallel pre-pass (10 workers, ~15s for ~50
   URLs). Extracted text caches to /output/.article-cache.json with
   a 7-day TTL so repeat runs in the same window don't re-pull.

   Headlines also get summarized now — one batched LLM call per
   category (world / local). Rendered as a paragraph below the
   title with source + time on the right rail.

   Prompt rewrites tell the model to pull names/numbers/places
   from the body and explicitly forbid restating the title.
   Result: real specifics ("71% saw no pay increase globally",
   "third time in less than two weeks", "Islamabad and Moscow
   intermediaries") instead of title paraphrase.

2) Per-desk collapse buttons

   Chevron next to .desk-count toggles a .is-collapsed class.
   Collapsed state is per-device (localStorage by section id) since
   collapse is a viewing preference, not content state.
2026-04-28 11:29:15 -07:00

711 lines
27 KiB
Python

"""news-digest — twice-daily LLM-curated briefing across subreddits + Miniflux.
Runs from cron at 0800 / 2000 local. Each invocation:
1. Pulls the subreddit list from Miniflux (any feed whose URL starts
with https://www.reddit.com/r/) — single source of truth, no
duplicated config.
2. Hits Reddit's public JSON API per subreddit for top-of-day,
filters by score + upvote ratio.
3. Pulls non-Reddit recent items from Miniflux (Tech aggregators
category — HN, Lobste.rs).
4. Batches each source through llama-swap on ana-ml2 with a
terse summarization prompt (one call per source).
5. Renders the Jinja2 template + CSS to /output/index.html
(atomic write via .tmp + rename).
6. Also writes /output/edition-YYYY-MM-DD-<am|pm>.html as an archive.
All tunables are environment-driven; see .env.example for the full
list. Designed to be a one-shot invocation — it does not loop or daemon.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable, Optional
import requests
from jinja2 import Environment, FileSystemLoader, select_autoescape
def _stable_id(*parts: str) -> str:
"""12-char sha1 prefix used as the per-item id for the X-button-to-hide
feature. Stable across editions (built from the source's native id),
cross-source-unique (prefixed with the source kind), and short enough
to live in JSON without bloat."""
h = hashlib.sha1()
for p in parts:
h.update(p.encode("utf-8", errors="replace"))
h.update(b"\x00")
return h.hexdigest()[:12]
# ── env config ───────────────────────────────────────────────────────
LLAMA_SWAP_URL = os.environ.get("LLAMA_SWAP_URL", "http://10.250.50.54:9292")
LLAMA_SWAP_MODEL = os.environ.get("LLAMA_SWAP_MODEL", "qwen3.5-35-a3b")
LLAMA_SWAP_TIMEOUT = int(os.environ.get("LLAMA_SWAP_TIMEOUT", "180"))
MINIFLUX_URL = os.environ.get("MINIFLUX_URL", "http://miniflux:8080")
MINIFLUX_USER = os.environ.get("MINIFLUX_USER", "lkraven")
MINIFLUX_PASS = os.environ.get("MINIFLUX_PASSWORD", "")
OUTPUT_DIR = Path(os.environ.get("DIGEST_OUTPUT_DIR", "/output"))
TEMPLATE_DIR = Path(os.environ.get("DIGEST_TEMPLATE_DIR", "/app/templates"))
REDDIT_HOURS = int(os.environ.get("DIGEST_REDDIT_HOURS", "12"))
REDDIT_MIN_SCORE = int(os.environ.get("DIGEST_MIN_SCORE", "50"))
REDDIT_MIN_RATIO = float(os.environ.get("DIGEST_MIN_RATIO", "0.85"))
REDDIT_MAX_PER_SUB = int(os.environ.get("DIGEST_MAX_PER_SUB", "8"))
REDDIT_USER_AGENT = os.environ.get(
"DIGEST_REDDIT_USER_AGENT",
"news-digest:phasefinal:0.1.0 (anonymous)",
)
MINIFLUX_TECH_CATEGORY = os.environ.get(
"DIGEST_MINIFLUX_TECH_CATEGORY", "Tech aggregators"
)
MINIFLUX_HOURS = int(os.environ.get("DIGEST_MINIFLUX_HOURS", "12"))
MINIFLUX_MAX_PER_SOURCE = int(os.environ.get("DIGEST_MINIFLUX_MAX", "8"))
# Headlines (world + local) — high-volume sections, no LLM summarization.
MINIFLUX_WORLD_CATEGORY = os.environ.get(
"DIGEST_MINIFLUX_WORLD_CATEGORY", "World"
)
MINIFLUX_LOCAL_CATEGORY = os.environ.get(
"DIGEST_MINIFLUX_LOCAL_CATEGORY", "Local"
)
MINIFLUX_HEADLINES_HOURS = int(os.environ.get("DIGEST_MINIFLUX_HEADLINES_HOURS", "8"))
MINIFLUX_HEADLINES_MAX = int(os.environ.get("DIGEST_MINIFLUX_HEADLINES_MAX", "15"))
TZ_NAME = os.environ.get("TZ", "America/Los_Angeles")
# ── data shapes ──────────────────────────────────────────────────────
@dataclass
class Item:
"""A single curated post — Reddit or Miniflux origin."""
id: str
title: str
url: str # external link or HTML permalink
permalink: str # discussion / source URL (Reddit thread, HN comments)
body: str # selftext / description (may be empty)
author: str
score: Optional[int] # Reddit upvotes if known
comments: Optional[int]
upvote_ratio: Optional[float]
posted_at: datetime
# Filled by summarize step:
tldr: str = ""
tag: str = ""
@dataclass
class Source:
"""A logical grouping of items shown as one section in the digest."""
name: str # display name ("r/selfhosted", "Hacker News")
kind: str # "reddit" | "miniflux"
href: str # link to the source's homepage / sub
items: list[Item] = field(default_factory=list)
@dataclass
class Headline:
"""One row in the dense world/local headlines list."""
id: str
title: str
url: str
source: str # display name of the originating feed
posted_at: datetime
tldr: str = "" # 2-3 sentence LLM summary of the linked article
# ── http session shared across calls ─────────────────────────────────
S = requests.Session()
S.headers["User-Agent"] = REDDIT_USER_AGENT
def log(msg: str) -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
# ── article-text cache ───────────────────────────────────────────────
# Most feeds ship just titles + thin excerpts. Real summaries need the
# article body, so we fetch + extract with trafilatura. Cache to disk
# so re-runs on the same window don't re-pull.
ARTICLE_CACHE_PATH = OUTPUT_DIR / ".article-cache.json"
ARTICLE_CACHE_TTL_HOURS = 7 * 24 # keep extracted text ~1 week
ARTICLE_FETCH_TIMEOUT = 12 # seconds per URL
ARTICLE_TEXT_CAP = 4000 # chars; LLM doesn't need more
ARTICLE_FETCH_WORKERS = 10 # parallel fetches per warm pass
REDDIT_DOMAIN_RE = re.compile(r"^https?://(?:[^/]*\.)?reddit\.com/", re.I)
def article_cache_load() -> dict:
if not ARTICLE_CACHE_PATH.exists():
return {}
try:
return json.loads(ARTICLE_CACHE_PATH.read_text())
except Exception:
return {}
def article_cache_save(cache: dict) -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
tmp = ARTICLE_CACHE_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(cache))
tmp.rename(ARTICLE_CACHE_PATH)
def fetch_article_text(url: str, cache: dict) -> str:
"""Return main-content text for `url`, cached. Empty string on any
failure — caller is expected to fall back to the feed body / title.
Skips reddit.com URLs (callers already have selftext as `body`)
and anything that 404s, paywalls, or extracts to less than a
paragraph."""
if not url or REDDIT_DOMAIN_RE.match(url):
return ""
key = hashlib.sha1(url.encode("utf-8")).hexdigest()
now = int(time.time())
cached = cache.get(key)
if cached and (now - int(cached.get("ts", 0))) < ARTICLE_CACHE_TTL_HOURS * 3600:
return cached.get("text", "")
try:
import trafilatura
downloaded = trafilatura.fetch_url(url)
if not downloaded:
cache[key] = {"ts": now, "text": ""}
return ""
text = trafilatura.extract(
downloaded,
include_comments=False,
include_tables=False,
no_fallback=False,
) or ""
text = text.strip()[:ARTICLE_TEXT_CAP]
cache[key] = {"ts": now, "text": text}
return text
except Exception as e:
log(f" ! article fetch failed for {url[:80]}: {e!r}")
cache[key] = {"ts": now, "text": ""}
return ""
def warm_article_cache(urls: Iterable[str], cache: dict) -> None:
"""Parallel-prefetch article text for `urls` into `cache`."""
pending = []
seen_urls: set[str] = set()
cutoff = int(time.time()) - ARTICLE_CACHE_TTL_HOURS * 3600
for url in urls:
if not url or url in seen_urls or REDDIT_DOMAIN_RE.match(url):
continue
seen_urls.add(url)
key = hashlib.sha1(url.encode("utf-8")).hexdigest()
cached = cache.get(key)
if cached and int(cached.get("ts", 0)) > cutoff:
continue
pending.append(url)
if not pending:
return
log(f" warming article cache: {len(pending)} URLs ({ARTICLE_FETCH_WORKERS} parallel)")
t0 = time.time()
with ThreadPoolExecutor(max_workers=ARTICLE_FETCH_WORKERS) as ex:
list(ex.map(lambda u: fetch_article_text(u, cache), pending))
log(f" done in {time.time() - t0:.1f}s")
# ── miniflux: discover subreddits + pull tech-aggregator items ───────
def miniflux_get(path: str, **params) -> Any:
url = f"{MINIFLUX_URL.rstrip('/')}{path}"
r = S.get(url, params=params, auth=(MINIFLUX_USER, MINIFLUX_PASS), timeout=20)
r.raise_for_status()
return r.json()
REDDIT_FEED_RE = re.compile(r"^https?://(?:www\.)?reddit\.com/r/([^/]+)/", re.I)
def discover_subreddits_from_miniflux() -> list[str]:
"""Return list of subreddit names extracted from Miniflux's feed URLs."""
feeds = miniflux_get("/v1/feeds")
subs: list[str] = []
for f in feeds:
m = REDDIT_FEED_RE.match(f.get("feed_url", ""))
if m:
subs.append(m.group(1))
seen, deduped = set(), []
for s in subs:
k = s.lower()
if k not in seen:
deduped.append(s)
seen.add(k)
return deduped
def fetch_miniflux_tech_items() -> list[Source]:
"""Return one Source per non-Reddit feed in the configured category."""
cats = miniflux_get("/v1/categories")
tech_cat = next(
(c for c in cats if c["title"].lower() == MINIFLUX_TECH_CATEGORY.lower()),
None,
)
if not tech_cat:
log(f"miniflux: category {MINIFLUX_TECH_CATEGORY!r} not found, skipping")
return []
cutoff = int((datetime.now(timezone.utc) - timedelta(hours=MINIFLUX_HOURS)).timestamp())
entries = miniflux_get(
"/v1/entries",
category_id=tech_cat["id"],
published_after=cutoff,
order="published_at",
direction="desc",
limit=200,
)
by_feed: dict[int, Source] = {}
for e in entries.get("entries", []):
feed = e.get("feed") or {}
if REDDIT_FEED_RE.match(feed.get("feed_url", "")):
continue # handled in Reddit pass
fid = feed.get("id")
if fid is None:
continue
src = by_feed.get(fid)
if src is None:
src = Source(
name=feed.get("title", "?"),
kind="miniflux",
href=feed.get("site_url") or feed.get("feed_url") or "",
)
by_feed[fid] = src
if len(src.items) >= MINIFLUX_MAX_PER_SOURCE:
continue
src.items.append(Item(
id=_stable_id("miniflux", str(e["id"])),
title=e.get("title", "(untitled)"),
url=e.get("url", ""),
permalink=e.get("url", ""),
body=(e.get("content") or "")[:1500],
author=e.get("author", ""),
score=None,
comments=None,
upvote_ratio=None,
posted_at=_parse_dt(e.get("published_at")),
))
return [s for s in by_feed.values() if s.items]
def fetch_miniflux_headlines(category_name: str) -> list[Headline]:
"""Pull recent items from a miniflux category as flat headlines.
Used for high-volume sections (world / local) where headlines move
fast and the volume justifies a dense list rather than the per-source
cards used for tech / reddit. No LLM summarization — the title is
the deliverable. Cross-feed dedup by lowercased title (different
feeds syndicate the same wire stories)."""
cats = miniflux_get("/v1/categories")
cat = next(
(c for c in cats if c["title"].lower() == category_name.lower()),
None,
)
if not cat:
log(f"miniflux: category {category_name!r} not found, skipping")
return []
cutoff = int(
(datetime.now(timezone.utc) - timedelta(hours=MINIFLUX_HEADLINES_HOURS)).timestamp()
)
entries = miniflux_get(
"/v1/entries",
category_id=cat["id"],
published_after=cutoff,
order="published_at",
direction="desc",
limit=200,
)
headlines: list[Headline] = []
seen: set[str] = set()
for e in entries.get("entries", []):
title = (e.get("title") or "(untitled)").strip()
key = title.lower()
if key in seen:
continue
seen.add(key)
feed = e.get("feed") or {}
headlines.append(Headline(
id=_stable_id("headline", str(e["id"])),
title=title,
url=e.get("url", ""),
source=feed.get("title", "?"),
posted_at=_parse_dt(e.get("published_at")),
))
if len(headlines) >= MINIFLUX_HEADLINES_MAX:
break
return headlines
def _parse_dt(s: Optional[str]) -> datetime:
if not s:
return datetime.now(timezone.utc)
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
return datetime.now(timezone.utc)
# ── reddit JSON: top-of-day per subreddit ────────────────────────────
def fetch_reddit_top(sub: str) -> Source:
log(f"reddit: r/{sub}")
url = f"https://www.reddit.com/r/{sub}/top/.json"
params = {"t": "day", "limit": 25}
r = S.get(url, params=params, timeout=20)
if not r.ok:
log(f" HTTP {r.status_code}: skipping")
return Source(name=f"r/{sub}", kind="reddit", href=f"https://reddit.com/r/{sub}")
posts = r.json().get("data", {}).get("children", [])
cutoff_ts = (datetime.now(timezone.utc) - timedelta(hours=REDDIT_HOURS)).timestamp()
items: list[Item] = []
for p in posts:
d = p.get("data", {})
score = d.get("score", 0)
ratio = d.get("upvote_ratio", 0.0)
created = d.get("created_utc", 0)
if score < REDDIT_MIN_SCORE: continue
if ratio < REDDIT_MIN_RATIO: continue
if created < cutoff_ts: continue
items.append(Item(
id=_stable_id("reddit", d.get("id", "")),
title=d.get("title", "(untitled)"),
url=d.get("url", ""),
permalink=f"https://reddit.com{d.get('permalink', '')}",
body=(d.get("selftext") or "")[:1500],
author=d.get("author", "[deleted]"),
score=score,
comments=d.get("num_comments"),
upvote_ratio=ratio,
posted_at=datetime.fromtimestamp(created, tz=timezone.utc),
))
items.sort(key=lambda x: (x.score or 0), reverse=True)
items = items[:REDDIT_MAX_PER_SUB]
log(f" kept {len(items)} (score>={REDDIT_MIN_SCORE}, ratio>={REDDIT_MIN_RATIO})")
return Source(name=f"r/{sub}", kind="reddit",
href=f"https://reddit.com/r/{sub}", items=items)
# ── llama-swap: batched summarization per source ─────────────────────
SUMMARIZE_SYSTEM = (
"You are a curator producing a tight intelligence briefing for an "
"engineer who reads many feeds. You are concise, neutral, and never "
"editorialize. You write summaries grounded in the article body — "
"never paraphrase the title back at the reader. You skip pure "
"shitposts and screenshots-without-context."
)
SUMMARIZE_USER_TEMPLATE = """Given the {n} posts from {source} below, return a JSON ARRAY where each element has:
- "id": the post id from the input
- "tldr": 2-3 sentences (40-80 words) summarizing the SUBSTANCE — what happened, what was announced, what conclusion the author drew. Pull facts, names, numbers from the body. Do NOT restate the title; the reader already sees it. Do NOT begin with "this post" / "the article" / "a user". If the body is too thin to add anything beyond the title, return tldr="".
- "tag": ONE word from {{news, tutorial, release, discussion, question, showcase, drama, meme, other}}
If a post is a pure shitpost / screenshot-without-context / duplicate of another item in this batch, set "tldr" to "" and "tag" to "skip".
Output ONLY the JSON array. No prose, no markdown fence.
POSTS:
{posts_json}
"""
HEADLINE_SUMMARIZE_USER_TEMPLATE = """Given the {n} {label} headlines below, return a JSON ARRAY where each element has:
- "id": the headline id from the input
- "tldr": 2-3 sentences (40-80 words) summarizing the article body — who, what, when, where, why. Pull names, numbers, places from the body. Do NOT restate the headline; the reader already sees it. Do NOT editorialize. If the body is too thin (e.g. just the headline rehashed), return tldr="".
Output ONLY the JSON array. No prose, no markdown fence.
HEADLINES:
{posts_json}
"""
def _llm_chat(messages: list[dict], label: str) -> dict[str, dict]:
"""Send a chat request and parse the JSON-array reply into a
{id: row} map. Returns {} on any failure (caller falls back to
raw titles)."""
try:
r = S.post(
f"{LLAMA_SWAP_URL.rstrip('/')}/v1/chat/completions",
json={
"model": LLAMA_SWAP_MODEL,
"messages": messages,
"temperature": 0.2,
"max_tokens": 4000,
},
timeout=LLAMA_SWAP_TIMEOUT,
)
r.raise_for_status()
msg = r.json()["choices"][0]["message"]
# Extended-thinking models (Qwen3.x) put output in
# reasoning_content while content is still streaming. Fall back
# so we get something to parse.
content = (msg.get("content") or msg.get("reasoning_content") or "").strip()
# Some models wrap JSON in ```...``` even when told not to.
content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.M).strip()
return {x.get("id"): x for x in json.loads(content)}
except Exception as e:
log(f" ! llm failed for {label}: {e!r}")
return {}
def summarize_source(src: Source, cache: dict) -> None:
if not src.items:
return
posts_json = json.dumps([
{
"id": it.id,
"title": it.title,
# Real article text (cached) wins over feed-shipped excerpt.
# Falls back to feed body for self-posts (Reddit selftext)
# and any URL where extraction failed.
"body": (fetch_article_text(it.url, cache) or it.body or "")[:2500],
"url": it.url,
}
for it in src.items
], ensure_ascii=False)
user = SUMMARIZE_USER_TEMPLATE.format(
n=len(src.items),
source=src.name,
posts_json=posts_json,
)
log(f" llm: summarizing {len(src.items)} items from {src.name}")
mapped = _llm_chat(
[
{"role": "system", "content": SUMMARIZE_SYSTEM},
{"role": "user", "content": user},
],
src.name,
)
if not mapped:
return
for it in src.items:
m = mapped.get(it.id, {})
it.tldr = (m.get("tldr") or "").strip()
it.tag = (m.get("tag") or "").strip().lower()
# Drop skipped entries from the source.
src.items = [it for it in src.items if it.tag != "skip" and (it.tldr or it.score is None)]
log(f" -> {len(src.items)} kept after llm filter")
def summarize_headlines(headlines: list[Headline], label: str, cache: dict) -> None:
"""Batch-summarize a headline list in-place. One LLM call for the
whole batch. Quietly leaves tldr empty on failure so the dense
list still renders (just without summaries)."""
if not headlines:
return
posts_json = json.dumps([
{
"id": h.id,
"title": h.title,
"source": h.source,
"body": fetch_article_text(h.url, cache)[:2000],
}
for h in headlines
], ensure_ascii=False)
user = HEADLINE_SUMMARIZE_USER_TEMPLATE.format(
n=len(headlines),
label=label,
posts_json=posts_json,
)
log(f" llm: summarizing {len(headlines)} {label} headlines")
mapped = _llm_chat(
[
{"role": "system", "content": SUMMARIZE_SYSTEM},
{"role": "user", "content": user},
],
f"{label} headlines",
)
if not mapped:
return
for h in headlines:
m = mapped.get(h.id, {})
h.tldr = (m.get("tldr") or "").strip()
# ── render ───────────────────────────────────────────────────────────
def render(reddit_sources: list[Source], tech_sources: list[Source],
world_headlines: list[Headline], local_headlines: list[Headline],
generated_at: datetime) -> str:
env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
trim_blocks=True,
lstrip_blocks=True,
)
env.filters["humanago"] = _humanago
env.filters["domain"] = _domain
template = env.get_template("digest.html.j2")
edition = "morning" if generated_at.hour < 14 else "evening"
reddit_kept = [s for s in reddit_sources if s.items]
tech_kept = [s for s in tech_sources if s.items]
return template.render(
reddit_sources=reddit_kept,
tech_sources=tech_kept,
world_headlines=world_headlines,
local_headlines=local_headlines,
reddit_total=sum(len(s.items) for s in reddit_kept),
tech_total=sum(len(s.items) for s in tech_kept),
world_total=len(world_headlines),
local_total=len(local_headlines),
generated_at=generated_at,
edition=edition,
edition_short="AM" if edition == "morning" else "PM",
model=LLAMA_SWAP_MODEL,
date_long=generated_at.strftime("%A %B %-d, %Y"),
time_short=generated_at.strftime("%-I:%M %p"),
tz=generated_at.tzname() or TZ_NAME,
next_edition=("evening" if edition == "morning" else "morning"),
)
def _humanago(d: datetime) -> str:
delta = datetime.now(timezone.utc) - d
s = int(delta.total_seconds())
if s < 60: return f"{s}s"
if s < 3600: return f"{s // 60}m"
if s < 86400: return f"{s // 3600}h"
return f"{s // 86400}d"
def _domain(url: str) -> str:
m = re.match(r"^https?://(?:www\.)?([^/]+)", url or "")
return m.group(1) if m else ""
def write_output(html: str, generated_at: datetime) -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
edition = "am" if generated_at.hour < 14 else "pm"
archive = OUTPUT_DIR / f"edition-{generated_at:%Y-%m-%d}-{edition}.html"
index = OUTPUT_DIR / "index.html"
archive_tmp = archive.with_suffix(".html.tmp")
archive_tmp.write_text(html, encoding="utf-8")
archive_tmp.rename(archive)
index_tmp = index.with_suffix(".html.tmp")
index_tmp.write_text(html, encoding="utf-8")
index_tmp.rename(index)
log(f"wrote {index} (and archive {archive.name})")
write_archive_index(generated_at)
ARCHIVE_FNAME_RE = re.compile(r"^edition-(\d{4}-\d{2}-\d{2})-(am|pm)\.html$")
def write_archive_index(generated_at: datetime) -> None:
"""Render /output/archive.html — list every edition-*.html in
OUTPUT_DIR, newest-first. Cheap (re-runs every digest fire);
template loads from the same TEMPLATE_DIR."""
editions = []
for p in OUTPUT_DIR.glob("edition-*.html"):
m = ARCHIVE_FNAME_RE.match(p.name)
if not m:
continue
date_str, ed = m.group(1), m.group(2)
try:
date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
continue
editions.append({
"filename": p.name,
"date": date,
"edition": "morning" if ed == "am" else "evening",
"edition_short": ed.upper(),
"date_long": date.strftime("%A %B %-d, %Y"),
# Sort key: date descending, then PM before AM (within a day,
# PM is the most recent edition).
"_sort": (date, 1 if ed == "pm" else 0),
})
editions.sort(key=lambda e: e["_sort"], reverse=True)
env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
trim_blocks=True,
lstrip_blocks=True,
)
tpl = env.get_template("archive.html.j2")
html = tpl.render(
editions=editions,
generated_at=generated_at,
total=len(editions),
)
out = OUTPUT_DIR / "archive.html"
out_tmp = out.with_suffix(".html.tmp")
out_tmp.write_text(html, encoding="utf-8")
out_tmp.rename(out)
log(f"wrote {out} ({len(editions)} editions indexed)")
# ── main ─────────────────────────────────────────────────────────────
def main() -> int:
if not MINIFLUX_PASS:
print("MINIFLUX_PASSWORD not set — bailing", file=sys.stderr)
return 2
try:
from zoneinfo import ZoneInfo
now_local = datetime.now(ZoneInfo(TZ_NAME))
except Exception:
now_local = datetime.now()
log(f"starting digest run at {now_local.isoformat()} ({'AM' if now_local.hour < 14 else 'PM'})")
log("phase 1: discovering subreddits from miniflux")
subs = discover_subreddits_from_miniflux()
log(f" found {len(subs)} subreddits: {', '.join(subs) or '(none)'}")
log("phase 2: fetching reddit top-of-day per subreddit")
reddit_sources: list[Source] = []
for sub in subs:
reddit_sources.append(fetch_reddit_top(sub))
time.sleep(1.5) # gentle to anonymous Reddit
log("phase 3a: fetching world headlines from miniflux")
world_headlines = fetch_miniflux_headlines(MINIFLUX_WORLD_CATEGORY)
log(f" found {len(world_headlines)} world headlines")
log("phase 3b: fetching local headlines from miniflux")
local_headlines = fetch_miniflux_headlines(MINIFLUX_LOCAL_CATEGORY)
log(f" found {len(local_headlines)} local headlines")
log("phase 3c: fetching tech-aggregator items from miniflux")
tech_sources = fetch_miniflux_tech_items()
log(f" found {len(tech_sources)} non-reddit feeds with recent items")
log("phase 3d: warming article-text cache (parallel)")
article_cache = article_cache_load()
all_urls: list[str] = []
for src in tech_sources + reddit_sources:
for it in src.items:
all_urls.append(it.url)
for h in world_headlines + local_headlines:
all_urls.append(h.url)
warm_article_cache(all_urls, article_cache)
log("phase 4a: summarizing reddit + tech sources via llama-swap")
for src in reddit_sources + tech_sources:
summarize_source(src, article_cache)
log("phase 4b: summarizing world + local headlines via llama-swap")
summarize_headlines(world_headlines, "world", article_cache)
summarize_headlines(local_headlines, "local", article_cache)
article_cache_save(article_cache)
log("phase 5: rendering")
html = render(reddit_sources, tech_sources, world_headlines, local_headlines, now_local)
write_output(html, now_local)
log("done")
return 0
if __name__ == "__main__":
sys.exit(main())