#!/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
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())