Files
esh-pfi-infrastructure/stacks/news-digest/seed-headlines.py
T
vh 9bdb41ea6a news-digest: add world + local headlines sections
Two new dense headline rails above the existing reddit/tech cards.
Designed for high-volume "what happened" coverage where the title
is the deliverable — no LLM summarization, ~15 items per section,
6-column-collapsing grid (title / source / time).

Digest pipeline:
  * fetch_miniflux_headlines(category) — flat list per category, dedup
    by lowercased title (different feeds syndicate the same wire stories)
  * 8h look-back window (vs 12h for tech/reddit) since headlines move
    faster
  * cap of 15 per section (DIGEST_MINIFLUX_HEADLINES_MAX)

Frontend:
  * .headline element parallels .item for the hide-button machinery
    (both have data-id, both honored by app.js)
  * dense 3-col layout collapses to 1-col on narrow screens
  * jumpnav now numbers world=01, local=02, reddit=03, tech=04

Setup:
  * seed-headlines.py — one-shot script (lives in the image at
    /app/seed-headlines.py). Creates the World + Local categories in
    miniflux, subscribes a curated feed list, and renames each feed
    to a short display title (BBC vs "BBC News", "LA Times" vs "California").
    Idempotent — reruns only add new feeds.
  * Default world: BBC, NPR, Al Jazeera. Default local: LA Times Local,
    LA Times CA, Voice of OC. (OC Register blocks miniflux; left out.)
  * entrypoint.sh now syncs templates/{style.css,app.js,favicon.svg}
    to /output on container start so frontend asset updates land
    without a manual copy after rebuild.
2026-04-28 11:14:15 -07:00

140 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""One-shot setup for the digest's World + Local headlines sections.
Creates the two miniflux categories (idempotent — no-op if present)
and subscribes a curated default feed list into each. Skips any feed
that's already subscribed in miniflux, so reruns are safe.
Run from inside the news-digest-worker container so the MINIFLUX_*
env vars are already set:
docker exec news-digest-worker python3 /app/seed-headlines.py
After this completes, the next digest run picks up the categories
automatically — no app restart needed.
"""
from __future__ import annotations
import os
import sys
import requests
MINIFLUX_URL = os.environ.get("MINIFLUX_URL", "http://miniflux:8080").rstrip("/")
MINIFLUX_USER = os.environ.get("MINIFLUX_USER", "lkraven")
MINIFLUX_PASS = os.environ.get("MINIFLUX_PASSWORD", "")
WORLD_CATEGORY = os.environ.get("DIGEST_MINIFLUX_WORLD_CATEGORY", "World")
LOCAL_CATEGORY = os.environ.get("DIGEST_MINIFLUX_LOCAL_CATEGORY", "Local")
# Default feed lists. Picked for: well-maintained RSS, low duplication
# across the set, mix of styles (wire-service vs editorial). Edit here
# and rerun the script to add/remove — already-subscribed feeds are
# skipped, so adding a new one only adds.
# (feed_url, display_title) — display_title overrides whatever miniflux
# pulls from the feed's <title> element. Keeps the dense headlines rail
# tidy ("BBC" beats "BBC News"; "Al Jazeera" beats the 60-char default).
WORLD_FEEDS = [
("http://feeds.bbci.co.uk/news/world/rss.xml", "BBC"),
("https://feeds.npr.org/1001/rss.xml", "NPR"),
("https://www.aljazeera.com/xml/rss/all.xml", "Al Jazeera"),
]
LOCAL_FEEDS = [
("https://www.latimes.com/local/rss2.0.xml", "LA Times"),
("https://www.latimes.com/california/rss2.0.xml", "LA Times CA"),
("https://voiceofoc.org/feed/", "Voice of OC"),
# OC Register blocks miniflux's fetcher (403). KTLA / Daily Pilot
# are options if more OC-specific coverage is needed later.
]
S = requests.Session()
S.auth = (MINIFLUX_USER, MINIFLUX_PASS)
def get(path: str) -> object:
r = S.get(f"{MINIFLUX_URL}{path}", timeout=20)
r.raise_for_status()
return r.json()
def post(path: str, body: dict) -> requests.Response:
return S.post(f"{MINIFLUX_URL}{path}", json=body, timeout=30)
def ensure_category(title: str) -> int:
"""Return the ID of the category named `title`, creating it if missing."""
cats = get("/v1/categories")
for c in cats:
if c["title"].lower() == title.lower():
print(f" category {title!r} already exists (id={c['id']})")
return c["id"]
r = post("/v1/categories", {"title": title})
r.raise_for_status()
cat_id = r.json()["id"]
print(f" created category {title!r} (id={cat_id})")
return cat_id
def find_feed_id(feed_url: str) -> int | None:
"""Return miniflux feed ID for `feed_url` if subscribed; else None.
Compares with both the original URL and miniflux's canonical
rewrite (some feeds get http→https'd or trailing-slashed at fetch
time)."""
feeds = get("/v1/feeds")
target = feed_url.rstrip("/")
for f in feeds:
url = (f.get("feed_url") or "").rstrip("/")
if url == target or url == target.replace("http://", "https://", 1):
return f["id"]
return None
def set_feed_title(feed_id: int, title: str) -> None:
r = S.put(f"{MINIFLUX_URL}/v1/feeds/{feed_id}", json={"title": title}, timeout=20)
if not r.ok:
print(f" ! rename failed ({r.status_code}): {r.text[:120]}")
def subscribe(feed_url: str, display_title: str, category_id: int) -> None:
existing = find_feed_id(feed_url)
if existing is not None:
print(f" skip (already subscribed): {feed_url}")
set_feed_title(existing, display_title)
return
r = post("/v1/feeds", {"feed_url": feed_url, "category_id": category_id})
if r.status_code in (200, 201):
new_id = r.json().get("feed_id")
print(f" + subscribed: {feed_url}")
if new_id:
set_feed_title(new_id, display_title)
else:
# Most common failure mode is a feed-discovery hiccup at
# miniflux's end — log loudly and continue so one bad URL
# doesn't block the rest.
print(f" ! FAILED ({r.status_code}): {feed_url} {r.text[:200]}")
def main() -> int:
if not MINIFLUX_PASS:
print("MINIFLUX_PASSWORD not set — bailing", file=sys.stderr)
return 2
print(f"miniflux: {MINIFLUX_URL} user={MINIFLUX_USER}")
print(f"\n{WORLD_CATEGORY}")
world_id = ensure_category(WORLD_CATEGORY)
for url, title in WORLD_FEEDS:
subscribe(url, title, world_id)
print(f"\n{LOCAL_CATEGORY}")
local_id = ensure_category(LOCAL_CATEGORY)
for url, title in LOCAL_FEEDS:
subscribe(url, title, local_id)
print("\ndone — next digest run will pick up the new sections.")
return 0
if __name__ == "__main__":
sys.exit(main())