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.
102 lines
4.1 KiB
YAML
102 lines
4.1 KiB
YAML
# news-digest — twice-daily LLM-curated briefing.
|
||
#
|
||
# Two containers in this stack, both built from the same Dockerfile:
|
||
#
|
||
# news-digest-worker — python + cron, runs digest.py at 0800/2000
|
||
# local, writes /output/index.html and
|
||
# /output/edition-*.html.
|
||
# news-digest-web — FastAPI on uvicorn, serves /output as static
|
||
# and exposes /api/{hidden,hide,restore} for
|
||
# the per-item × button (state in
|
||
# /output/hidden.json, shared across devices).
|
||
# Homepage card lives on this container.
|
||
#
|
||
# Same bind-mounted /output for both: worker writes HTML, web reads
|
||
# it back. Worker uses atomic writes (.tmp + rename) so partial pages
|
||
# never get served.
|
||
|
||
services:
|
||
news-digest-worker:
|
||
image: local/news-digest:${NEWS_DIGEST_TAG:-v1}
|
||
build:
|
||
context: .
|
||
dockerfile: Dockerfile
|
||
container_name: news-digest-worker
|
||
restart: unless-stopped
|
||
environment:
|
||
- TZ=${NEWS_DIGEST_TZ:-America/Los_Angeles}
|
||
- LLAMA_SWAP_URL=${LLAMA_SWAP_URL:-http://10.250.50.54:9292}
|
||
- LLAMA_SWAP_MODEL=${LLAMA_SWAP_MODEL:-qwen3.5-35-a3b}
|
||
- LLAMA_SWAP_TIMEOUT=${LLAMA_SWAP_TIMEOUT:-180}
|
||
- MINIFLUX_URL=${MINIFLUX_URL:-http://miniflux:8080}
|
||
- MINIFLUX_USER=${MINIFLUX_USER:-lkraven}
|
||
- MINIFLUX_PASSWORD=${MINIFLUX_PASSWORD}
|
||
- DIGEST_OUTPUT_DIR=/output
|
||
- DIGEST_TEMPLATE_DIR=/app/templates
|
||
- DIGEST_REDDIT_HOURS=${DIGEST_REDDIT_HOURS:-12}
|
||
- DIGEST_MIN_SCORE=${DIGEST_MIN_SCORE:-50}
|
||
- DIGEST_MIN_RATIO=${DIGEST_MIN_RATIO:-0.85}
|
||
- DIGEST_MAX_PER_SUB=${DIGEST_MAX_PER_SUB:-8}
|
||
- DIGEST_MINIFLUX_HOURS=${DIGEST_MINIFLUX_HOURS:-12}
|
||
- DIGEST_MINIFLUX_MAX=${DIGEST_MINIFLUX_MAX:-8}
|
||
- DIGEST_MINIFLUX_TECH_CATEGORY=${DIGEST_MINIFLUX_TECH_CATEGORY:-Tech aggregators}
|
||
- DIGEST_MINIFLUX_WORLD_CATEGORY=${DIGEST_MINIFLUX_WORLD_CATEGORY:-World}
|
||
- DIGEST_MINIFLUX_LOCAL_CATEGORY=${DIGEST_MINIFLUX_LOCAL_CATEGORY:-Local}
|
||
- DIGEST_MINIFLUX_HEADLINES_HOURS=${DIGEST_MINIFLUX_HEADLINES_HOURS:-8}
|
||
- DIGEST_MINIFLUX_HEADLINES_MAX=${DIGEST_MINIFLUX_HEADLINES_MAX:-15}
|
||
volumes:
|
||
- ${NEWS_DIGEST_OUTPUT_DIR}:/output
|
||
networks:
|
||
- tnet
|
||
# Cron-driven worker — no healthcheck endpoint. The web container
|
||
# is what users hit; if the worker dies we'll see stale content.
|
||
# Restart policy handles transient crashes.
|
||
|
||
news-digest-web:
|
||
image: local/news-digest:${NEWS_DIGEST_TAG:-v1}
|
||
build:
|
||
context: .
|
||
dockerfile: Dockerfile
|
||
container_name: news-digest-web
|
||
restart: unless-stopped
|
||
depends_on:
|
||
- news-digest-worker
|
||
ports:
|
||
- "${NEWS_DIGEST_BIND:-0.0.0.0}:${NEWS_DIGEST_PORT}:80"
|
||
volumes:
|
||
# Read-write here so the API can persist hidden.json. Worker also
|
||
# writes here (HTML); both processes serialize via filenames they
|
||
# don't share, plus uvicorn's threading.Lock around hidden.json.
|
||
- ${NEWS_DIGEST_OUTPUT_DIR}:/output
|
||
environment:
|
||
- DIGEST_OUTPUT_DIR=/output
|
||
# Override the worker's cron entrypoint to launch uvicorn instead.
|
||
# tini still wraps the process for clean signal forwarding.
|
||
entrypoint: ["/sbin/tini", "--"]
|
||
command: ["uvicorn", "web:app", "--host", "0.0.0.0", "--port", "80",
|
||
"--no-access-log"]
|
||
healthcheck:
|
||
# 127.0.0.1 instead of localhost — alpine's busybox wget tries
|
||
# IPv6 first when localhost resolves to both ::1 and 127.0.0.1
|
||
# (per /etc/hosts). uvicorn only binds 0.0.0.0 (IPv4), so the
|
||
# v6 attempt gets connection-refused and busybox doesn't fall
|
||
# back. Pin to v4 explicitly.
|
||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||
interval: 30s
|
||
timeout: 5s
|
||
retries: 3
|
||
start_period: 30s
|
||
networks:
|
||
- tnet
|
||
labels:
|
||
- homepage.group=News
|
||
- homepage.name=Daily Digest
|
||
- homepage.icon=mdi-newspaper-variant-outline
|
||
- homepage.description=LLM-curated briefing across feeds, twice daily
|
||
- homepage.href=http://10.250.50.70:${NEWS_DIGEST_PORT}
|
||
|
||
networks:
|
||
tnet:
|
||
name: traefik-net
|
||
external: true
|