diff --git a/README.md b/README.md index e7076ee..eb4e5ba 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Per-host snapshots of the running system live under `servers//system-detai **Anaheim non-GPU (ana-docker):** - `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) -- 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) - Fleet services: `beszel` (metrics hub, port 8090), `dozzle-hub` (log viewer, 8088), `backrest` (restic UI, 9898) - Backup target: `rest-server-ana` on port 8000 diff --git a/STATUS.md b/STATUS.md index e604e08..e779089 100644 --- a/STATUS.md +++ b/STATUS.md @@ -507,6 +507,20 @@ Side fix: moved `stacks/llama-swap/config.yaml` → the server layout (`stacks//conf/` → `/opt/docker/conf//`). +## 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) ### Audio generation on irv-ml1 (A6000) diff --git a/playbooks/deploy-news-digest.yaml b/playbooks/deploy-news-digest.yaml deleted file mode 100644 index 7daf6c0..0000000 --- a/playbooks/deploy-news-digest.yaml +++ /dev/null @@ -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" diff --git a/scripts/add-digest-user.sh b/scripts/add-digest-user.sh deleted file mode 100755 index 7750c5d..0000000 --- a/scripts/add-digest-user.sh +++ /dev/null @@ -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 # generate password -# scripts/add-digest-user.sh # 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-/ -# 6. Brings up the per-user stack (`docker compose -p digest- 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 < [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 < $HOST_COMPOSE_DIR/.env" <&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." diff --git a/stacks/news-digest/.env.example b/stacks/news-digest/.env.example deleted file mode 100644 index 0df5307..0000000 --- a/stacks/news-digest/.env.example +++ /dev/null @@ -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// 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 diff --git a/stacks/news-digest/Dockerfile b/stacks/news-digest/Dockerfile deleted file mode 100644 index 72253b8..0000000 --- a/stacks/news-digest/Dockerfile +++ /dev/null @@ -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"] diff --git a/stacks/news-digest/README.md b/stacks/news-digest/README.md deleted file mode 100644 index 656027c..0000000 --- a/stacks/news-digest/README.md +++ /dev/null @@ -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=|" .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 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//` 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=|" .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-/` and - `/opt/docker/data/digest-/` (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- 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-/ # 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-/ # 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--worker` and `digest--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. diff --git a/stacks/news-digest/compose.yaml b/stacks/news-digest/compose.yaml deleted file mode 100644 index 06a4f6c..0000000 --- a/stacks/news-digest/compose.yaml +++ /dev/null @@ -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 diff --git a/stacks/news-digest/digest.py b/stacks/news-digest/digest.py deleted file mode 100644 index 65e0ad9..0000000 --- a/stacks/news-digest/digest.py +++ /dev/null @@ -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-.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()) diff --git a/stacks/news-digest/entrypoint.sh b/stacks/news-digest/entrypoint.sh deleted file mode 100644 index 8e80bf8..0000000 --- a/stacks/news-digest/entrypoint.sh +++ /dev/null @@ -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 diff --git a/stacks/news-digest/run-digest.sh b/stacks/news-digest/run-digest.sh deleted file mode 100644 index b2b8589..0000000 --- a/stacks/news-digest/run-digest.sh +++ /dev/null @@ -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')] /" diff --git a/stacks/news-digest/seed-headlines.py b/stacks/news-digest/seed-headlines.py deleted file mode 100644 index e6232e8..0000000 --- a/stacks/news-digest/seed-headlines.py +++ /dev/null @@ -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 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()) diff --git a/stacks/news-digest/templates/app.js b/stacks/news-digest/templates/app.js deleted file mode 100644 index 4e37b16..0000000 --- a/stacks/news-digest/templates/app.js +++ /dev/null @@ -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(); - }); -})(); diff --git a/stacks/news-digest/templates/archive.html.j2 b/stacks/news-digest/templates/archive.html.j2 deleted file mode 100644 index 31d876d..0000000 --- a/stacks/news-digest/templates/archive.html.j2 +++ /dev/null @@ -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 - - - - - - -
-
-
- -

All Editions

- newest first -
- - {% if editions %} -
    - {% for e in editions %} -
  1. - - {{ e.date_long }} - - {{ e.edition_short }} - - - -
  2. - {% endfor %} -
- {% else %} -

No editions on file yet. Cron fires twice daily at 0800 / 2000.

- {% endif %} -
-
- -
-
-
- INDEXED - {{ generated_at.isoformat(timespec="seconds") }} -
-
- STORE - /opt/docker/data/news-digest -
-
- FORMAT - edition-YYYY-MM-DD-{am,pm}.html -
-
-
- - diff --git a/stacks/news-digest/templates/digest.html.j2 b/stacks/news-digest/templates/digest.html.j2 deleted file mode 100644 index f09cc03..0000000 --- a/stacks/news-digest/templates/digest.html.j2 +++ /dev/null @@ -1,301 +0,0 @@ - - - - - - Daily Digest · {{ date_long }} · {{ edition_short }} - - {# 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). #} - - - - {# ────── header ────── #} - - - {# ────── nav strip ────── #} - - -
- - {# ────── WORLD HEADLINES ────── #} - {% if world_headlines %} -
-
- 01 -

World

- top of last {{ generated_at.hour < 14 and "8" or "8" }} hours · headlines - {{ world_total }} items - -
-
    - {% for h in world_headlines %} -
  1. - -

    - {{ h.title }} -

    - {{ h.source }} - {{ h.posted_at|humanago }} - {% if h.tldr %} -

    {{ h.tldr }}

    - {% endif %} -
  2. - {% endfor %} -
-
- {% endif %} - - {# ────── LOCAL HEADLINES ────── #} - {% if local_headlines %} -
-
- 02 -

Local

- orange county · greater LA · last 8 hours - {{ local_total }} items - -
-
    - {% for h in local_headlines %} -
  1. - -

    - {{ h.title }} -

    - {{ h.source }} - {{ h.posted_at|humanago }} - {% if h.tldr %} -

    {{ h.tldr }}

    - {% endif %} -
  2. - {% endfor %} -
-
- {% endif %} - - {# ────── REDDIT SECTION ────── #} - {% if reddit_sources %} -
-
- 03 -

Reddit

- top of last {{ generated_at.hour < 14 and "12" or "12" }} hours · score-filtered - {{ reddit_total }} items - -
- - {% for src in reddit_sources %} -
-
- - {{ src.name }} - - {{ src.items|length }} -
-
    - {% for it in src.items %} -
  1. - - -
    -

    - {{ it.title }} -

    - {% if it.tldr %} -

    {{ it.tldr }}

    - {% endif %} - -
    -
  2. - {% endfor %} -
-
- {% endfor %} -
- {% endif %} - - {# ────── TECH FEEDS SECTION ────── #} - {% if tech_sources %} -
-
- 04 -

Tech Feeds

- non-reddit · last {{ generated_at.hour < 14 and "12" or "12" }} hours - {{ tech_total }} items - -
- - {% for src in tech_sources %} -
-
- - {{ src.name }} - - {{ src.items|length }} -
-
    - {% for it in src.items %} -
  1. - - -
    -

    - {{ it.title }} -

    - {% if it.tldr %} -

    {{ it.tldr }}

    - {% endif %} - -
    -
  2. - {% endfor %} -
-
- {% endfor %} -
- {% endif %} - - {% if not reddit_sources and not tech_sources and not world_headlines and not local_headlines %} -
-

- No items cleared the filters in the last window.
- Lower DIGEST_MIN_SCORE or widen - DIGEST_REDDIT_HOURS if this looks wrong. -

-
- {% 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. #} - - -
- -
-
-
- FILED - {{ generated_at.isoformat(timespec="seconds") }} -
-
- NEXT EDITION - {{ next_edition|upper }} · 12 H -
-
- PIPELINE - REDDIT JSON + MINIFLUX → {{ model }} → JINJA2 -
-
-
- - - diff --git a/stacks/news-digest/templates/favicon.svg b/stacks/news-digest/templates/favicon.svg deleted file mode 100644 index 74bfdb3..0000000 --- a/stacks/news-digest/templates/favicon.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - Daily Digest - 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. - - - - - - - - - diff --git a/stacks/news-digest/templates/style.css b/stacks/news-digest/templates/style.css deleted file mode 100644 index ddb60ef..0000000 --- a/stacks/news-digest/templates/style.css +++ /dev/null @@ -1,1046 +0,0 @@ -/* ------------------------------------------------------------------------- - news-digest — daily intel briefing. - - Aesthetic: operations-center chrome (Australis cool-mono palette, - JetBrains Mono UPPERCASE eyebrows, mdi-glyph anchor) wrapping - editorial-serif news content (Fraunces). Two type families that - wouldn't normally meet, intentionally combined: the chrome says - "filed at 0800 from the bridge"; the headlines say "this is news, - read it like news." - - Type: - Display (headlines): Fraunces — variable serif w/ optical sizes - Body (summaries): Inter — same as Australis, optimized for screens - Mono (chrome): JetBrains Mono — eyebrows / metadata / chips - - Color: pure Australis Ice + Sea + Aurora. Score chips lean - --aus-blue (info); release tags --aus-green; drama --aus-red - (sparingly). No warm accents — Australis is cool-only by design. - ------------------------------------------------------------------------- */ - -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700;9..144,800&display=swap'); - -:root { - /* Australis palette — vendored subset */ - --aus-black: #222531; - --aus-white: #a9bcc3; - --aus-bright-white: #cce7ec; - --aus-bright-black: #373b46; - --aus-dark-30: #414751; - --aus-dark-40: #565f69; - --aus-dark-50: #6e7882; - --aus-dark-60: #86929d; - --aus-bright-70: #9daeb6; - --aus-bright-80: #b3cbcf; - --aus-blue: #6388d8; - --aus-bright-blue: #a4c4ff; - --aus-cyan: #00b1a8; - --aus-bright-cyan: #42dcd1; - --aus-green: #16b866; - --aus-bright-green: #51e08a; - --aus-yellow: #e1c631; - --aus-bright-yellow:#ffe14e; - --aus-red: #ff491a; - --aus-magenta: #9d78ff; - - --bg: var(--aus-black); - --bg-elev: var(--aus-bright-black); - --surface: #2a2e3a; - --surface-hi: var(--aus-dark-30); - --rule: var(--aus-dark-30); - --rule-bright: var(--aus-dark-40); - --fg: var(--aus-bright-white); - --fg-dim: var(--aus-bright-70); - --fg-faint: var(--aus-dark-50); - --accent: var(--aus-bright-cyan); - --accent-dim: var(--aus-cyan); - - /* Type families */ - --font-display: "Fraunces", "Times New Roman", Georgia, serif; - --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; - --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; - - /* Scale + chrome */ - --pad-x: 24px; - --content-w: 880px; -} - -*, *::before, *::after { box-sizing: border-box; } -[hidden] { display: none !important; } - -html, body { - margin: 0; - background: var(--bg); - color: var(--fg); - font: 16px/1.55 var(--font-sans); - font-feature-settings: "ss01" 1; - -webkit-font-smoothing: antialiased; -} - -a { color: inherit; text-decoration: none; } -a:hover { color: var(--accent); } - -/* ------------------------------------------------------------------------- - masthead — full-width banner; the "I am a news edition" statement - ------------------------------------------------------------------------- */ - -.masthead { - border-bottom: 1px solid var(--rule); - background: var(--bg-elev); -} - -.masthead-inner { - max-width: var(--content-w); - margin: 0 auto; - padding: 36px var(--pad-x) 24px; - display: grid; - grid-template-columns: auto 1fr; - grid-template-rows: auto auto; - column-gap: 32px; - row-gap: 16px; - align-items: end; -} - -.masthead-brand { - grid-column: 1 / 2; - grid-row: 1 / 2; - display: flex; - align-items: baseline; - gap: 10px; - font-family: var(--font-mono); - /* Brand wraps an so the hero is clickable to - the latest edition from any archived page. Strip default link - styling so the visual is unchanged. */ - color: inherit; - text-decoration: none; - transition: opacity 0.15s ease; -} -.masthead-brand:hover { - opacity: 0.85; -} -.masthead-brand:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 4px; - border-radius: 2px; -} -.brand-glyph { - font-size: 32px; - line-height: 1; - color: var(--accent); - text-shadow: 0 0 12px rgba(66, 220, 209, .35); -} -.brand-mark { - font-size: 14px; - font-weight: 700; - letter-spacing: .18em; - color: var(--fg); -} - -.masthead-edition { - grid-column: 2 / 3; - grid-row: 1 / 2; - display: flex; - align-items: baseline; - justify-content: flex-end; - gap: 18px; - font-family: var(--font-mono); -} -.archive-link { - color: var(--fg-dim); - font-size: 11px; - letter-spacing: .14em; - border-bottom: 1px dotted var(--fg-faint); - padding-bottom: 1px; -} -.archive-link:hover { color: var(--accent); border-color: var(--accent-dim); } -.edition-num { - color: var(--fg-faint); - font-size: 11px; - letter-spacing: .14em; -} -.edition-stamp { - /* THE statement piece — large mono in Australis yellow. - "This is the morning/evening brief, filed officially." */ - font-family: var(--font-mono); - font-size: 22px; - font-weight: 700; - letter-spacing: .14em; - color: var(--aus-yellow); - padding: 4px 10px; - border: 1px solid var(--aus-yellow); - border-radius: 2px; -} - -.masthead-meta { - grid-column: 1 / 3; - grid-row: 2 / 3; - display: grid; - grid-template-columns: repeat(3, auto); - gap: 4px 32px; - align-items: baseline; - font-family: var(--font-mono); - padding-top: 16px; - border-top: 1px solid var(--rule); -} -.meta-row { - display: flex; - align-items: baseline; - gap: 10px; - font-size: 11px; - letter-spacing: .08em; -} -.meta-label { - color: var(--fg-faint); - text-transform: uppercase; - letter-spacing: .14em; -} -.meta-value { - color: var(--fg); - font-variant-numeric: tabular-nums; -} - -/* The aurora glow rule — sanctioned Australis gradient, used once. */ -.aurora-rule { - height: 2px; - background: linear-gradient(90deg, - transparent 0%, - var(--aus-bright-blue) 25%, - var(--aus-bright-cyan) 50%, - var(--aus-bright-green) 75%, - transparent 100%); - opacity: .65; -} - -/* ------------------------------------------------------------------------- - jump nav - ------------------------------------------------------------------------- */ - -.jumpnav { - position: sticky; - top: 0; - z-index: 5; - background: rgba(34, 37, 49, 0.92); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border-bottom: 1px solid var(--rule); -} -.jumpnav ol { - list-style: none; - margin: 0; - padding: 12px var(--pad-x); - max-width: var(--content-w); - margin-inline: auto; - display: flex; - gap: 28px; - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: .14em; - text-transform: uppercase; -} -.jumpnav a { - color: var(--fg-dim); - display: inline-flex; - align-items: baseline; - gap: 8px; -} -.jumpnav a:hover { color: var(--fg); } -.jump-num { color: var(--fg-faint); font-size: 10px; } - -/* ------------------------------------------------------------------------- - main brief - ------------------------------------------------------------------------- */ - -.brief { - max-width: var(--content-w); - margin: 0 auto; - padding: 32px var(--pad-x) 64px; -} - -.desk + .desk { margin-top: 64px; } - -.desk-head { - display: grid; - /* num | title | count | collapse — explicit columns so the - collapse button doesn't auto-flow into the desk-sub row. */ - grid-template-columns: auto 1fr auto auto; - align-items: baseline; - column-gap: 16px; - padding-bottom: 14px; - margin-bottom: 24px; - border-bottom: 1px solid var(--rule-bright); -} -.desk-num { - font-family: var(--font-mono); - font-size: 11px; - font-weight: 700; - letter-spacing: .14em; - color: var(--aus-yellow); - padding-top: 4px; /* aligns visually with the serif baseline */ -} -.desk-title { - font-family: var(--font-display); - font-size: 38px; - font-weight: 700; - letter-spacing: -0.015em; - margin: 0; - color: var(--fg); - font-variation-settings: "opsz" 144; -} -.desk-sub { - /* Sub-line hugs the title baseline */ - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: .08em; - color: var(--fg-faint); - text-transform: uppercase; - grid-column: 2 / 3; - align-self: end; - padding-bottom: 6px; - padding-left: 12px; -} -.desk-count { - font-family: var(--font-mono); - font-size: 11px; - font-variant-numeric: tabular-nums; - letter-spacing: .08em; - color: var(--fg-dim); - text-transform: uppercase; -} - -/* Per-desk collapse toggle. Sits on the right edge of the desk header, - clicking collapses the body to a single-line summary. State is - persisted in localStorage by section id (#world, #local, etc), so - each device remembers per-user preference. */ -.desk-collapse { - grid-column: 4 / 5; - grid-row: 1 / 2; - align-self: center; - background: transparent; - border: 1px solid transparent; - border-radius: 4px; - padding: 4px 8px; - font-family: var(--font-mono); - font-size: 14px; - line-height: 1; - color: var(--fg-dim); - cursor: pointer; - transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease, transform 0.18s ease; -} -.desk-collapse:hover, -.desk-collapse:focus-visible { - color: var(--accent); - border-color: var(--rule-bright); - background: var(--bg-elev); - outline: none; -} -.desk.is-collapsed .desk-collapse { - transform: rotate(-90deg); -} -/* When collapsed, hide everything inside the desk EXCEPT the header. - The .desk-head is direct child; .source / .headlines-list etc are - the rest of the body. */ -.desk.is-collapsed > :not(.desk-head) { - display: none; -} -/* Tighten the header bottom-border in collapsed state — no items below - means the heavy underline looks weird floating alone. */ -.desk.is-collapsed .desk-head { - margin-bottom: 0; - padding-bottom: 8px; - border-bottom: 1px dashed var(--rule); -} - -/* ------------------------------------------------------------------------- - per-source block - ------------------------------------------------------------------------- */ - -.source + .source { margin-top: 36px; } - -.source-head { - display: flex; - align-items: baseline; - gap: 14px; - margin-bottom: 14px; -} -.source-name { - font-family: var(--font-mono); - font-size: 12px; - font-weight: 700; - letter-spacing: .12em; - text-transform: uppercase; - color: var(--accent); - border-bottom: 1px dashed var(--accent-dim); - padding-bottom: 1px; -} -.source-name:hover { color: var(--aus-bright-cyan); } -.source-count { - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: .14em; - color: var(--fg-faint); - font-variant-numeric: tabular-nums; -} - -/* ------------------------------------------------------------------------- - item — the news card - ------------------------------------------------------------------------- */ - -.items { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: 24px; -} - -.item { - display: grid; - grid-template-columns: 96px 1fr; - column-gap: 24px; - padding-bottom: 24px; - border-bottom: 1px solid var(--rule); -} -.item:last-child { - border-bottom: 0; - padding-bottom: 0; -} - -.item-rail { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 6px; - padding-top: 6px; -} - -.chip { - font-family: var(--font-mono); - font-size: 10.5px; - font-weight: 600; - letter-spacing: .06em; - padding: 2px 8px; - border: 1px solid currentColor; - border-radius: 2px; - white-space: nowrap; - font-variant-numeric: tabular-nums; -} -.chip-score { color: var(--aus-blue); } -.chip-comments { color: var(--fg-dim); border-color: var(--rule-bright); } -.chip-tag { - font-size: 9.5px; - letter-spacing: .14em; - text-transform: uppercase; -} -.chip-tag-news { color: var(--aus-bright-blue); } -.chip-tag-tutorial { color: var(--aus-bright-cyan); } -.chip-tag-release { color: var(--aus-bright-green); } -.chip-tag-discussion{ color: var(--fg-dim); } -.chip-tag-question { color: var(--aus-yellow); } -.chip-tag-showcase { color: var(--aus-magenta); } -.chip-tag-drama { color: var(--aus-red); } -.chip-tag-meme { color: var(--fg-faint); } - -.item-body { - min-width: 0; -} - -.item-title { - margin: 0 0 8px; - font-family: var(--font-display); - font-size: 22px; - line-height: 1.2; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--fg); - font-variation-settings: "opsz" 36; -} -.item-title a { color: inherit; } -.item-title a:hover { color: var(--accent); } - -.item-tldr { - margin: 0 0 12px; - font-size: 15px; - line-height: 1.55; - color: var(--fg-dim); -} - -.item-foot { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 16px; - font-family: var(--font-mono); - font-size: 10.5px; - letter-spacing: .06em; - color: var(--fg-faint); -} -/* Action chips — explicit "go here" buttons. Reddit items get TWO of - these when the post links externally: SOURCE (the linked content) - and REDDIT THREAD (the discussion). Non-Reddit items get just SOURCE. */ -.item-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; -} -.action { - display: inline-flex; - align-items: baseline; - gap: 6px; - padding: 4px 10px; - border: 1px solid var(--rule-bright); - border-radius: 2px; - font-family: var(--font-mono); - font-size: 10.5px; - font-weight: 600; - letter-spacing: .08em; - text-transform: uppercase; - color: var(--fg-dim); - transition: border-color 120ms, color 120ms, background 120ms; -} -.action-arrow { - color: var(--accent-dim); - font-size: 11px; -} -.action-label { color: var(--fg); } -.action-domain { - color: var(--fg-dim); - font-weight: 500; - letter-spacing: .04em; - text-transform: lowercase; -} -.action:hover { - border-color: var(--accent); - background: rgba(66, 220, 209, 0.08); -} -.action:hover .action-arrow, -.action:hover .action-label, -.action:hover .action-domain { color: var(--accent); } - -.action-source { /* default styling above is "source" */ } -.action-thread .action-arrow { color: var(--aus-bright-blue); } -.action-thread:hover { border-color: var(--aus-bright-blue); background: rgba(164, 196, 255, 0.08); } -.action-thread:hover .action-arrow, -.action-thread:hover .action-label { color: var(--aus-bright-blue); } - -.item-meta-row { - display: flex; - align-items: baseline; - gap: 8px; - margin-left: auto; /* pushes timestamp to the right */ -} -.item-meta { font-variant-numeric: tabular-nums; color: var(--fg-faint); } -.sep { color: var(--rule-bright); } - -/* Visual de-emphasize purely meme/skip items if any slip through. */ -.item[data-tag="meme"] { opacity: .58; } -.item[data-tag="other"] { opacity: .85; } - -/* ------------------------------------------------------------------------- - headlines — dense list (world / local). - Different rhythm from .item cards: one row per story, source on the - right, title-led. Volume-driven sections where the deliverable is - "what happened" not "what someone said about what happened". - ------------------------------------------------------------------------- */ - -.headlines-list { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: 0; -} - -.headline { - position: relative; /* anchors the absolute-positioned × button */ - display: grid; - grid-template-columns: 1fr auto auto; - grid-template-rows: auto auto; - align-items: baseline; - column-gap: 16px; - row-gap: 4px; - padding: 14px 0; - border-bottom: 1px solid var(--rule); -} -.headline-title { grid-column: 1 / 2; grid-row: 1; } -.headline-source { grid-column: 2 / 3; grid-row: 1; } -.headline-time { grid-column: 3 / 4; grid-row: 1; } -.headline-tldr { - grid-column: 1 / 4; - grid-row: 2; - margin: 4px 0 2px; - font-family: var(--font-sans); - font-size: 14px; - line-height: 1.5; - color: var(--fg-dim); -} -.headline:last-child { - border-bottom: 0; -} - -.headline-title { - margin: 0; - font-family: var(--font-display); - font-size: 17px; - line-height: 1.35; - font-weight: 500; - letter-spacing: -0.005em; - color: var(--fg); - font-variation-settings: "opsz" 24; - /* Title takes the leftmost column; allow it to wrap on narrow screens. */ - min-width: 0; -} -.headline-title a { color: inherit; } -.headline-title a:hover { color: var(--accent); } - -.headline-source { - font-family: var(--font-mono); - font-size: 10px; - font-weight: 600; - letter-spacing: .12em; - color: var(--fg-faint); - text-transform: uppercase; - white-space: nowrap; -} - -.headline-time { - font-family: var(--font-mono); - font-size: 10.5px; - color: var(--fg-faint); - font-variant-numeric: tabular-nums; - min-width: 32px; - text-align: right; -} - -/* Hidden state — same mechanism as .item, applies to .headline too. */ -.headline.is-hidden { display: none; } - -/* ------------------------------------------------------------------------- - archive page — list of all editions - ------------------------------------------------------------------------- */ - -.archive-list { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: 0; - border: 1px solid var(--rule); - border-radius: 2px; - overflow: hidden; -} -.archive-item + .archive-item { border-top: 1px solid var(--rule); } - -.archive-row { - display: grid; - grid-template-columns: 1fr auto auto; - align-items: center; - gap: 24px; - padding: 16px 20px; - background: var(--surface); - transition: background 120ms; -} -.archive-row:hover { - background: var(--surface-hi); - color: var(--fg); -} - -.archive-date { - font-family: var(--font-display); - font-size: 17px; - font-weight: 500; - letter-spacing: -0.005em; - color: var(--fg); - font-variation-settings: "opsz" 36; -} - -.archive-edition { - font-family: var(--font-mono); - font-size: 10.5px; - font-weight: 700; - letter-spacing: .14em; - padding: 3px 9px; - border: 1px solid currentColor; - border-radius: 2px; -} -.archive-edition-am { color: var(--aus-yellow); } -.archive-edition-pm { color: var(--aus-bright-cyan); } - -.archive-arrow { - font-family: var(--font-mono); - color: var(--fg-faint); - font-size: 14px; -} -.archive-row:hover .archive-arrow { color: var(--accent); } - -/* ------------------------------------------------------------------------- - empty state + colophon - ------------------------------------------------------------------------- */ - -.desk.empty { - text-align: center; - padding: 72px 24px; - border: 1px dashed var(--rule); - border-radius: 6px; -} -.empty-msg { - font-family: var(--font-mono); - font-size: 13px; - color: var(--fg-faint); - line-height: 1.6; -} -.empty-msg code { - color: var(--fg-dim); - font-family: inherit; - background: var(--surface); - padding: 2px 6px; - border-radius: 2px; -} - -.colophon { - border-top: 1px solid var(--rule); - background: var(--bg-elev); - padding: 24px var(--pad-x); - margin-top: 64px; -} -.colophon-inner { - max-width: var(--content-w); - margin: 0 auto; - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 20px; - font-family: var(--font-mono); - font-size: 10.5px; - letter-spacing: .08em; -} -.colophon-block { - display: flex; - flex-direction: column; - gap: 4px; -} -.colophon-block .meta-label { color: var(--fg-faint); font-size: 10px; } -.colophon-block .meta-value { color: var(--fg-dim); font-size: 11px; } - -/* ------------------------------------------------------------------------- - per-item × button + hidden tray - ------------------------------------------------------------------------- */ - -/* The .item layout is a 2-column grid; the × button absolute-positions - over its top-right corner so it doesn't disturb the rail/body grid. */ -.item { - position: relative; -} - -.item-hide { - position: absolute; - top: -2px; - right: -4px; - width: 22px; - height: 22px; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - border: 1px solid transparent; - border-radius: 4px; - color: var(--fg-faint); - font-family: var(--font-sans); - font-size: 18px; - line-height: 1; - cursor: pointer; - opacity: 0; - transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease, background 0.15s ease; -} -.item:hover .item-hide, -.item:focus-within .item-hide, -.item-hide:focus-visible { - opacity: 1; -} -.item-hide:hover, -.item-hide:focus-visible { - color: var(--aus-red); - border-color: var(--rule-bright); - background: var(--bg-elev); - outline: none; -} - -/* Hidden state — display:none rather than animate, since we re-render - pre-paint from the API set on every page load. Keeping it simple. */ -.item.is-hidden { - display: none; -} - -/* When every item in a .source (or every source in a .desk) is hidden, - collapse the whole header out — no point rendering "r/homelab (0)". - app.js applies .is-empty when the visible-count drops to zero. */ -.source.is-empty, -.desk.is-empty { - display: none; -} - -.hidden-tray { - margin-top: 24px; - padding: 14px 18px; - background: var(--bg-elev); - border: 1px solid var(--rule); - border-radius: 6px; -} -.hidden-tray-head { - margin: 0; -} -.hidden-tray-toggle { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 4px 6px; - margin-left: -6px; - background: transparent; - border: none; - color: var(--fg-dim); - font-family: var(--font-sans); - font-size: 11px; - letter-spacing: 0.14em; - text-transform: uppercase; - cursor: pointer; - border-radius: 4px; - transition: color 0.15s ease, background 0.15s ease; -} -.hidden-tray-toggle:hover, -.hidden-tray-toggle:focus-visible { - color: var(--fg); - background: var(--surface); - outline: none; -} -.tray-glyph { - display: inline-block; - font-size: 11px; - transition: transform 0.18s ease; -} -.hidden-tray.is-expanded .tray-glyph { - transform: rotate(180deg); -} -.tray-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 22px; - padding: 0 6px; - background: var(--surface); - color: var(--fg); - border-radius: 10px; - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0; -} - -.hidden-tray-list { - margin: 12px 0 0; - padding: 0; - list-style: none; - display: flex; - flex-direction: column; - gap: 4px; -} -.hidden-tray-item { - display: grid; - grid-template-columns: 1fr auto; - align-items: baseline; - gap: 12px; - padding: 6px 8px; - border-radius: 4px; - transition: background 0.12s ease; -} -.hidden-tray-item:hover { - background: var(--surface); -} -.hidden-tray-title { - font-family: var(--font-display); - font-weight: 400; - font-size: 14px; - color: var(--fg-dim); - line-height: 1.35; - /* Truncate with ellipsis on long titles so the row stays one line. */ - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.hidden-tray-restore { - padding: 2px 8px; - background: transparent; - border: 1px solid var(--rule-bright); - border-radius: 4px; - color: var(--fg-dim); - font-family: var(--font-sans); - font-size: 12px; - cursor: pointer; - transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; -} -.hidden-tray-restore:hover, -.hidden-tray-restore:focus-visible { - color: var(--accent); - border-color: var(--accent); - background: var(--bg); - outline: none; -} - -/* ------------------------------------------------------------------------- - responsive — two breakpoints: - ≤ 720 px : tablet / narrow desktop. Soften the layout but still - room for two-column-ish. - ≤ 480 px : true phone. Strip the rail, hide section numbers, tighten - everything for a single-thumb scroll. - ------------------------------------------------------------------------- */ - -@media (max-width: 720px) { - :root { --pad-x: 16px; } - .masthead-inner { - grid-template-columns: 1fr; - grid-template-rows: auto auto auto; - padding: 24px var(--pad-x) 16px; - row-gap: 12px; - } - /* Desktop pins brand+edition both to grid-row 1, which collapses - them on top of each other once we go single-column. Reset row - placement so they auto-flow vertically. */ - .masthead-brand, - .masthead-edition, - .masthead-meta { - grid-column: 1 / 2; - grid-row: auto; - } - .masthead-edition { justify-content: flex-start; flex-wrap: wrap; gap: 12px; } - .edition-stamp { font-size: 16px; padding: 3px 8px; } - .masthead-meta { - grid-template-columns: 1fr; - gap: 6px; - padding-top: 12px; - } - .meta-row { font-size: 10.5px; flex-wrap: wrap; } - - .jumpnav ol { gap: 18px; padding: 10px var(--pad-x); } - - /* Desk header: num / title row 1, sub spans below, count + collapse - pushed to row 1 right-side. */ - .desk-head { - grid-template-columns: auto 1fr auto; - row-gap: 6px; - } - .desk-num { padding-top: 2px; } - .desk-title { font-size: 26px; } - .desk-sub { - grid-column: 1 / 4; - padding-left: 0; - padding-bottom: 0; - align-self: start; - } - .desk-count { font-size: 10px; align-self: center; } - .desk-collapse { - grid-column: 3 / 4; - grid-row: 1 / 2; - margin-left: 4px; - /* Bigger touch target for thumbs. */ - padding: 6px 10px; - font-size: 16px; - } - - /* Items — keep the rail but compact it. */ - .item { grid-template-columns: 60px 1fr; column-gap: 12px; } - .item-rail { padding-top: 4px; gap: 4px; } - .chip { font-size: 9.5px; padding: 1px 6px; } - .item-title { font-size: 18px; } - .item-tldr { font-size: 14px; } - - .colophon-inner { grid-template-columns: 1fr; } - - /* On touch screens there's no hover, so the × always shows. - Slightly bigger tap target too (44×44 minimum is the WCAG ask; - 28 is the visible bullseye, padded to ~44 effective via the - parent .item gutter). */ - .item-hide { opacity: 1; width: 30px; height: 30px; font-size: 20px; } - .hidden-tray { padding: 12px 14px; } - - /* Headlines: stack title / source / time / tldr vertically. */ - .headline { - grid-template-columns: 1fr; - gap: 4px; - padding: 12px 0; - } - .headline-title, - .headline-source, - .headline-time, - .headline-tldr { - grid-column: 1 / 2; - grid-row: auto; - } - .headline-time { text-align: left; } - .headline-tldr { font-size: 14px; } -} - -@media (max-width: 480px) { - :root { --pad-x: 14px; } - - /* Masthead: shrink the brand strip, drop the edition number badge. */ - .masthead-inner { padding: 18px var(--pad-x) 12px; } - .brand-glyph { font-size: 26px; } - .brand-mark { font-size: 12px; letter-spacing: .14em; } - .edition-num { display: none; } - .archive-link { font-size: 10px; } - - .jumpnav ol { - gap: 14px; - font-size: 10.5px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - flex-wrap: nowrap; - white-space: nowrap; - } - .jumpnav { -webkit-overflow-scrolling: touch; } - - /* Phone: drop the section number badge AND the rail. Chips for - reddit cards float into the body inline (the "▲ 234" stays - useful but the column itself is wasteful at this width). */ - .desk-num { display: none; } - .desk-head { grid-template-columns: 1fr auto; column-gap: 10px; } - .desk-title { font-size: 22px; } - .desk-sub { grid-column: 1 / 3; } - .desk-count { display: none; } - .desk-collapse { grid-column: 2 / 3; padding: 8px 12px; } - - /* Strip the rail entirely; chips go inline above the title. Keeps - score/comment context but reclaims the 60px gutter for content. */ - .item { - grid-template-columns: 1fr; - column-gap: 0; - row-gap: 6px; - padding-bottom: 18px; - } - .item-rail { - flex-direction: row; - align-items: center; - align-self: start; - gap: 6px; - padding-top: 0; - } - .chip { font-size: 9px; padding: 1px 5px; } - .item-title { font-size: 17px; line-height: 1.3; } - .item-tldr { font-size: 13.5px; } - .item-foot { gap: 10px; font-size: 10px; } - .action { padding: 4px 8px; font-size: 10px; } - .item-meta-row { margin-left: 0; flex-basis: 100%; } - - .colophon { padding: 18px var(--pad-x); margin-top: 32px; } - .colophon-block .meta-value { font-size: 10.5px; word-break: break-word; } - - .brief { padding: 20px var(--pad-x) 40px; } - .desk + .desk { margin-top: 40px; } -} - -/* Belt-and-suspenders: never let a long URL or unbroken token push - horizontal overflow. */ -@media (max-width: 720px) { - body { overflow-x: hidden; } - .item-title, .item-tldr, .headline-title, .headline-tldr { - overflow-wrap: anywhere; - word-break: break-word; - } -} diff --git a/stacks/news-digest/web.py b/stacks/news-digest/web.py deleted file mode 100644 index 5eb0fd0..0000000 --- a/stacks/news-digest/web.py +++ /dev/null @@ -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")