extract news-digest into its own repo (vh/nevermore)

The applet outgrew "stack alongside the infra-management workspace" —
it has its own pyproject, multi-tenant deploy story, separate
release cadence, and isn't actually about managing infrastructure.
Lives at https://gitea.phasefinal.com/vh/nevermore now, with
provenance noted in its initial commit.

This commit removes:
  stacks/news-digest/             (full stack tree)
  playbooks/deploy-news-digest.yaml
  scripts/add-digest-user.sh

The existing ana-docker deployment continues running on its baked
local/news-digest:v5 image — nothing changes for the live install
until you choose to redeploy from the new repo. Migration steps
(rename data dir, redeploy, retire old compose dir) are in
nevermore's README.

Updated:
  README.md     — Current stacks listing now points at the new repo
  STATUS.md     — milestones entry for the extraction
This commit is contained in:
2026-04-29 17:18:14 -07:00
parent 21526a4a3c
commit 48aaa53c9d
18 changed files with 15 additions and 3621 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ Per-host snapshots of the running system live under `servers/<host>/system-detai
**Anaheim non-GPU (ana-docker):** **Anaheim non-GPU (ana-docker):**
- `traefik`, `crowdsec`, `gitea`, `vaultwarden`, `synapse`, `seafile`, `searxng`, `openwebui`, `sillytavern`, `mailrise`, `rustdesk`, `dockge`, `it-tools` - `traefik`, `crowdsec`, `gitea`, `vaultwarden`, `synapse`, `seafile`, `searxng`, `openwebui`, `sillytavern`, `mailrise`, `rustdesk`, `dockge`, `it-tools`
- (`mattermost` retired 2026-04-21 — compose dir may still linger, containers gone) - (`mattermost` retired 2026-04-21 — compose dir may still linger, containers gone)
- Notes / feeds: `miniflux` (RSS, 8080), `news-digest` (twice-daily LLM-curated brief, 8181, multi-tenant via `scripts/add-digest-user.sh`), `memos` (note server, 5230) - Notes / feeds: `miniflux` (RSS, 8080), `nevermore` (twice-daily LLM-curated brief, 8181, multi-tenant — extracted to its own repo at [`vh/nevermore`](https://gitea.phasefinal.com/vh/nevermore)), `memos` (note server, 5230)
- Assistant tooling: `task-board` (MCP + dashboard for assistant task state, 7878) - Assistant tooling: `task-board` (MCP + dashboard for assistant task state, 7878)
- Fleet services: `beszel` (metrics hub, port 8090), `dozzle-hub` (log viewer, 8088), `backrest` (restic UI, 9898) - Fleet services: `beszel` (metrics hub, port 8090), `dozzle-hub` (log viewer, 8088), `backrest` (restic UI, 9898)
- Backup target: `rest-server-ana` on port 8000 - Backup target: `rest-server-ana` on port 8000
+14
View File
@@ -507,6 +507,20 @@ Side fix: moved `stacks/llama-swap/config.yaml` →
the server layout (`stacks/<stack>/conf/<file>` the server layout (`stacks/<stack>/conf/<file>`
`/opt/docker/conf/<stack>/<file>`). `/opt/docker/conf/<stack>/<file>`).
## Session milestones — 2026-04-29 (Nevermore extraction)
The news-digest applet was extracted into its own repo at
[`vh/nevermore`](https://gitea.phasefinal.com/vh/nevermore) — clean
break, fresh git history, renamed throughout (DIGEST_* → NEVERMORE_*,
news-digest-* containers → nevermore-*, masthead → "Nevermore"). The
existing `news-digest-*` deployment on ana-docker continues to run
unchanged on its baked image; future updates come from the new repo.
Migration path documented in nevermore's README.
Removed from this workspace:
stacks/news-digest/, playbooks/deploy-news-digest.yaml,
scripts/add-digest-user.sh.
## Session milestones — 2026-04-28..29 (audio gen, digest applet, task-board polish, tooling) ## Session milestones — 2026-04-28..29 (audio gen, digest applet, task-board polish, tooling)
### Audio generation on irv-ml1 (A6000) ### Audio generation on irv-ml1 (A6000)
-186
View File
@@ -1,186 +0,0 @@
# Deploy news-digest (LLM-curated daily briefing) to ana-docker.
#
# Usage:
# scripts/elway ana-docker --playbook playbooks/deploy-news-digest.yaml
#
# Idempotent — every step is creates-/when-gated; rerun is safe.
#
# After first deploy, the .env still has CHANGE_ME for the Miniflux
# password. Edit it (see stacks/news-digest/README.md), then
# `docker compose up -d` again — the container will run the first
# digest at startup if /output is empty, so no need to wait for cron.
vars:
compose_dir: /opt/docker/compose/news-digest
output_dir: /opt/docker/data/news-digest
host_port: "8181"
steps:
# ── host-side dirs ──────────────────────────────────────────────────
- name: Ensure compose dir exists
shell: mkdir -p {{ compose_dir }}
creates: "{{ compose_dir }}"
- name: Ensure output dir exists (bind-mounted into both containers)
shell: mkdir -p {{ output_dir }}
creates: "{{ output_dir }}"
# ── deploy build context (compose, env, dockerfile, app, templates) ──
- name: Upload compose.yaml
upload:
src: stacks/news-digest/compose.yaml
dest: "{{ compose_dir }}/compose.yaml"
mode: "0644"
- name: Upload Dockerfile
upload:
src: stacks/news-digest/Dockerfile
dest: "{{ compose_dir }}/Dockerfile"
mode: "0644"
- name: Upload digest.py
upload:
src: stacks/news-digest/digest.py
dest: "{{ compose_dir }}/digest.py"
mode: "0644"
- name: Upload web.py (FastAPI for the web container)
upload:
src: stacks/news-digest/web.py
dest: "{{ compose_dir }}/web.py"
mode: "0644"
- name: Upload entrypoint.sh
upload:
src: stacks/news-digest/entrypoint.sh
dest: "{{ compose_dir }}/entrypoint.sh"
mode: "0755"
- name: Upload run-digest.sh
upload:
src: stacks/news-digest/run-digest.sh
dest: "{{ compose_dir }}/run-digest.sh"
mode: "0755"
- name: Upload crontab
upload:
src: stacks/news-digest/crontab
dest: "{{ compose_dir }}/crontab"
mode: "0644"
- name: Ensure templates dir exists
shell: mkdir -p {{ compose_dir }}/templates
creates: "{{ compose_dir }}/templates"
- name: Upload templates/digest.html.j2
upload:
src: stacks/news-digest/templates/digest.html.j2
dest: "{{ compose_dir }}/templates/digest.html.j2"
mode: "0644"
- name: Upload templates/style.css
upload:
src: stacks/news-digest/templates/style.css
dest: "{{ compose_dir }}/templates/style.css"
mode: "0644"
- name: Upload templates/favicon.svg
upload:
src: stacks/news-digest/templates/favicon.svg
dest: "{{ compose_dir }}/templates/favicon.svg"
mode: "0644"
- name: Upload templates/app.js (× button + hidden tray client)
upload:
src: stacks/news-digest/templates/app.js
dest: "{{ compose_dir }}/templates/app.js"
mode: "0644"
- name: Seed .env from template (only if absent)
upload:
src: stacks/news-digest/.env.example
dest: "{{ compose_dir }}/.env"
mode: "0644"
when: "[ ! -f {{ compose_dir }}/.env ]"
# ── build + bring up ────────────────────────────────────────────────
# The Dockerfile expects style.css to be inside the image too (it's
# copied via `COPY templates /app/templates`). nginx serves the
# generated index.html alongside its own copy of style.css from
# /output, so the worker writes a copy of style.css into /output too.
- name: docker compose build (~2-3 min first time)
# ana-docker is now on docker-ce 29 (post 2026-04-24 fleet upgrade)
# so BuildKit works natively — the old DOCKER_BUILDKIT=0 fallback is
# no longer needed. Strip BuildKit's progress UI lines for cleaner
# elway output.
shell: |
set -o pipefail
cd {{ compose_dir }} && docker compose build 2>&1 \
| grep -vE '^#[0-9]+ |^ => |^=> |Collecting|Downloading|Requirement|Using cached|Installing collected|Successfully (installed|built)|━'
- name: Pre-stage style.css + favicon.svg + app.js into /output
# The worker writes HTML that references "style.css", "favicon.svg",
# and "app.js" relative. None are generated dynamically; the web
# container serves whichever copy lands in /output. Copy all three
# from the templates dir at deploy-time.
shell: |
cp -f {{ compose_dir }}/templates/style.css {{ output_dir }}/style.css
cp -f {{ compose_dir }}/templates/favicon.svg {{ output_dir }}/favicon.svg
cp -f {{ compose_dir }}/templates/app.js {{ output_dir }}/app.js
- name: docker compose up -d (rebuild + recreate so the new web image lands)
shell: cd {{ compose_dir }} && docker compose up -d --build
- name: Wait for the web container to serve /
shell: |
for i in $(seq 1 30); do
curl -sf -o /dev/null --max-time 3 http://localhost:{{ host_port }}/ && exit 0
sleep 2
done
exit 1
changed_when: "false"
verify:
- name: web returns 200 on /
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/
changed_when: "false"
- name: Both containers running
shell: docker inspect news-digest-worker news-digest-web --format '{{.State.Status}}' | grep -c running | grep -q '^2$'
changed_when: "false"
- name: news-digest-web on traefik-net (homepage discovery)
shell: docker inspect news-digest-web --format '{{json .NetworkSettings.Networks}}' | grep -q traefik-net
changed_when: "false"
- name: /api/hidden returns a JSON array
shell: |
curl -sf --max-time 5 http://localhost:{{ host_port }}/api/hidden \
| python3 -c "import sys, json; d = json.load(sys.stdin); assert isinstance(d, list)"
changed_when: "false"
- name: app.js is reachable
shell: curl -sf -o /dev/null --max-time 5 http://localhost:{{ host_port }}/app.js
changed_when: "false"
- name: hide → /api/hidden contains it → restore → /api/hidden no longer contains it
# End-to-end smoke of the hide/restore round-trip without touching
# any real item id. Uses a synthetic id so we don't pollute state if
# the deploy runs against a live install.
shell: |
set -e
tid="smoke-$(date +%s)-$$"
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"id\":\"${tid}\"}" \
http://localhost:{{ host_port }}/api/hide >/dev/null
curl -sf http://localhost:{{ host_port }}/api/hidden \
| python3 -c "import sys, json; assert '${tid}' in json.load(sys.stdin)"
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"id\":\"${tid}\"}" \
http://localhost:{{ host_port }}/api/restore >/dev/null
curl -sf http://localhost:{{ host_port }}/api/hidden \
| python3 -c "import sys, json; assert '${tid}' not in json.load(sys.stdin)"
changed_when: "false"
-214
View File
@@ -1,214 +0,0 @@
#!/usr/bin/env bash
# add-digest-user.sh — provision a per-user news-digest stack.
#
# Multi-tenant onboarding for the daily-digest applet. One miniflux
# instance, multiple miniflux users, one news-digest stack per user
# (own port, own output dir, own hide-state).
#
# Usage:
# scripts/add-digest-user.sh <username> # generate password
# scripts/add-digest-user.sh <username> <pass> # set explicit password
#
# What it does:
# 1. Reads miniflux admin creds from ana-docker:/opt/docker/compose/miniflux/.env
# 2. Allocates the next free NEWS_DIGEST_PORT above 8181
# 3. Creates a miniflux user via the admin API
# 4. Creates per-user dirs on ana-docker (sudo prompt expected once)
# 5. Materializes a per-user .env at /opt/docker/compose/digest-<user>/
# 6. Brings up the per-user stack (`docker compose -p digest-<user> up -d`)
# 7. Seeds default world/local/tech feeds in the new user's miniflux account
#
# Idempotent-ish: re-running for an existing user re-syncs config + feeds
# but won't recreate the miniflux user (409 from /v1/users is non-fatal).
set -euo pipefail
usage() {
cat <<EOF
usage: $0 <username> [password] [--am "MIN HR * * *"] [--pm "MIN HR * * *"]
Defaults: AM = "0 8 * * *", PM = "0 20 * * *". Cron syntax is
standard 5-field; busybox crond honors the container's \$TZ.
Examples:
$0 alice
$0 bob --am "0 6 * * *" --pm "0 17 * * *"
$0 carol s3cret --pm "30 18 * * 1-5" # weekdays only PM run
EOF
exit 1
}
USER_ARG=""
USER_PASS=""
CRON_AM="0 8 * * *"
CRON_PM="0 20 * * *"
while [ "$#" -gt 0 ]; do
case "$1" in
--am) CRON_AM="$2"; shift 2 ;;
--pm) CRON_PM="$2"; shift 2 ;;
--help|-h) usage ;;
--*) echo "unknown flag: $1" >&2; usage ;;
*)
if [ -z "$USER_ARG" ]; then
USER_ARG="$1"
elif [ -z "$USER_PASS" ]; then
USER_PASS="$1"
else
echo "unexpected positional arg: $1" >&2; usage
fi
shift
;;
esac
done
[ -n "$USER_ARG" ] || usage
HOST=ana-docker
WORKSTATION_STACK="$(dirname "$0")/../stacks/news-digest"
PROJECT="digest-$USER_ARG"
HOST_COMPOSE_DIR="/opt/docker/compose/$PROJECT"
HOST_DATA_DIR="/opt/docker/data/$PROJECT"
PORT_BASE=8181
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
bold "→ provisioning news-digest for user '$USER_ARG'"
# 1. Pull miniflux admin creds from host
info "reading miniflux admin creds from $HOST"
admin_creds=$(ssh "$HOST" 'grep -E "^MINIFLUX_ADMIN_(USERNAME|PASSWORD)=" /opt/docker/compose/miniflux/.env')
admin_user=$(awk -F= '/^MINIFLUX_ADMIN_USERNAME=/ {sub(/^MINIFLUX_ADMIN_USERNAME=/, ""); print}' <<<"$admin_creds")
admin_pass=$(awk -F= '/^MINIFLUX_ADMIN_PASSWORD=/ {sub(/^MINIFLUX_ADMIN_PASSWORD=/, ""); print}' <<<"$admin_creds")
[ -n "$admin_user" ] && [ -n "$admin_pass" ] || { echo "FATAL: could not read miniflux admin creds" >&2; exit 1; }
# 2. Allocate next free port. Use `find` so the glob doesn't blow up
# when there are zero per-user digest-*/.env files yet.
info "scanning for used digest ports..."
used_ports=$(ssh "$HOST" 'find /opt/docker/compose -maxdepth 2 -mindepth 2 -name .env \( -path "*/news-digest/*" -o -path "*/digest-*/*" \) -exec grep -h "^NEWS_DIGEST_PORT=" {} + 2>/dev/null | cut -d= -f2 | sort -un' || true)
new_port=$PORT_BASE
while echo "$used_ports" | grep -qx "$new_port"; do
new_port=$((new_port + 1))
done
info "allocated port: $new_port (in use: ${used_ports//$'\n'/, })"
# 3. Generate password if not provided
if [ -z "$USER_PASS" ]; then
USER_PASS=$(openssl rand -base64 18 | tr -d '/+=')
fi
info "user password: $USER_PASS"
# 4. Create miniflux user via admin API (skip silently if 409)
info "creating miniflux user '$USER_ARG'..."
http_code=$(ssh "$HOST" "curl -s -o /dev/null -w '%{http_code}' \
-u '$admin_user:$admin_pass' \
-H 'Content-Type: application/json' \
-d '{\"username\":\"$USER_ARG\",\"password\":\"$USER_PASS\",\"is_admin\":false}' \
http://10.250.50.70:8080/v1/users")
case "$http_code" in
201) info " created" ;;
400|409) info " already exists (HTTP $http_code) — keeping existing user, password reset NOT performed" ;;
*) echo "FATAL: miniflux /v1/users returned HTTP $http_code" >&2; exit 1 ;;
esac
# 5. Provision per-user dirs. Sudo on the host needs a TTY for the
# password prompt; if we're being run non-interactively (piped, in
# a script), check whether the dirs already exist and bail with a
# manual command if they don't.
info "checking host dirs..."
if ssh "$HOST" "[ -w '$HOST_COMPOSE_DIR' ] && [ -w '$HOST_DATA_DIR' ]" 2>/dev/null; then
info " exist + writable, skipping sudo step"
elif [ -t 0 ] && [ -t 1 ]; then
info " creating (sudo prompt incoming)..."
ssh -t "$HOST" "sudo mkdir -p $HOST_COMPOSE_DIR $HOST_DATA_DIR && \
sudo chown -R lkraven:lkraven $HOST_COMPOSE_DIR $HOST_DATA_DIR"
else
cat >&2 <<EOF
Run this on your terminal first (this script can't drive sudo without a TTY):
ssh -t $HOST "sudo mkdir -p $HOST_COMPOSE_DIR $HOST_DATA_DIR && sudo chown -R lkraven:lkraven $HOST_COMPOSE_DIR $HOST_DATA_DIR"
Then re-run: scripts/add-digest-user.sh $USER_ARG $USER_PASS
EOF
exit 1
fi
# 6. Sync compose + materialize .env
info "syncing compose.yaml..."
scp -q "$WORKSTATION_STACK/compose.yaml" "$HOST:$HOST_COMPOSE_DIR/compose.yaml"
# Also need the build context (Dockerfile, *.py, templates/) so the
# image can build if it's not already cached. Use the existing tree.
scp -q "$WORKSTATION_STACK/Dockerfile" "$HOST:$HOST_COMPOSE_DIR/Dockerfile"
scp -q "$WORKSTATION_STACK"/{digest.py,seed-headlines.py,web.py,entrypoint.sh,run-digest.sh} "$HOST:$HOST_COMPOSE_DIR/"
ssh "$HOST" "mkdir -p $HOST_COMPOSE_DIR/templates"
scp -q "$WORKSTATION_STACK"/templates/* "$HOST:$HOST_COMPOSE_DIR/templates/"
info "materializing .env..."
# Inherit the image tag from the canonical news-digest stack so all
# per-user instances use the same built image (otherwise we'd accidentally
# pin to whatever was current when this script was last edited).
canonical_tag=$(ssh "$HOST" 'awk -F= "/^NEWS_DIGEST_TAG=/ {print \$2}" /opt/docker/compose/news-digest/.env' || echo "v1")
info "using image tag: $canonical_tag (inherited from canonical)"
ssh "$HOST" "cat > $HOST_COMPOSE_DIR/.env" <<EOF
# Auto-generated by scripts/add-digest-user.sh on $(date -Iseconds)
# User: $USER_ARG
NEWS_DIGEST_TAG=$canonical_tag
DIGEST_PROJECT=$PROJECT
DIGEST_HOMEPAGE_NAME=$USER_ARG's Digest
DIGEST_HOMEPAGE_DESC=Personal LLM-curated brief for $USER_ARG
NEWS_DIGEST_PORT=$new_port
NEWS_DIGEST_BIND=0.0.0.0
NEWS_DIGEST_TZ=America/Los_Angeles
LLAMA_SWAP_URL=http://10.250.50.54:9292
LLAMA_SWAP_MODEL=granite-4-small
LLAMA_SWAP_TIMEOUT=180
MINIFLUX_URL=http://miniflux:8080
MINIFLUX_USER=$USER_ARG
MINIFLUX_PASSWORD=$USER_PASS
DIGEST_REDDIT_HOURS=12
DIGEST_MIN_SCORE=50
DIGEST_MIN_RATIO=0.85
DIGEST_MAX_PER_SUB=8
DIGEST_MINIFLUX_HOURS=12
DIGEST_MINIFLUX_MAX=8
DIGEST_MINIFLUX_TECH_CATEGORY=Tech aggregators
DIGEST_MINIFLUX_WORLD_CATEGORY=World
DIGEST_MINIFLUX_LOCAL_CATEGORY=Local
DIGEST_MINIFLUX_HEADLINES_HOURS=8
DIGEST_MINIFLUX_HEADLINES_MAX=15
DIGEST_CRON_AM=$CRON_AM
DIGEST_CRON_PM=$CRON_PM
NEWS_DIGEST_OUTPUT_DIR=$HOST_DATA_DIR
EOF
# 7. Bring up the per-user stack
info "bringing stack up (compose project: $PROJECT)..."
ssh "$HOST" "cd $HOST_COMPOSE_DIR && docker compose -p $PROJECT up -d 2>&1 | sed 's/^/ /'"
# 8. Wait for worker to be alive then seed feeds
info "waiting for worker to be ready..."
for i in $(seq 1 30); do
if ssh "$HOST" "docker exec $PROJECT-worker test -f /app/seed-headlines.py" 2>/dev/null; then
break
fi
sleep 2
done
info "seeding default feeds in miniflux for $USER_ARG..."
ssh "$HOST" "docker exec $PROJECT-worker python3 /app/seed-headlines.py 2>&1 | sed 's/^/ /'"
# 9. Trigger first digest run so the page isn't blank
info "triggering first digest run (this can take ~90s)..."
ssh "$HOST" "docker exec $PROJECT-worker /usr/local/bin/run-digest.sh 2>&1 | tail -3 | sed 's/^/ /'" || true
bold ""
bold "✓ provisioned digest for $USER_ARG"
echo
echo " digest URL : http://10.250.50.70:$new_port/"
echo " miniflux UI : http://10.250.50.70:8080/ (login: $USER_ARG / $USER_PASS)"
echo " schedule : AM '$CRON_AM' / PM '$CRON_PM' (TZ from \$NEWS_DIGEST_TZ)"
echo " compose dir : $HOST:$HOST_COMPOSE_DIR/"
echo " output dir : $HOST:$HOST_DATA_DIR/"
echo
echo " hand the URL + miniflux creds to the user; they can manage their"
echo " feed subscriptions via the miniflux UI."
-81
View File
@@ -1,81 +0,0 @@
# news-digest stack tunables. Copy to `.env` on ana-docker before
# deploying and fill in MINIFLUX_PASSWORD.
# ── image pin ────────────────────────────────────────────────────────
# Bump when you change Dockerfile / digest.py / templates / crontab
# to force a clean image build.
NEWS_DIGEST_TAG=v1
# ── network ──────────────────────────────────────────────────────────
# Host port for the static web container (nginx). Container 80.
# Reserved on ana-docker (sample): 5001 Dockge, 7878 task-board,
# 8000 (other), 8025/8088/8090/8100/8380/8780 (various), 8080 Miniflux.
# 8181 free.
NEWS_DIGEST_PORT=8181
NEWS_DIGEST_BIND=0.0.0.0
# Timezone — busybox crond honors this for fire-times.
NEWS_DIGEST_TZ=America/Los_Angeles
# ── llama-swap (LLM summarizer) ──────────────────────────────────────
# Model picked for one-shot summarization quality + low VRAM impact.
# qwen3.5-35-a3b is loaded in the persistent group on ana-ml2.
LLAMA_SWAP_URL=http://10.250.50.54:9292
# granite-4-small — small (~4B), fast (~1s/call), no extended-thinking
# phase that eats the token budget like qwen3.x do. Plenty of capability
# for the one-sentence-tldr + one-word-tag task. To swap to a larger
# model later, ones currently working: gemma4-26b-a4b, granite-4-small.
# Avoid: qwen3.5-35-a3b (model file broken — process exits on launch),
# qwen3.6-35-a3b (defaults to thinking mode, eats budget without output).
LLAMA_SWAP_MODEL=granite-4-small
LLAMA_SWAP_TIMEOUT=180
# ── miniflux (feed source for Tech aggregators + subreddit list) ─────
# In-cluster: miniflux container is on traefik-net so we can reach it
# by container hostname. From-host fallback: http://10.250.50.70:8080
MINIFLUX_URL=http://miniflux:8080
MINIFLUX_USER=lkraven
MINIFLUX_PASSWORD=CHANGE_ME
# Category in Miniflux holding non-Reddit feeds (HN, Lobste.rs, etc).
DIGEST_MINIFLUX_TECH_CATEGORY=Tech aggregators
# ── filtering knobs ──────────────────────────────────────────────────
# Reddit: only consider posts created in the last N hours, with
# at least N upvotes and an upvote ratio above threshold.
DIGEST_REDDIT_HOURS=12
DIGEST_MIN_SCORE=50
DIGEST_MIN_RATIO=0.85
DIGEST_MAX_PER_SUB=8
# Miniflux Tech aggregators: same look-back window + cap per source.
DIGEST_MINIFLUX_HOURS=12
DIGEST_MINIFLUX_MAX=8
# Miniflux World/Local headlines (high-volume, dense list, no LLM
# summarization). Categories must exist in miniflux — see
# stacks/news-digest/seed-headlines.py for the one-shot setup.
DIGEST_MINIFLUX_WORLD_CATEGORY=World
DIGEST_MINIFLUX_LOCAL_CATEGORY=Local
DIGEST_MINIFLUX_HEADLINES_HOURS=8
DIGEST_MINIFLUX_HEADLINES_MAX=15
# Cron schedule (standard 5-field). busybox crond honors $TZ above.
# Two fires per day by convention (morning / evening); change times
# per-user to match working hours.
DIGEST_CRON_AM=0 8 * * *
DIGEST_CRON_PM=0 20 * * *
# ── output dir on host (bind-mounted) ────────────────────────────────
# Separate from /opt/docker/conf/<stack>/ to keep generated content
# distinct from config. Owned by container UID; writes are atomic.
NEWS_DIGEST_OUTPUT_DIR=/opt/docker/data/news-digest
# ── multi-tenant (optional) ──────────────────────────────────────────
# Per-user instances are deployed via scripts/add-digest-user.sh, which
# materializes a per-user .env and sets DIGEST_PROJECT to namespace
# container names + homepage labels. Leave unset for the singleton
# install — defaults preserve the original "news-digest" naming.
# DIGEST_PROJECT=digest-alice
# DIGEST_HOMEPAGE_NAME=Alice's Digest
# DIGEST_HOMEPAGE_DESC=Personal news brief for Alice
-54
View File
@@ -1,54 +0,0 @@
# news-digest — base image for two containers in this stack:
#
# news-digest-worker — runs alpine's busybox crond + the one-shot
# digest.py per fire (default ENTRYPOINT).
# news-digest-web — runs uvicorn web:app (overridden in compose)
# to serve /output as static + the tiny
# hidden-items API at /api/*.
#
# Single image, two roles selected via compose `command:`.
# Bind-mounted /output is the shared canvas: worker writes HTML, web
# serves it.
FROM python:3.12-alpine
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# tzdata so $TZ works for cron + datetime; tini so signals propagate cleanly.
RUN apk add --no-cache tzdata tini bash curl
# fastapi + uvicorn[standard] for the web container; requests + jinja2
# for the worker. Both shipped in both containers — neither set is
# heavy enough to justify splitting the image.
# trafilatura: main-content extractor for the article-summary upgrade
# (worker only — it pulls lxml + a handful of HTML utils, ~80 MB total).
# libxml2-dev/libxslt-dev are for lxml's musl wheels. apk caches are
# cleaned in the same RUN to keep the layer small.
RUN apk add --no-cache --virtual .build-deps gcc musl-dev libxml2-dev libxslt-dev \
&& apk add --no-cache libxml2 libxslt \
&& pip install --no-cache-dir \
requests jinja2 trafilatura \
'fastapi>=0.115' 'uvicorn[standard]>=0.30' \
&& apk del .build-deps
WORKDIR /app
COPY digest.py /app/digest.py
COPY seed-headlines.py /app/seed-headlines.py
COPY web.py /app/web.py
COPY templates /app/templates
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY run-digest.sh /usr/local/bin/run-digest.sh
# /etc/crontabs/root is written by entrypoint.sh from
# DIGEST_CRON_AM/DIGEST_CRON_PM env at container start, so each
# per-user instance gets its own schedule. Image no longer ships a
# baked-in crontab.
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/run-digest.sh
# Sentinel + first-run output dir
VOLUME /output
# Default ENTRYPOINT runs the worker (cron). The web container in
# compose overrides both entrypoint and command to launch uvicorn.
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
-279
View File
@@ -1,279 +0,0 @@
# news-digest
Twice-daily LLM-curated briefing across Reddit (via JSON API) and
Miniflux's Tech aggregators category. Output is a single static HTML
page styled in the Australis design system with editorial-serif
headlines (Fraunces) — operations-center chrome wrapping news content.
## Why this stack exists
After a few subreddits + HN + Lobste.rs, the Miniflux inbox gets
noisy. This stack:
1. Pulls **top-of-day** posts per subreddit from Reddit's public JSON
API (gives us scores + upvote ratios — RSS doesn't).
2. Filters by `score >= 50` and `upvote_ratio >= 0.85` (configurable)
to drop flame-bait and low-effort posts.
3. Pulls non-Reddit recent items from Miniflux's Tech aggregators
category (HN, Lobste.rs).
4. Sends each source through `granite-4-small` on llama-swap (one
batched call per source — efficient) for a 2-3 sentence summary +
single-word tag (news / tutorial / release / discussion / question
/ showcase / drama / meme / other). World + Local headlines also
get summarized in one batch call per category. (Original default
was `qwen3.5-35-a3b` but its model file is broken on launch.)
5. Renders an HTML page styled with Australis tokens + Fraunces
serif headlines.
6. The page is served by a tiny FastAPI app on uvicorn that also
exposes `/api/{hidden,hide,restore}` for the per-item × button
(state in `/output/hidden.json`, shared across every device the
user opens the digest from).
Two editions per day by default (0800 / 2000 local), parametrized via
`DIGEST_CRON_AM` / `DIGEST_CRON_PM` env so each per-user instance can
fire on its own schedule. Plus per-edition archives at
`/edition-YYYY-MM-DD-{am,pm}.html`.
## Architecture
Two containers built from the same Dockerfile, both on `traefik-net`,
sharing a bind-mounted output dir:
```
news-digest-worker (default ENTRYPOINT — busybox crond)
├── busybox crond fires at 0 8,20 * * *
├── digest.py:
│ ├── miniflux /v1/feeds → discover subreddits
│ ├── reddit JSON top/.json?t=day per sub (gentle 1.5s sleep)
│ ├── miniflux /v1/entries → tech aggregators
│ ├── llama-swap /v1/chat/completions → batched per source
│ └── jinja2 render → /output/index.html (atomic .tmp + rename)
│ → /output/edition-2026-04-26-pm.html
└── style.css / favicon.svg / app.js staged in /output at deploy
news-digest-web (entrypoint overridden → uvicorn web:app)
├── / → serve /output as static (index.html as default)
├── /api/hidden GET → JSON array of hidden item ids
├── /api/hide POST → {id} → adds id to hidden.json
├── /api/restore POST → {id} → removes id from hidden.json
├── /output/hidden.json — durable state (atomic writes + threading lock)
└── homepage card via container labels (group=News)
```
Hidden state is server-side and global per-user (single-user setup):
hide an article once and it stays hidden in any future edition that
includes the same article. The "Hidden (N)" tray at the bottom of
each page shows items hidden FROM THE CURRENT PAGE; older hidden ids
that aren't present on this page just sit silently in `hidden.json`
and continue to filter future editions.
## Deploy
```bash
scripts/elway ana-docker --playbook playbooks/deploy-news-digest.yaml
```
After first deploy, **fill in MINIFLUX_PASSWORD on the host**:
```bash
ssh ana-docker '
cd /opt/docker/compose/news-digest
sed -i "s|^MINIFLUX_PASSWORD=.*|MINIFLUX_PASSWORD=<your-miniflux-admin-password>|" .env
docker compose up -d
'
```
The container runs the first digest immediately if `/output/index.html`
doesn't exist, so the page populates within a minute or two of bringing
the stack up with real credentials.
Visit <http://10.250.50.70:8181> to read.
## Tuning the noise floor
Defaults in `.env.example`:
| Knob | Default | Effect |
|---|---|---|
| `DIGEST_REDDIT_HOURS` | 12 | Look-back window (matches twice-daily cadence) |
| `DIGEST_MIN_SCORE` | 50 | Reddit minimum upvotes to consider |
| `DIGEST_MIN_RATIO` | 0.85 | Reddit minimum upvote ratio (skips flamebait) |
| `DIGEST_MAX_PER_SUB` | 8 | Cap per subreddit, post-filter |
| `DIGEST_MINIFLUX_HOURS` | 12 | Look-back window for HN/Lobste.rs |
| `DIGEST_MINIFLUX_MAX` | 8 | Cap per non-Reddit feed |
For a busier day, lower `DIGEST_MIN_SCORE`. For a quieter morning
edition, raise it. Edit `.env`, no rebuild needed — the worker reads
env on each cron fire.
## Adding more subreddits
The digest picks up subreddit feeds from Miniflux automatically — any
feed whose URL starts with `https://www.reddit.com/r/<sub>/` gets
queried. To add a sub, just subscribe in Miniflux (UI or API). The
next digest run includes it.
## Updating the LLM model
```bash
ssh ana-docker '
cd /opt/docker/compose/news-digest
sed -i "s|^LLAMA_SWAP_MODEL=.*|LLAMA_SWAP_MODEL=<new-model>|" .env
docker compose up -d
'
```
The model must be loaded in llama-swap's `config.yaml`. Check
`http://10.250.50.54:9292/v1/models` for what's available. Models
with tool/JSON-mode support give better summarization quality;
`granite-4-small` is the current default (small ~4B, fast ~1s/call,
no extended-thinking phase eating the token budget). Avoid
`qwen3.5-35-a3b` (model file broken — process exits on launch) and
`qwen3.6-35-a3b` (defaults to thinking mode, eats budget without output).
## Forcing a fresh digest now
```bash
ssh ana-docker 'docker exec news-digest-worker /usr/local/bin/run-digest.sh'
```
Runs the full pipeline once, ignoring cron. Useful after changing
filtering knobs or adding feeds.
## Customizing the run schedule
Times come from two env vars on the worker, written into the busybox
crontab at container start. Standard 5-field cron syntax.
| Var | Default | Effect |
|---|---|---|
| `DIGEST_CRON_AM` | `0 8 * * *` | morning fire |
| `DIGEST_CRON_PM` | `0 20 * * *` | evening fire |
`busybox crond` honors `$NEWS_DIGEST_TZ` (defaults to
`America/Los_Angeles`), so values are interpreted in the configured TZ.
```bash
# Shift the canonical instance to 7am / 6pm
ssh ana-docker '
cd /opt/docker/compose/news-digest
sed -i "s|^DIGEST_CRON_AM=.*|DIGEST_CRON_AM=0 7 * * *|" .env
sed -i "s|^DIGEST_CRON_PM=.*|DIGEST_CRON_PM=0 18 * * *|" .env
docker compose up -d --force-recreate news-digest-worker
'
```
Verify the rendered crontab:
```bash
ssh ana-docker 'docker exec news-digest-worker cat /etc/crontabs/root'
```
## Multi-tenant: one instance per teammate
Architecture: **shared miniflux + per-user digest stack**. Miniflux
already supports multi-user natively (each user has their own feeds,
categories, hide-state); we layer a separate news-digest stack per
user on its own port + output dir, scoped to that miniflux user's
credentials.
### Onboarding a new user
```bash
# Defaults — 8am / 8pm local, random password
scripts/add-digest-user.sh alice
# Custom hours
scripts/add-digest-user.sh bob --am "0 6 * * *" --pm "0 17 * * *"
# Weekday-only PM run
scripts/add-digest-user.sh carol --pm "30 18 * * 1-5"
# Pin a known password (still creates the miniflux user if missing)
scripts/add-digest-user.sh dan 'pickyourpassword' --am "0 9 * * *"
```
What the script does:
1. Reads miniflux admin creds from
`ana-docker:/opt/docker/compose/miniflux/.env`.
2. Allocates the next free `NEWS_DIGEST_PORT` (scans existing
`news-digest` + `digest-*` `.env` files).
3. Creates the miniflux user via the admin API. Already-exists is
non-fatal (kept; password not reset).
4. Provisions per-user dirs at
`/opt/docker/compose/digest-<user>/` and
`/opt/docker/data/digest-<user>/` (one-time sudo prompt — the
script falls back to printing the manual command if there's no TTY).
5. Materializes a per-user `.env` (inherits `NEWS_DIGEST_TAG` from the
canonical stack so all tenants run the same image).
6. Brings the stack up via `docker compose -p digest-<user> up -d`.
7. Runs `seed-headlines.py` against miniflux as the new user (creates
the World + Local categories with default feeds).
8. Triggers a first digest run so the page isn't blank.
Outputs the digest URL, miniflux login, and rendered cron schedule.
### Per-user file layout
```
ana-docker:
/opt/docker/compose/digest-<user>/ # compose + .env + build context
.env # auto-generated, contains MINIFLUX_PASSWORD
compose.yaml
Dockerfile + digest.py + ... # build context (image is shared/cached)
/opt/docker/data/digest-<user>/ # rendered HTML + per-user hidden.json
index.html
edition-YYYY-MM-DD-{am,pm}.html
hidden.json
.article-cache.json # extracted article text, 7-day TTL
```
Container names: `digest-<user>-worker` and `digest-<user>-web`.
### Updating an existing user's schedule
Re-running the script with the same username is idempotent:
```bash
# Change alice's evening run to 5:30pm
scripts/add-digest-user.sh alice --pm "30 17 * * *"
```
The miniflux user is kept (password unchanged), the .env is
re-materialized with the new schedule, and the worker container is
recreated. Use the same flow to bump cron times, rotate passwords
(by passing a new one explicitly), or rerun feed seeding.
### Removing a user
```bash
ssh ana-docker '
cd /opt/docker/compose/digest-alice
docker compose -p digest-alice down -v
'
# Optional: nuke compose dir + rendered output
ssh -t ana-docker 'sudo rm -rf /opt/docker/compose/digest-alice /opt/docker/data/digest-alice'
# Optional: delete the miniflux user via the admin UI at http://10.250.50.70:8080/
```
The digest is gone immediately; the miniflux account stays around
unless you delete it explicitly (cheap to leave; ~zero resource cost
when no stack is querying its feeds).
## Logs
```bash
ssh ana-docker 'docker logs --tail 100 news-digest-worker'
```
Worker logs each phase (subreddit discovery / fetching / summarizing /
rendering) with timestamps. Per-source LLM filter results show how
many items were kept vs skipped.
## License + attribution
Reddit content surfaced here is owned by its authors and Reddit. The
digest is a derived index pointing at original sources — every item
links back to the Reddit thread (and to the external link if the
post linked out). Same for HN / Lobste.rs.
-109
View File
@@ -1,109 +0,0 @@
# 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
# ${DIGEST_PROJECT} prefixes container names so multiple instances
# (one per teammate, scoped to their miniflux user) can coexist on
# the same host. Default keeps backward-compat with the original
# singleton deploy.
container_name: ${DIGEST_PROJECT:-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:-granite-4-small}
- 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}
# Per-instance cron schedule. Standard 5-field syntax. busybox
# crond honors $TZ. Defaults preserve the original 0800 / 2000.
- DIGEST_CRON_AM=${DIGEST_CRON_AM:-0 8 * * *}
- DIGEST_CRON_PM=${DIGEST_CRON_PM:-0 20 * * *}
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: ${DIGEST_PROJECT:-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=${DIGEST_HOMEPAGE_NAME:-Daily Digest}
- homepage.icon=mdi-newspaper-variant-outline
- homepage.description=${DIGEST_HOMEPAGE_DESC:-LLM-curated briefing across feeds, twice daily}
- homepage.href=http://10.250.50.70:${NEWS_DIGEST_PORT}
networks:
tnet:
name: traefik-net
external: true
-710
View File
@@ -1,710 +0,0 @@
"""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())
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
# entrypoint.sh — news-digest worker startup.
#
# Strategy:
# 1. If /output is empty, run digest.py once at start so the page
# isn't blank while waiting for the next cron tick.
# 2. Start busybox crond in foreground so the container stays up.
#
# All env vars are inherited from compose, including TZ which busybox
# crond honors when computing fire times.
set -e
mkdir -p /output
# Sync static frontend assets from /app/templates → /output. The web
# container serves /output as static; the assets are baked into the
# image but the bind-mounted /output otherwise wouldn't pick up
# CSS/JS updates on rebuild without a manual copy.
for asset in style.css app.js favicon.svg; do
if [ -f "/app/templates/$asset" ]; then
cp -f "/app/templates/$asset" "/output/$asset"
fi
done
if [ ! -f /output/index.html ]; then
echo "[entrypoint] no /output/index.html yet — running first digest"
/usr/local/bin/run-digest.sh || \
echo "[entrypoint] first run failed; cron will retry on schedule"
fi
# Render the crontab from env so each per-user instance can fire on
# its own schedule. Defaults match the original singleton (0800 / 2000
# local). Two separate vars (AM / PM) instead of one combined string
# so users can tweak one fire without re-deriving the other.
CRON_AM="${DIGEST_CRON_AM:-0 8 * * *}"
CRON_PM="${DIGEST_CRON_PM:-0 20 * * *}"
mkdir -p /etc/crontabs
{
echo "# news-digest cron — generated at container start from env."
echo "$CRON_AM /usr/local/bin/run-digest.sh"
echo "$CRON_PM /usr/local/bin/run-digest.sh"
} > /etc/crontabs/root
echo "[entrypoint] crontab:"
sed 's/^/ /' /etc/crontabs/root
# Foreground crond. -L /dev/stdout sends cron stdout/stderr to docker logs.
echo "[entrypoint] starting crond"
exec crond -f -L /dev/stdout -l 8
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env bash
# run-digest.sh — single-shot wrapper invoked by cron.
# Loads env from /etc/environment (cron's empty environment otherwise)
# and pipes output to docker logs via a timestamped prefix.
set -e
# busybox crond doesn't carry container env. Re-export from /etc/environment
# (Docker writes container envs there if you set DOCKER_ENV write — but
# we can't rely on that). Simpler: source any envfile we drop in entrypoint.
if [ -f /tmp/digest.env ]; then
set -a; . /tmp/digest.env; set +a
fi
cd /app
exec python3 /app/digest.py 2>&1 | sed "s/^/[$(date '+%H:%M:%S')] /"
-139
View File
@@ -1,139 +0,0 @@
#!/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())
-251
View File
@@ -1,251 +0,0 @@
// news-digest — per-item × button + hidden tray.
//
// State lives server-side at /api/hidden (a JSON array of item IDs the
// user has hidden). Same set is shared across every device the user
// opens the digest from. Each item has a stable `data-id` (12-char sha1
// prefix computed by digest.py at render time).
//
// Lifecycle:
// 1. On load, GET /api/hidden — apply `is-hidden` to matching items
// pre-paint by hiding the <main>, then revealing it on the next
// animation frame after the class-toggle pass. (Avoids a flash of
// hide-then-show.)
// 2. × button click → POST /api/hide {id}, animate the item out, add
// a row to the hidden tray, optimistically commit (rollback on
// network error).
// 3. Tray "restore" click → POST /api/restore {id}, animate item back,
// remove tray row.
//
// Tray semantics: shows only items hidden FROM THE CURRENT PAGE, since
// titles/urls come from the DOM. Older hidden IDs not on this page just
// sit silently in /api/hidden and continue to filter future pages that
// happen to include the same article.
(function () {
"use strict";
const HIDE_CLASS = "is-hidden";
const TRAY = document.getElementById("hidden-tray");
const TRAY_LIST = document.getElementById("hidden-tray-list");
const TRAY_COUNT = document.getElementById("hidden-tray-count");
const TRAY_TOGGLE = TRAY && TRAY.querySelector(".hidden-tray-toggle");
/** Map<id, HTMLElement> — every hideable element on this page, keyed by
* data-id. Both .item (cards) and .headline (compact rows) qualify. */
const itemsById = new Map();
document.querySelectorAll(".item[data-id], .headline[data-id]").forEach((el) => {
itemsById.set(el.dataset.id, el);
});
/* ── network ────────────────────────────────────────────────────── */
async function apiGetHidden() {
try {
const r = await fetch("/api/hidden", { credentials: "same-origin" });
if (!r.ok) throw new Error(`GET /api/hidden ${r.status}`);
return new Set(await r.json());
} catch (e) {
console.warn("[digest] failed to fetch hidden state:", e);
return new Set();
}
}
async function apiHide(id, hide) {
const path = hide ? "/api/hide" : "/api/restore";
const r = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ id }),
});
if (!r.ok) throw new Error(`POST ${path} ${r.status}`);
return r.json();
}
/* ── live counts ─────────────────────────────────────────────────
The server-rendered .source-count and .desk-count badges are
accurate at render time, but they fall stale as soon as the user
hides anything. Recompute from the DOM whenever the visible set
changes. Empty sources / desks get an .is-empty class that hides
them entirely (no point showing "r/homelab (0)"). */
function refreshCounts() {
document.querySelectorAll(".source").forEach((src) => {
const visible = src.querySelectorAll(".item:not(.is-hidden)").length;
const badge = src.querySelector(".source-count");
if (badge) badge.textContent = String(visible);
src.classList.toggle("is-empty", visible === 0);
});
document.querySelectorAll(".desk").forEach((desk) => {
const visibleItems = desk.querySelectorAll(".item:not(.is-hidden), .headline:not(.is-hidden)").length;
const badge = desk.querySelector(".desk-count");
if (badge) badge.textContent = `${visibleItems} items`;
desk.classList.toggle("is-empty", visibleItems === 0);
});
}
/* ── tray rendering ─────────────────────────────────────────────── */
function refreshTrayVisibility() {
if (!TRAY) return;
const n = TRAY_LIST.children.length;
TRAY_COUNT.textContent = String(n);
if (n > 0) {
TRAY.hidden = false;
} else {
TRAY.hidden = true;
// Also collapse so that re-hiding starts collapsed-with-content.
setTrayExpanded(false);
}
}
function setTrayExpanded(expanded) {
if (!TRAY_LIST || !TRAY_TOGGLE) return;
TRAY_LIST.hidden = !expanded;
TRAY_TOGGLE.setAttribute("aria-expanded", String(expanded));
TRAY.classList.toggle("is-expanded", expanded);
}
function addTrayRow(id, title) {
if (!TRAY_LIST) return;
if (TRAY_LIST.querySelector(`[data-id="${CSS.escape(id)}"]`)) return;
const li = document.createElement("li");
li.className = "hidden-tray-item";
li.dataset.id = id;
li.innerHTML =
`<span class="hidden-tray-title"></span>` +
`<button class="hidden-tray-restore" type="button" title="Restore" aria-label="Restore">↺</button>`;
li.querySelector(".hidden-tray-title").textContent = title;
li.querySelector(".hidden-tray-restore").addEventListener("click", () => {
restoreItem(id);
});
TRAY_LIST.appendChild(li);
refreshTrayVisibility();
}
function removeTrayRow(id) {
if (!TRAY_LIST) return;
const row = TRAY_LIST.querySelector(`[data-id="${CSS.escape(id)}"]`);
if (row) row.remove();
refreshTrayVisibility();
}
function titleOf(el) {
const t = el.querySelector(".item-title, .headline-title");
return (t && t.textContent.trim()) || "(untitled)";
}
/* ── hide / restore ─────────────────────────────────────────────── */
async function hideItem(id) {
const el = itemsById.get(id);
if (!el) return;
if (el.classList.contains(HIDE_CLASS)) return;
// Optimistic — flip class first, talk to server next.
el.classList.add(HIDE_CLASS);
addTrayRow(id, titleOf(el));
refreshCounts();
try {
await apiHide(id, true);
} catch (e) {
console.warn("[digest] hide failed, rolling back:", e);
el.classList.remove(HIDE_CLASS);
removeTrayRow(id);
refreshCounts();
}
}
async function restoreItem(id) {
const el = itemsById.get(id);
if (!el) {
// Item is in tray but not in DOM — happens if the tray was rendered
// from the API set for an item not on this page. Just clear it
// server-side and remove the row.
try { await apiHide(id, false); } catch (_) {}
removeTrayRow(id);
return;
}
el.classList.remove(HIDE_CLASS);
removeTrayRow(id);
refreshCounts();
try {
await apiHide(id, false);
} catch (e) {
console.warn("[digest] restore failed, re-hiding:", e);
el.classList.add(HIDE_CLASS);
addTrayRow(id, titleOf(el));
refreshCounts();
}
}
/* ── wiring ─────────────────────────────────────────────────────── */
// × button — single delegated handler at the document level so we don't
// need to attach to each .item-hide individually (cheaper, also handles
// dynamically-rendered items if we ever add them).
document.addEventListener("click", (ev) => {
const btn = ev.target.closest(".item-hide");
if (!btn) return;
const item = btn.closest("[data-id]");
if (!item) return;
ev.preventDefault();
hideItem(item.dataset.id);
});
if (TRAY_TOGGLE) {
TRAY_TOGGLE.addEventListener("click", () => {
const wasExpanded = TRAY.classList.contains("is-expanded");
setTrayExpanded(!wasExpanded);
});
}
/* ── desk collapse (per-device, localStorage) ───────────────────── */
// Stored as a JSON object {deskId: true} of collapsed desks. Per
// device by design — collapse is a viewing preference, not content
// state, so no server roundtrip.
const COLLAPSE_KEY = "digest:collapsed-desks";
function readCollapsed() {
try { return JSON.parse(localStorage.getItem(COLLAPSE_KEY) || "{}"); }
catch (_) { return {}; }
}
function writeCollapsed(obj) {
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(obj)); } catch (_) {}
}
function applyCollapseState() {
const state = readCollapsed();
document.querySelectorAll(".desk[id]").forEach((desk) => {
desk.classList.toggle("is-collapsed", !!state[desk.id]);
});
}
applyCollapseState();
document.addEventListener("click", (ev) => {
const btn = ev.target.closest(".desk-collapse");
if (!btn) return;
const desk = btn.closest(".desk[id]");
if (!desk) return;
ev.preventDefault();
desk.classList.toggle("is-collapsed");
const state = readCollapsed();
if (desk.classList.contains("is-collapsed")) state[desk.id] = true;
else delete state[desk.id];
writeCollapsed(state);
});
/* ── initial paint ──────────────────────────────────────────────── */
apiGetHidden().then((hidden) => {
let trayHadAdditions = false;
hidden.forEach((id) => {
const el = itemsById.get(id);
if (el) {
el.classList.add(HIDE_CLASS);
addTrayRow(id, titleOf(el));
trayHadAdditions = true;
}
});
if (trayHadAdditions) refreshTrayVisibility();
refreshCounts();
});
})();
@@ -1,84 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Daily Digest · Archive</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="stylesheet" href="style.css?v={{ generated_at.strftime('%Y%m%d%H%M%S') }}" />
</head>
<body>
<header class="masthead" role="banner">
<div class="masthead-inner">
<a class="masthead-brand" href="index.html" title="Latest edition">
<span class="brand-glyph" aria-hidden="true">◢</span>
<span class="brand-mark">DAILY DIGEST</span>
</a>
<div class="masthead-edition">
<span class="edition-stamp">ARCHIVE</span>
<a class="archive-link" href="index.html">← LATEST</a>
</div>
<div class="masthead-meta">
<div class="meta-row">
<span class="meta-label">EDITIONS ON FILE</span>
<span class="meta-value">{{ total }}</span>
</div>
<div class="meta-row">
<span class="meta-label">CADENCE</span>
<span class="meta-value">2× DAILY · 0800 / 2000</span>
</div>
<div class="meta-row">
<span class="meta-label">RETENTION</span>
<span class="meta-value">UNLIMITED</span>
</div>
</div>
</div>
<div class="aurora-rule" aria-hidden="true"></div>
</header>
<main class="brief">
<section class="desk">
<header class="desk-head">
<span class="desk-num">∞</span>
<h2 class="desk-title">All Editions</h2>
<span class="desk-sub">newest first</span>
</header>
{% if editions %}
<ol class="archive-list">
{% for e in editions %}
<li class="archive-item">
<a class="archive-row" href="{{ e.filename }}">
<span class="archive-date">{{ e.date_long }}</span>
<span class="archive-edition archive-edition-{{ e.edition_short|lower }}">
{{ e.edition_short }}
</span>
<span class="archive-arrow">→</span>
</a>
</li>
{% endfor %}
</ol>
{% else %}
<p class="empty-msg">No editions on file yet. Cron fires twice daily at 0800 / 2000.</p>
{% endif %}
</section>
</main>
<footer class="colophon">
<div class="colophon-inner">
<div class="colophon-block">
<span class="meta-label">INDEXED</span>
<span class="meta-value">{{ generated_at.isoformat(timespec="seconds") }}</span>
</div>
<div class="colophon-block">
<span class="meta-label">STORE</span>
<span class="meta-value">/opt/docker/data/news-digest</span>
</div>
<div class="colophon-block">
<span class="meta-label">FORMAT</span>
<span class="meta-value">edition-YYYY-MM-DD-{am,pm}.html</span>
</div>
</div>
</footer>
</body>
</html>
-301
View File
@@ -1,301 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Daily Digest · {{ date_long }} · {{ edition_short }}</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
{# Cache-bust on every digest run so frontend asset updates land
immediately — browsers were serving stale app.js / style.css after
rebuilds, which was hiding headline desks (old JS treated them as
empty because it only counted .item, not .headline). #}
<link rel="stylesheet" href="style.css?v={{ generated_at.strftime('%Y%m%d%H%M%S') }}" />
</head>
<body>
{# ────── header ────── #}
<header class="masthead" role="banner">
<div class="masthead-inner">
<a class="masthead-brand" href="index.html" title="Latest edition">
<span class="brand-glyph" aria-hidden="true">◢</span>
<span class="brand-mark">DAILY DIGEST</span>
</a>
<div class="masthead-edition">
<span class="edition-num">№ {{ generated_at.strftime("%j") }}</span>
<span class="edition-stamp">{{ edition|upper }} EDITION</span>
<a class="archive-link" href="archive.html">ARCHIVE →</a>
</div>
<div class="masthead-meta">
<div class="meta-row">
<span class="meta-label">FILED</span>
<span class="meta-value">{{ date_long|upper }} · {{ time_short }} {{ tz }}</span>
</div>
<div class="meta-row">
<span class="meta-label">DESK</span>
<span class="meta-value">WORLD · LOCAL · SUBREDDITS · TECH FEEDS</span>
</div>
<div class="meta-row">
<span class="meta-label">CURATED BY</span>
<span class="meta-value">{{ model }}</span>
</div>
</div>
</div>
{# A thin aurora-glow rule under the masthead — the only sanctioned
Australis gradient. #}
<div class="aurora-rule" aria-hidden="true"></div>
</header>
{# ────── nav strip ────── #}
<nav class="jumpnav" aria-label="sections">
<ol>
{% if world_headlines %}
<li><a href="#world"><span class="jump-num">01</span> WORLD</a></li>
{% endif %}
{% if local_headlines %}
<li><a href="#local"><span class="jump-num">02</span> LOCAL</a></li>
{% endif %}
{% if reddit_sources %}
<li><a href="#reddit"><span class="jump-num">03</span> REDDIT</a></li>
{% endif %}
{% if tech_sources %}
<li><a href="#tech"><span class="jump-num">04</span> TECH FEEDS</a></li>
{% endif %}
</ol>
</nav>
<main class="brief">
{# ────── WORLD HEADLINES ────── #}
{% if world_headlines %}
<section id="world" class="desk desk-headlines">
<header class="desk-head">
<span class="desk-num">01</span>
<h2 class="desk-title">World</h2>
<span class="desk-sub">top of last {{ generated_at.hour < 14 and "8" or "8" }} hours · headlines</span>
<span class="desk-count">{{ world_total }} items</span>
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
</header>
<ol class="headlines-list">
{% for h in world_headlines %}
<li class="headline" data-id="{{ h.id }}">
<button class="item-hide" type="button" title="Hide this headline" aria-label="Hide this headline">×</button>
<h3 class="headline-title">
<a href="{{ h.url }}" target="_blank" rel="noopener">{{ h.title }}</a>
</h3>
<span class="headline-source">{{ h.source }}</span>
<span class="headline-time">{{ h.posted_at|humanago }}</span>
{% if h.tldr %}
<p class="headline-tldr">{{ h.tldr }}</p>
{% endif %}
</li>
{% endfor %}
</ol>
</section>
{% endif %}
{# ────── LOCAL HEADLINES ────── #}
{% if local_headlines %}
<section id="local" class="desk desk-headlines">
<header class="desk-head">
<span class="desk-num">02</span>
<h2 class="desk-title">Local</h2>
<span class="desk-sub">orange county · greater LA · last 8 hours</span>
<span class="desk-count">{{ local_total }} items</span>
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
</header>
<ol class="headlines-list">
{% for h in local_headlines %}
<li class="headline" data-id="{{ h.id }}">
<button class="item-hide" type="button" title="Hide this headline" aria-label="Hide this headline">×</button>
<h3 class="headline-title">
<a href="{{ h.url }}" target="_blank" rel="noopener">{{ h.title }}</a>
</h3>
<span class="headline-source">{{ h.source }}</span>
<span class="headline-time">{{ h.posted_at|humanago }}</span>
{% if h.tldr %}
<p class="headline-tldr">{{ h.tldr }}</p>
{% endif %}
</li>
{% endfor %}
</ol>
</section>
{% endif %}
{# ────── REDDIT SECTION ────── #}
{% if reddit_sources %}
<section id="reddit" class="desk">
<header class="desk-head">
<span class="desk-num">03</span>
<h2 class="desk-title">Reddit</h2>
<span class="desk-sub">top of last {{ generated_at.hour < 14 and "12" or "12" }} hours · score-filtered</span>
<span class="desk-count">{{ reddit_total }} items</span>
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
</header>
{% for src in reddit_sources %}
<article class="source">
<header class="source-head">
<a class="source-name" href="{{ src.href }}" target="_blank" rel="noopener">
{{ src.name }}
</a>
<span class="source-count">{{ src.items|length }}</span>
</header>
<ol class="items">
{% for it in src.items %}
<li class="item" data-tag="{{ it.tag }}" data-id="{{ it.id }}">
<button class="item-hide" type="button" title="Hide this item" aria-label="Hide this item">×</button>
<div class="item-rail" aria-hidden="true">
{% if it.score %}
<span class="chip chip-score" title="upvotes">▲ {{ it.score }}</span>
{% endif %}
{% if it.comments %}
<span class="chip chip-comments" title="comments">{{ it.comments }} ⌥</span>
{% endif %}
{% if it.tag and it.tag != 'other' %}
<span class="chip chip-tag chip-tag-{{ it.tag }}">{{ it.tag }}</span>
{% endif %}
</div>
<div class="item-body">
<h3 class="item-title">
<a href="{{ it.url or it.permalink }}" target="_blank" rel="noopener">{{ it.title }}</a>
</h3>
{% if it.tldr %}
<p class="item-tldr">{{ it.tldr }}</p>
{% endif %}
<footer class="item-foot">
<div class="item-actions">
{% if it.url and it.url != it.permalink %}
<a class="action action-source" href="{{ it.url }}" target="_blank" rel="noopener">
<span class="action-arrow">↗</span>
<span class="action-label">SOURCE</span>
<span class="action-domain">{{ it.url|domain }}</span>
</a>
{% endif %}
<a class="action action-thread" href="{{ it.permalink }}" target="_blank" rel="noopener">
<span class="action-arrow">⌥</span>
<span class="action-label">REDDIT THREAD</span>
</a>
</div>
<div class="item-meta-row">
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
<span class="sep">·</span>
<span class="item-meta">u/{{ it.author }}</span>
</div>
</footer>
</div>
</li>
{% endfor %}
</ol>
</article>
{% endfor %}
</section>
{% endif %}
{# ────── TECH FEEDS SECTION ────── #}
{% if tech_sources %}
<section id="tech" class="desk">
<header class="desk-head">
<span class="desk-num">04</span>
<h2 class="desk-title">Tech Feeds</h2>
<span class="desk-sub">non-reddit · last {{ generated_at.hour < 14 and "12" or "12" }} hours</span>
<span class="desk-count">{{ tech_total }} items</span>
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
</header>
{% for src in tech_sources %}
<article class="source">
<header class="source-head">
<a class="source-name" href="{{ src.href }}" target="_blank" rel="noopener">
{{ src.name }}
</a>
<span class="source-count">{{ src.items|length }}</span>
</header>
<ol class="items">
{% for it in src.items %}
<li class="item" data-tag="{{ it.tag }}" data-id="{{ it.id }}">
<button class="item-hide" type="button" title="Hide this item" aria-label="Hide this item">×</button>
<div class="item-rail" aria-hidden="true">
{% if it.tag and it.tag != 'other' %}
<span class="chip chip-tag chip-tag-{{ it.tag }}">{{ it.tag }}</span>
{% endif %}
</div>
<div class="item-body">
<h3 class="item-title">
<a href="{{ it.url }}" target="_blank" rel="noopener">{{ it.title }}</a>
</h3>
{% if it.tldr %}
<p class="item-tldr">{{ it.tldr }}</p>
{% endif %}
<footer class="item-foot">
{% if it.url %}
<div class="item-actions">
<a class="action action-source" href="{{ it.url }}" target="_blank" rel="noopener">
<span class="action-arrow">↗</span>
<span class="action-label">SOURCE</span>
<span class="action-domain">{{ it.url|domain }}</span>
</a>
</div>
{% endif %}
<div class="item-meta-row">
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
{% if it.author %}
<span class="sep">·</span>
<span class="item-meta">{{ it.author }}</span>
{% endif %}
</div>
</footer>
</div>
</li>
{% endfor %}
</ol>
</article>
{% endfor %}
</section>
{% endif %}
{% if not reddit_sources and not tech_sources and not world_headlines and not local_headlines %}
<section class="desk empty">
<p class="empty-msg">
No items cleared the filters in the last window.<br>
Lower <code>DIGEST_MIN_SCORE</code> or widen
<code>DIGEST_REDDIT_HOURS</code> if this looks wrong.
</p>
</section>
{% endif %}
{# ────── HIDDEN TRAY ──────
Hidden by default. app.js unhides it once it has anything to show
(i.e. at least one .item on this page is in the user's hidden set).
Click the header to expand/collapse; click a row's restore button
to bring the item back into the desk it came from. #}
<section id="hidden-tray" class="hidden-tray" hidden>
<header class="hidden-tray-head">
<button class="hidden-tray-toggle" type="button" aria-expanded="false">
<span class="tray-glyph" aria-hidden="true">▾</span>
<span class="tray-label">Hidden</span>
<span class="tray-count" id="hidden-tray-count">0</span>
</button>
</header>
<ol class="hidden-tray-list" id="hidden-tray-list" hidden></ol>
</section>
</main>
<footer class="colophon">
<div class="colophon-inner">
<div class="colophon-block">
<span class="meta-label">FILED</span>
<span class="meta-value">{{ generated_at.isoformat(timespec="seconds") }}</span>
</div>
<div class="colophon-block">
<span class="meta-label">NEXT EDITION</span>
<span class="meta-value">{{ next_edition|upper }} · 12 H</span>
</div>
<div class="colophon-block">
<span class="meta-label">PIPELINE</span>
<span class="meta-value">REDDIT JSON + MINIFLUX → {{ model }} → JINJA2</span>
</div>
</div>
</footer>
<script src="app.js?v={{ generated_at.strftime('%Y%m%d%H%M%S') }}" defer></script>
</body>
</html>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<title>Daily Digest</title>
<desc>Masthead rule across the top with descending text-line indicators below — reads as "stacked headlines / news columns" at any size. Australis palette: cool-black surface, bright-cyan masthead, bright-70 / dark-50 text lines.</desc>
<rect width="32" height="32" rx="5" fill="#222531"/>
<!-- masthead rule (echoes the page's aurora-rule + cyan brand glyph) -->
<rect x="4" y="5" width="24" height="2" rx="1" fill="#42dcd1"/>
<!-- four text-line indicators of varying widths — like a newspaper -->
<rect x="4" y="11" width="20" height="2" rx="1" fill="#b3cbcf"/>
<rect x="4" y="16" width="24" height="2" rx="1" fill="#9daeb6"/>
<rect x="4" y="21" width="14" height="2" rx="1" fill="#9daeb6"/>
<rect x="4" y="26" width="18" height="2" rx="1" fill="#6e7882"/>
</svg>

Before

Width:  |  Height:  |  Size: 925 B

File diff suppressed because it is too large Load Diff
-88
View File
@@ -1,88 +0,0 @@
"""news-digest-web — FastAPI app that serves the digest + a tiny hidden-items API.
Replaces the old nginx web container. Two responsibilities:
1. Serve every file in /output as static content (index.html,
edition-*.html, archive.html, style.css, favicon.svg, app.js).
2. Expose /api/{hidden,hide,restore} so the per-item × button can
persist hidden state server-side, shared across every device the
user opens the digest from.
Storage is a single /output/hidden.json — array of item IDs the user
has hidden. Atomic writes via tempfile + rename; a threading lock
serializes the read-modify-write inside this single uvicorn worker.
Single-user setup, no auth (the digest itself is unauthenticated on
LAN; same trust boundary applies).
Item IDs are stable 12-char sha1 prefixes computed by digest.py at
render time and embedded in the page as `data-id` on each `.item`.
The frontend (templates/app.js) reads /api/hidden once on page load,
hides matching items pre-paint, and hits /api/hide and /api/restore
on user interactions.
"""
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
OUTPUT_DIR = Path(os.environ.get("DIGEST_OUTPUT_DIR", "/output"))
HIDDEN_FILE = OUTPUT_DIR / "hidden.json"
app = FastAPI(title="news-digest-web")
_lock = threading.Lock()
def _load_hidden() -> set[str]:
if not HIDDEN_FILE.exists():
return set()
try:
data = json.loads(HIDDEN_FILE.read_text())
return set(data) if isinstance(data, list) else set()
except (json.JSONDecodeError, OSError):
return set()
def _save_hidden(ids: set[str]) -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
tmp = HIDDEN_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(sorted(ids)))
tmp.replace(HIDDEN_FILE)
class IdBody(BaseModel):
id: str
@app.get("/api/hidden")
def get_hidden() -> list[str]:
return sorted(_load_hidden())
@app.post("/api/hide")
def post_hide(body: IdBody) -> dict[str, object]:
with _lock:
ids = _load_hidden()
ids.add(body.id)
_save_hidden(ids)
return {"ok": True, "count": len(ids)}
@app.post("/api/restore")
def post_restore(body: IdBody) -> dict[str, object]:
with _lock:
ids = _load_hidden()
ids.discard(body.id)
_save_hidden(ids)
return {"ok": True, "count": len(ids)}
# Mounted last so /api/* routes win precedence over a (nonexistent)
# /api/* file. html=True makes index.html the directory default,
# matching nginx's `try_files` behavior we used to rely on.
app.mount("/", StaticFiles(directory=str(OUTPUT_DIR), html=True), name="static")