9bdb41ea6a
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.
140 lines
4.9 KiB
Python
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())
|