extract news-digest into its own repo (vh/nevermore)
The applet outgrew "stack alongside the infra-management workspace" — it has its own pyproject, multi-tenant deploy story, separate release cadence, and isn't actually about managing infrastructure. Lives at https://gitea.phasefinal.com/vh/nevermore now, with provenance noted in its initial commit. This commit removes: stacks/news-digest/ (full stack tree) playbooks/deploy-news-digest.yaml scripts/add-digest-user.sh The existing ana-docker deployment continues running on its baked local/news-digest:v5 image — nothing changes for the live install until you choose to redeploy from the new repo. Migration steps (rename data dir, redeploy, retire old compose dir) are in nevermore's README. Updated: README.md — Current stacks listing now points at the new repo STATUS.md — milestones entry for the extraction
This commit is contained in:
@@ -71,7 +71,7 @@ Per-host snapshots of the running system live under `servers/<host>/system-detai
|
|||||||
**Anaheim non-GPU (ana-docker):**
|
**Anaheim non-GPU (ana-docker):**
|
||||||
- `traefik`, `crowdsec`, `gitea`, `vaultwarden`, `synapse`, `seafile`, `searxng`, `openwebui`, `sillytavern`, `mailrise`, `rustdesk`, `dockge`, `it-tools`
|
- `traefik`, `crowdsec`, `gitea`, `vaultwarden`, `synapse`, `seafile`, `searxng`, `openwebui`, `sillytavern`, `mailrise`, `rustdesk`, `dockge`, `it-tools`
|
||||||
- (`mattermost` retired 2026-04-21 — compose dir may still linger, containers gone)
|
- (`mattermost` retired 2026-04-21 — compose dir may still linger, containers gone)
|
||||||
- Notes / feeds: `miniflux` (RSS, 8080), `news-digest` (twice-daily LLM-curated brief, 8181, multi-tenant via `scripts/add-digest-user.sh`), `memos` (note server, 5230)
|
- Notes / feeds: `miniflux` (RSS, 8080), `nevermore` (twice-daily LLM-curated brief, 8181, multi-tenant — extracted to its own repo at [`vh/nevermore`](https://gitea.phasefinal.com/vh/nevermore)), `memos` (note server, 5230)
|
||||||
- Assistant tooling: `task-board` (MCP + dashboard for assistant task state, 7878)
|
- Assistant tooling: `task-board` (MCP + dashboard for assistant task state, 7878)
|
||||||
- Fleet services: `beszel` (metrics hub, port 8090), `dozzle-hub` (log viewer, 8088), `backrest` (restic UI, 9898)
|
- Fleet services: `beszel` (metrics hub, port 8090), `dozzle-hub` (log viewer, 8088), `backrest` (restic UI, 9898)
|
||||||
- Backup target: `rest-server-ana` on port 8000
|
- Backup target: `rest-server-ana` on port 8000
|
||||||
|
|||||||
@@ -507,6 +507,20 @@ Side fix: moved `stacks/llama-swap/config.yaml` →
|
|||||||
the server layout (`stacks/<stack>/conf/<file>` →
|
the server layout (`stacks/<stack>/conf/<file>` →
|
||||||
`/opt/docker/conf/<stack>/<file>`).
|
`/opt/docker/conf/<stack>/<file>`).
|
||||||
|
|
||||||
|
## Session milestones — 2026-04-29 (Nevermore extraction)
|
||||||
|
|
||||||
|
The news-digest applet was extracted into its own repo at
|
||||||
|
[`vh/nevermore`](https://gitea.phasefinal.com/vh/nevermore) — clean
|
||||||
|
break, fresh git history, renamed throughout (DIGEST_* → NEVERMORE_*,
|
||||||
|
news-digest-* containers → nevermore-*, masthead → "Nevermore"). The
|
||||||
|
existing `news-digest-*` deployment on ana-docker continues to run
|
||||||
|
unchanged on its baked image; future updates come from the new repo.
|
||||||
|
Migration path documented in nevermore's README.
|
||||||
|
|
||||||
|
Removed from this workspace:
|
||||||
|
stacks/news-digest/, playbooks/deploy-news-digest.yaml,
|
||||||
|
scripts/add-digest-user.sh.
|
||||||
|
|
||||||
## Session milestones — 2026-04-28..29 (audio gen, digest applet, task-board polish, tooling)
|
## Session milestones — 2026-04-28..29 (audio gen, digest applet, task-board polish, tooling)
|
||||||
|
|
||||||
### Audio generation on irv-ml1 (A6000)
|
### Audio generation on irv-ml1 (A6000)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# add-digest-user.sh — provision a per-user news-digest stack.
|
|
||||||
#
|
|
||||||
# Multi-tenant onboarding for the daily-digest applet. One miniflux
|
|
||||||
# instance, multiple miniflux users, one news-digest stack per user
|
|
||||||
# (own port, own output dir, own hide-state).
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/add-digest-user.sh <username> # generate password
|
|
||||||
# scripts/add-digest-user.sh <username> <pass> # set explicit password
|
|
||||||
#
|
|
||||||
# What it does:
|
|
||||||
# 1. Reads miniflux admin creds from ana-docker:/opt/docker/compose/miniflux/.env
|
|
||||||
# 2. Allocates the next free NEWS_DIGEST_PORT above 8181
|
|
||||||
# 3. Creates a miniflux user via the admin API
|
|
||||||
# 4. Creates per-user dirs on ana-docker (sudo prompt expected once)
|
|
||||||
# 5. Materializes a per-user .env at /opt/docker/compose/digest-<user>/
|
|
||||||
# 6. Brings up the per-user stack (`docker compose -p digest-<user> up -d`)
|
|
||||||
# 7. Seeds default world/local/tech feeds in the new user's miniflux account
|
|
||||||
#
|
|
||||||
# Idempotent-ish: re-running for an existing user re-syncs config + feeds
|
|
||||||
# but won't recreate the miniflux user (409 from /v1/users is non-fatal).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
usage: $0 <username> [password] [--am "MIN HR * * *"] [--pm "MIN HR * * *"]
|
|
||||||
|
|
||||||
Defaults: AM = "0 8 * * *", PM = "0 20 * * *". Cron syntax is
|
|
||||||
standard 5-field; busybox crond honors the container's \$TZ.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
$0 alice
|
|
||||||
$0 bob --am "0 6 * * *" --pm "0 17 * * *"
|
|
||||||
$0 carol s3cret --pm "30 18 * * 1-5" # weekdays only PM run
|
|
||||||
EOF
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
USER_ARG=""
|
|
||||||
USER_PASS=""
|
|
||||||
CRON_AM="0 8 * * *"
|
|
||||||
CRON_PM="0 20 * * *"
|
|
||||||
|
|
||||||
while [ "$#" -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--am) CRON_AM="$2"; shift 2 ;;
|
|
||||||
--pm) CRON_PM="$2"; shift 2 ;;
|
|
||||||
--help|-h) usage ;;
|
|
||||||
--*) echo "unknown flag: $1" >&2; usage ;;
|
|
||||||
*)
|
|
||||||
if [ -z "$USER_ARG" ]; then
|
|
||||||
USER_ARG="$1"
|
|
||||||
elif [ -z "$USER_PASS" ]; then
|
|
||||||
USER_PASS="$1"
|
|
||||||
else
|
|
||||||
echo "unexpected positional arg: $1" >&2; usage
|
|
||||||
fi
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[ -n "$USER_ARG" ] || usage
|
|
||||||
|
|
||||||
HOST=ana-docker
|
|
||||||
WORKSTATION_STACK="$(dirname "$0")/../stacks/news-digest"
|
|
||||||
PROJECT="digest-$USER_ARG"
|
|
||||||
HOST_COMPOSE_DIR="/opt/docker/compose/$PROJECT"
|
|
||||||
HOST_DATA_DIR="/opt/docker/data/$PROJECT"
|
|
||||||
PORT_BASE=8181
|
|
||||||
|
|
||||||
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
|
||||||
info() { printf ' %s\n' "$*"; }
|
|
||||||
|
|
||||||
bold "→ provisioning news-digest for user '$USER_ARG'"
|
|
||||||
|
|
||||||
# 1. Pull miniflux admin creds from host
|
|
||||||
info "reading miniflux admin creds from $HOST"
|
|
||||||
admin_creds=$(ssh "$HOST" 'grep -E "^MINIFLUX_ADMIN_(USERNAME|PASSWORD)=" /opt/docker/compose/miniflux/.env')
|
|
||||||
admin_user=$(awk -F= '/^MINIFLUX_ADMIN_USERNAME=/ {sub(/^MINIFLUX_ADMIN_USERNAME=/, ""); print}' <<<"$admin_creds")
|
|
||||||
admin_pass=$(awk -F= '/^MINIFLUX_ADMIN_PASSWORD=/ {sub(/^MINIFLUX_ADMIN_PASSWORD=/, ""); print}' <<<"$admin_creds")
|
|
||||||
[ -n "$admin_user" ] && [ -n "$admin_pass" ] || { echo "FATAL: could not read miniflux admin creds" >&2; exit 1; }
|
|
||||||
|
|
||||||
# 2. Allocate next free port. Use `find` so the glob doesn't blow up
|
|
||||||
# when there are zero per-user digest-*/.env files yet.
|
|
||||||
info "scanning for used digest ports..."
|
|
||||||
used_ports=$(ssh "$HOST" 'find /opt/docker/compose -maxdepth 2 -mindepth 2 -name .env \( -path "*/news-digest/*" -o -path "*/digest-*/*" \) -exec grep -h "^NEWS_DIGEST_PORT=" {} + 2>/dev/null | cut -d= -f2 | sort -un' || true)
|
|
||||||
new_port=$PORT_BASE
|
|
||||||
while echo "$used_ports" | grep -qx "$new_port"; do
|
|
||||||
new_port=$((new_port + 1))
|
|
||||||
done
|
|
||||||
info "allocated port: $new_port (in use: ${used_ports//$'\n'/, })"
|
|
||||||
|
|
||||||
# 3. Generate password if not provided
|
|
||||||
if [ -z "$USER_PASS" ]; then
|
|
||||||
USER_PASS=$(openssl rand -base64 18 | tr -d '/+=')
|
|
||||||
fi
|
|
||||||
info "user password: $USER_PASS"
|
|
||||||
|
|
||||||
# 4. Create miniflux user via admin API (skip silently if 409)
|
|
||||||
info "creating miniflux user '$USER_ARG'..."
|
|
||||||
http_code=$(ssh "$HOST" "curl -s -o /dev/null -w '%{http_code}' \
|
|
||||||
-u '$admin_user:$admin_pass' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{\"username\":\"$USER_ARG\",\"password\":\"$USER_PASS\",\"is_admin\":false}' \
|
|
||||||
http://10.250.50.70:8080/v1/users")
|
|
||||||
case "$http_code" in
|
|
||||||
201) info " created" ;;
|
|
||||||
400|409) info " already exists (HTTP $http_code) — keeping existing user, password reset NOT performed" ;;
|
|
||||||
*) echo "FATAL: miniflux /v1/users returned HTTP $http_code" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# 5. Provision per-user dirs. Sudo on the host needs a TTY for the
|
|
||||||
# password prompt; if we're being run non-interactively (piped, in
|
|
||||||
# a script), check whether the dirs already exist and bail with a
|
|
||||||
# manual command if they don't.
|
|
||||||
info "checking host dirs..."
|
|
||||||
if ssh "$HOST" "[ -w '$HOST_COMPOSE_DIR' ] && [ -w '$HOST_DATA_DIR' ]" 2>/dev/null; then
|
|
||||||
info " exist + writable, skipping sudo step"
|
|
||||||
elif [ -t 0 ] && [ -t 1 ]; then
|
|
||||||
info " creating (sudo prompt incoming)..."
|
|
||||||
ssh -t "$HOST" "sudo mkdir -p $HOST_COMPOSE_DIR $HOST_DATA_DIR && \
|
|
||||||
sudo chown -R lkraven:lkraven $HOST_COMPOSE_DIR $HOST_DATA_DIR"
|
|
||||||
else
|
|
||||||
cat >&2 <<EOF
|
|
||||||
|
|
||||||
Run this on your terminal first (this script can't drive sudo without a TTY):
|
|
||||||
|
|
||||||
ssh -t $HOST "sudo mkdir -p $HOST_COMPOSE_DIR $HOST_DATA_DIR && sudo chown -R lkraven:lkraven $HOST_COMPOSE_DIR $HOST_DATA_DIR"
|
|
||||||
|
|
||||||
Then re-run: scripts/add-digest-user.sh $USER_ARG $USER_PASS
|
|
||||||
EOF
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 6. Sync compose + materialize .env
|
|
||||||
info "syncing compose.yaml..."
|
|
||||||
scp -q "$WORKSTATION_STACK/compose.yaml" "$HOST:$HOST_COMPOSE_DIR/compose.yaml"
|
|
||||||
# Also need the build context (Dockerfile, *.py, templates/) so the
|
|
||||||
# image can build if it's not already cached. Use the existing tree.
|
|
||||||
scp -q "$WORKSTATION_STACK/Dockerfile" "$HOST:$HOST_COMPOSE_DIR/Dockerfile"
|
|
||||||
scp -q "$WORKSTATION_STACK"/{digest.py,seed-headlines.py,web.py,entrypoint.sh,run-digest.sh} "$HOST:$HOST_COMPOSE_DIR/"
|
|
||||||
ssh "$HOST" "mkdir -p $HOST_COMPOSE_DIR/templates"
|
|
||||||
scp -q "$WORKSTATION_STACK"/templates/* "$HOST:$HOST_COMPOSE_DIR/templates/"
|
|
||||||
|
|
||||||
info "materializing .env..."
|
|
||||||
# Inherit the image tag from the canonical news-digest stack so all
|
|
||||||
# per-user instances use the same built image (otherwise we'd accidentally
|
|
||||||
# pin to whatever was current when this script was last edited).
|
|
||||||
canonical_tag=$(ssh "$HOST" 'awk -F= "/^NEWS_DIGEST_TAG=/ {print \$2}" /opt/docker/compose/news-digest/.env' || echo "v1")
|
|
||||||
info "using image tag: $canonical_tag (inherited from canonical)"
|
|
||||||
ssh "$HOST" "cat > $HOST_COMPOSE_DIR/.env" <<EOF
|
|
||||||
# Auto-generated by scripts/add-digest-user.sh on $(date -Iseconds)
|
|
||||||
# User: $USER_ARG
|
|
||||||
NEWS_DIGEST_TAG=$canonical_tag
|
|
||||||
DIGEST_PROJECT=$PROJECT
|
|
||||||
DIGEST_HOMEPAGE_NAME=$USER_ARG's Digest
|
|
||||||
DIGEST_HOMEPAGE_DESC=Personal LLM-curated brief for $USER_ARG
|
|
||||||
NEWS_DIGEST_PORT=$new_port
|
|
||||||
NEWS_DIGEST_BIND=0.0.0.0
|
|
||||||
NEWS_DIGEST_TZ=America/Los_Angeles
|
|
||||||
LLAMA_SWAP_URL=http://10.250.50.54:9292
|
|
||||||
LLAMA_SWAP_MODEL=granite-4-small
|
|
||||||
LLAMA_SWAP_TIMEOUT=180
|
|
||||||
MINIFLUX_URL=http://miniflux:8080
|
|
||||||
MINIFLUX_USER=$USER_ARG
|
|
||||||
MINIFLUX_PASSWORD=$USER_PASS
|
|
||||||
DIGEST_REDDIT_HOURS=12
|
|
||||||
DIGEST_MIN_SCORE=50
|
|
||||||
DIGEST_MIN_RATIO=0.85
|
|
||||||
DIGEST_MAX_PER_SUB=8
|
|
||||||
DIGEST_MINIFLUX_HOURS=12
|
|
||||||
DIGEST_MINIFLUX_MAX=8
|
|
||||||
DIGEST_MINIFLUX_TECH_CATEGORY=Tech aggregators
|
|
||||||
DIGEST_MINIFLUX_WORLD_CATEGORY=World
|
|
||||||
DIGEST_MINIFLUX_LOCAL_CATEGORY=Local
|
|
||||||
DIGEST_MINIFLUX_HEADLINES_HOURS=8
|
|
||||||
DIGEST_MINIFLUX_HEADLINES_MAX=15
|
|
||||||
DIGEST_CRON_AM=$CRON_AM
|
|
||||||
DIGEST_CRON_PM=$CRON_PM
|
|
||||||
NEWS_DIGEST_OUTPUT_DIR=$HOST_DATA_DIR
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 7. Bring up the per-user stack
|
|
||||||
info "bringing stack up (compose project: $PROJECT)..."
|
|
||||||
ssh "$HOST" "cd $HOST_COMPOSE_DIR && docker compose -p $PROJECT up -d 2>&1 | sed 's/^/ /'"
|
|
||||||
|
|
||||||
# 8. Wait for worker to be alive then seed feeds
|
|
||||||
info "waiting for worker to be ready..."
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if ssh "$HOST" "docker exec $PROJECT-worker test -f /app/seed-headlines.py" 2>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
info "seeding default feeds in miniflux for $USER_ARG..."
|
|
||||||
ssh "$HOST" "docker exec $PROJECT-worker python3 /app/seed-headlines.py 2>&1 | sed 's/^/ /'"
|
|
||||||
|
|
||||||
# 9. Trigger first digest run so the page isn't blank
|
|
||||||
info "triggering first digest run (this can take ~90s)..."
|
|
||||||
ssh "$HOST" "docker exec $PROJECT-worker /usr/local/bin/run-digest.sh 2>&1 | tail -3 | sed 's/^/ /'" || true
|
|
||||||
|
|
||||||
bold ""
|
|
||||||
bold "✓ provisioned digest for $USER_ARG"
|
|
||||||
echo
|
|
||||||
echo " digest URL : http://10.250.50.70:$new_port/"
|
|
||||||
echo " miniflux UI : http://10.250.50.70:8080/ (login: $USER_ARG / $USER_PASS)"
|
|
||||||
echo " schedule : AM '$CRON_AM' / PM '$CRON_PM' (TZ from \$NEWS_DIGEST_TZ)"
|
|
||||||
echo " compose dir : $HOST:$HOST_COMPOSE_DIR/"
|
|
||||||
echo " output dir : $HOST:$HOST_DATA_DIR/"
|
|
||||||
echo
|
|
||||||
echo " hand the URL + miniflux creds to the user; they can manage their"
|
|
||||||
echo " feed subscriptions via the miniflux UI."
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# news-digest stack tunables. Copy to `.env` on ana-docker before
|
|
||||||
# deploying and fill in MINIFLUX_PASSWORD.
|
|
||||||
|
|
||||||
# ── image pin ────────────────────────────────────────────────────────
|
|
||||||
# Bump when you change Dockerfile / digest.py / templates / crontab
|
|
||||||
# to force a clean image build.
|
|
||||||
NEWS_DIGEST_TAG=v1
|
|
||||||
|
|
||||||
# ── network ──────────────────────────────────────────────────────────
|
|
||||||
# Host port for the static web container (nginx). Container 80.
|
|
||||||
# Reserved on ana-docker (sample): 5001 Dockge, 7878 task-board,
|
|
||||||
# 8000 (other), 8025/8088/8090/8100/8380/8780 (various), 8080 Miniflux.
|
|
||||||
# 8181 free.
|
|
||||||
NEWS_DIGEST_PORT=8181
|
|
||||||
NEWS_DIGEST_BIND=0.0.0.0
|
|
||||||
|
|
||||||
# Timezone — busybox crond honors this for fire-times.
|
|
||||||
NEWS_DIGEST_TZ=America/Los_Angeles
|
|
||||||
|
|
||||||
# ── llama-swap (LLM summarizer) ──────────────────────────────────────
|
|
||||||
# Model picked for one-shot summarization quality + low VRAM impact.
|
|
||||||
# qwen3.5-35-a3b is loaded in the persistent group on ana-ml2.
|
|
||||||
LLAMA_SWAP_URL=http://10.250.50.54:9292
|
|
||||||
# granite-4-small — small (~4B), fast (~1s/call), no extended-thinking
|
|
||||||
# phase that eats the token budget like qwen3.x do. Plenty of capability
|
|
||||||
# for the one-sentence-tldr + one-word-tag task. To swap to a larger
|
|
||||||
# model later, ones currently working: gemma4-26b-a4b, granite-4-small.
|
|
||||||
# Avoid: qwen3.5-35-a3b (model file broken — process exits on launch),
|
|
||||||
# qwen3.6-35-a3b (defaults to thinking mode, eats budget without output).
|
|
||||||
LLAMA_SWAP_MODEL=granite-4-small
|
|
||||||
LLAMA_SWAP_TIMEOUT=180
|
|
||||||
|
|
||||||
# ── miniflux (feed source for Tech aggregators + subreddit list) ─────
|
|
||||||
# In-cluster: miniflux container is on traefik-net so we can reach it
|
|
||||||
# by container hostname. From-host fallback: http://10.250.50.70:8080
|
|
||||||
MINIFLUX_URL=http://miniflux:8080
|
|
||||||
MINIFLUX_USER=lkraven
|
|
||||||
MINIFLUX_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
# Category in Miniflux holding non-Reddit feeds (HN, Lobste.rs, etc).
|
|
||||||
DIGEST_MINIFLUX_TECH_CATEGORY=Tech aggregators
|
|
||||||
|
|
||||||
# ── filtering knobs ──────────────────────────────────────────────────
|
|
||||||
# Reddit: only consider posts created in the last N hours, with
|
|
||||||
# at least N upvotes and an upvote ratio above threshold.
|
|
||||||
DIGEST_REDDIT_HOURS=12
|
|
||||||
DIGEST_MIN_SCORE=50
|
|
||||||
DIGEST_MIN_RATIO=0.85
|
|
||||||
DIGEST_MAX_PER_SUB=8
|
|
||||||
|
|
||||||
# Miniflux Tech aggregators: same look-back window + cap per source.
|
|
||||||
DIGEST_MINIFLUX_HOURS=12
|
|
||||||
DIGEST_MINIFLUX_MAX=8
|
|
||||||
|
|
||||||
# Miniflux World/Local headlines (high-volume, dense list, no LLM
|
|
||||||
# summarization). Categories must exist in miniflux — see
|
|
||||||
# stacks/news-digest/seed-headlines.py for the one-shot setup.
|
|
||||||
DIGEST_MINIFLUX_WORLD_CATEGORY=World
|
|
||||||
DIGEST_MINIFLUX_LOCAL_CATEGORY=Local
|
|
||||||
DIGEST_MINIFLUX_HEADLINES_HOURS=8
|
|
||||||
DIGEST_MINIFLUX_HEADLINES_MAX=15
|
|
||||||
|
|
||||||
# Cron schedule (standard 5-field). busybox crond honors $TZ above.
|
|
||||||
# Two fires per day by convention (morning / evening); change times
|
|
||||||
# per-user to match working hours.
|
|
||||||
DIGEST_CRON_AM=0 8 * * *
|
|
||||||
DIGEST_CRON_PM=0 20 * * *
|
|
||||||
|
|
||||||
# ── output dir on host (bind-mounted) ────────────────────────────────
|
|
||||||
# Separate from /opt/docker/conf/<stack>/ to keep generated content
|
|
||||||
# distinct from config. Owned by container UID; writes are atomic.
|
|
||||||
NEWS_DIGEST_OUTPUT_DIR=/opt/docker/data/news-digest
|
|
||||||
|
|
||||||
# ── multi-tenant (optional) ──────────────────────────────────────────
|
|
||||||
# Per-user instances are deployed via scripts/add-digest-user.sh, which
|
|
||||||
# materializes a per-user .env and sets DIGEST_PROJECT to namespace
|
|
||||||
# container names + homepage labels. Leave unset for the singleton
|
|
||||||
# install — defaults preserve the original "news-digest" naming.
|
|
||||||
# DIGEST_PROJECT=digest-alice
|
|
||||||
# DIGEST_HOMEPAGE_NAME=Alice's Digest
|
|
||||||
# DIGEST_HOMEPAGE_DESC=Personal news brief for Alice
|
|
||||||
@@ -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"]
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
# news-digest
|
|
||||||
|
|
||||||
Twice-daily LLM-curated briefing across Reddit (via JSON API) and
|
|
||||||
Miniflux's Tech aggregators category. Output is a single static HTML
|
|
||||||
page styled in the Australis design system with editorial-serif
|
|
||||||
headlines (Fraunces) — operations-center chrome wrapping news content.
|
|
||||||
|
|
||||||
## Why this stack exists
|
|
||||||
|
|
||||||
After a few subreddits + HN + Lobste.rs, the Miniflux inbox gets
|
|
||||||
noisy. This stack:
|
|
||||||
|
|
||||||
1. Pulls **top-of-day** posts per subreddit from Reddit's public JSON
|
|
||||||
API (gives us scores + upvote ratios — RSS doesn't).
|
|
||||||
2. Filters by `score >= 50` and `upvote_ratio >= 0.85` (configurable)
|
|
||||||
to drop flame-bait and low-effort posts.
|
|
||||||
3. Pulls non-Reddit recent items from Miniflux's Tech aggregators
|
|
||||||
category (HN, Lobste.rs).
|
|
||||||
4. Sends each source through `granite-4-small` on llama-swap (one
|
|
||||||
batched call per source — efficient) for a 2-3 sentence summary +
|
|
||||||
single-word tag (news / tutorial / release / discussion / question
|
|
||||||
/ showcase / drama / meme / other). World + Local headlines also
|
|
||||||
get summarized in one batch call per category. (Original default
|
|
||||||
was `qwen3.5-35-a3b` but its model file is broken on launch.)
|
|
||||||
5. Renders an HTML page styled with Australis tokens + Fraunces
|
|
||||||
serif headlines.
|
|
||||||
6. The page is served by a tiny FastAPI app on uvicorn that also
|
|
||||||
exposes `/api/{hidden,hide,restore}` for the per-item × button
|
|
||||||
(state in `/output/hidden.json`, shared across every device the
|
|
||||||
user opens the digest from).
|
|
||||||
|
|
||||||
Two editions per day by default (0800 / 2000 local), parametrized via
|
|
||||||
`DIGEST_CRON_AM` / `DIGEST_CRON_PM` env so each per-user instance can
|
|
||||||
fire on its own schedule. Plus per-edition archives at
|
|
||||||
`/edition-YYYY-MM-DD-{am,pm}.html`.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Two containers built from the same Dockerfile, both on `traefik-net`,
|
|
||||||
sharing a bind-mounted output dir:
|
|
||||||
|
|
||||||
```
|
|
||||||
news-digest-worker (default ENTRYPOINT — busybox crond)
|
|
||||||
├── busybox crond fires at 0 8,20 * * *
|
|
||||||
├── digest.py:
|
|
||||||
│ ├── miniflux /v1/feeds → discover subreddits
|
|
||||||
│ ├── reddit JSON top/.json?t=day per sub (gentle 1.5s sleep)
|
|
||||||
│ ├── miniflux /v1/entries → tech aggregators
|
|
||||||
│ ├── llama-swap /v1/chat/completions → batched per source
|
|
||||||
│ └── jinja2 render → /output/index.html (atomic .tmp + rename)
|
|
||||||
│ → /output/edition-2026-04-26-pm.html
|
|
||||||
└── style.css / favicon.svg / app.js staged in /output at deploy
|
|
||||||
|
|
||||||
news-digest-web (entrypoint overridden → uvicorn web:app)
|
|
||||||
├── / → serve /output as static (index.html as default)
|
|
||||||
├── /api/hidden GET → JSON array of hidden item ids
|
|
||||||
├── /api/hide POST → {id} → adds id to hidden.json
|
|
||||||
├── /api/restore POST → {id} → removes id from hidden.json
|
|
||||||
├── /output/hidden.json — durable state (atomic writes + threading lock)
|
|
||||||
└── homepage card via container labels (group=News)
|
|
||||||
```
|
|
||||||
|
|
||||||
Hidden state is server-side and global per-user (single-user setup):
|
|
||||||
hide an article once and it stays hidden in any future edition that
|
|
||||||
includes the same article. The "Hidden (N)" tray at the bottom of
|
|
||||||
each page shows items hidden FROM THE CURRENT PAGE; older hidden ids
|
|
||||||
that aren't present on this page just sit silently in `hidden.json`
|
|
||||||
and continue to filter future editions.
|
|
||||||
|
|
||||||
## Deploy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/elway ana-docker --playbook playbooks/deploy-news-digest.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
After first deploy, **fill in MINIFLUX_PASSWORD on the host**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh ana-docker '
|
|
||||||
cd /opt/docker/compose/news-digest
|
|
||||||
sed -i "s|^MINIFLUX_PASSWORD=.*|MINIFLUX_PASSWORD=<your-miniflux-admin-password>|" .env
|
|
||||||
docker compose up -d
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
The container runs the first digest immediately if `/output/index.html`
|
|
||||||
doesn't exist, so the page populates within a minute or two of bringing
|
|
||||||
the stack up with real credentials.
|
|
||||||
|
|
||||||
Visit <http://10.250.50.70:8181> to read.
|
|
||||||
|
|
||||||
## Tuning the noise floor
|
|
||||||
|
|
||||||
Defaults in `.env.example`:
|
|
||||||
|
|
||||||
| Knob | Default | Effect |
|
|
||||||
|---|---|---|
|
|
||||||
| `DIGEST_REDDIT_HOURS` | 12 | Look-back window (matches twice-daily cadence) |
|
|
||||||
| `DIGEST_MIN_SCORE` | 50 | Reddit minimum upvotes to consider |
|
|
||||||
| `DIGEST_MIN_RATIO` | 0.85 | Reddit minimum upvote ratio (skips flamebait) |
|
|
||||||
| `DIGEST_MAX_PER_SUB` | 8 | Cap per subreddit, post-filter |
|
|
||||||
| `DIGEST_MINIFLUX_HOURS` | 12 | Look-back window for HN/Lobste.rs |
|
|
||||||
| `DIGEST_MINIFLUX_MAX` | 8 | Cap per non-Reddit feed |
|
|
||||||
|
|
||||||
For a busier day, lower `DIGEST_MIN_SCORE`. For a quieter morning
|
|
||||||
edition, raise it. Edit `.env`, no rebuild needed — the worker reads
|
|
||||||
env on each cron fire.
|
|
||||||
|
|
||||||
## Adding more subreddits
|
|
||||||
|
|
||||||
The digest picks up subreddit feeds from Miniflux automatically — any
|
|
||||||
feed whose URL starts with `https://www.reddit.com/r/<sub>/` gets
|
|
||||||
queried. To add a sub, just subscribe in Miniflux (UI or API). The
|
|
||||||
next digest run includes it.
|
|
||||||
|
|
||||||
## Updating the LLM model
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh ana-docker '
|
|
||||||
cd /opt/docker/compose/news-digest
|
|
||||||
sed -i "s|^LLAMA_SWAP_MODEL=.*|LLAMA_SWAP_MODEL=<new-model>|" .env
|
|
||||||
docker compose up -d
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
The model must be loaded in llama-swap's `config.yaml`. Check
|
|
||||||
`http://10.250.50.54:9292/v1/models` for what's available. Models
|
|
||||||
with tool/JSON-mode support give better summarization quality;
|
|
||||||
`granite-4-small` is the current default (small ~4B, fast ~1s/call,
|
|
||||||
no extended-thinking phase eating the token budget). Avoid
|
|
||||||
`qwen3.5-35-a3b` (model file broken — process exits on launch) and
|
|
||||||
`qwen3.6-35-a3b` (defaults to thinking mode, eats budget without output).
|
|
||||||
|
|
||||||
## Forcing a fresh digest now
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh ana-docker 'docker exec news-digest-worker /usr/local/bin/run-digest.sh'
|
|
||||||
```
|
|
||||||
|
|
||||||
Runs the full pipeline once, ignoring cron. Useful after changing
|
|
||||||
filtering knobs or adding feeds.
|
|
||||||
|
|
||||||
## Customizing the run schedule
|
|
||||||
|
|
||||||
Times come from two env vars on the worker, written into the busybox
|
|
||||||
crontab at container start. Standard 5-field cron syntax.
|
|
||||||
|
|
||||||
| Var | Default | Effect |
|
|
||||||
|---|---|---|
|
|
||||||
| `DIGEST_CRON_AM` | `0 8 * * *` | morning fire |
|
|
||||||
| `DIGEST_CRON_PM` | `0 20 * * *` | evening fire |
|
|
||||||
|
|
||||||
`busybox crond` honors `$NEWS_DIGEST_TZ` (defaults to
|
|
||||||
`America/Los_Angeles`), so values are interpreted in the configured TZ.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Shift the canonical instance to 7am / 6pm
|
|
||||||
ssh ana-docker '
|
|
||||||
cd /opt/docker/compose/news-digest
|
|
||||||
sed -i "s|^DIGEST_CRON_AM=.*|DIGEST_CRON_AM=0 7 * * *|" .env
|
|
||||||
sed -i "s|^DIGEST_CRON_PM=.*|DIGEST_CRON_PM=0 18 * * *|" .env
|
|
||||||
docker compose up -d --force-recreate news-digest-worker
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify the rendered crontab:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh ana-docker 'docker exec news-digest-worker cat /etc/crontabs/root'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Multi-tenant: one instance per teammate
|
|
||||||
|
|
||||||
Architecture: **shared miniflux + per-user digest stack**. Miniflux
|
|
||||||
already supports multi-user natively (each user has their own feeds,
|
|
||||||
categories, hide-state); we layer a separate news-digest stack per
|
|
||||||
user on its own port + output dir, scoped to that miniflux user's
|
|
||||||
credentials.
|
|
||||||
|
|
||||||
### Onboarding a new user
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Defaults — 8am / 8pm local, random password
|
|
||||||
scripts/add-digest-user.sh alice
|
|
||||||
|
|
||||||
# Custom hours
|
|
||||||
scripts/add-digest-user.sh bob --am "0 6 * * *" --pm "0 17 * * *"
|
|
||||||
|
|
||||||
# Weekday-only PM run
|
|
||||||
scripts/add-digest-user.sh carol --pm "30 18 * * 1-5"
|
|
||||||
|
|
||||||
# Pin a known password (still creates the miniflux user if missing)
|
|
||||||
scripts/add-digest-user.sh dan 'pickyourpassword' --am "0 9 * * *"
|
|
||||||
```
|
|
||||||
|
|
||||||
What the script does:
|
|
||||||
|
|
||||||
1. Reads miniflux admin creds from
|
|
||||||
`ana-docker:/opt/docker/compose/miniflux/.env`.
|
|
||||||
2. Allocates the next free `NEWS_DIGEST_PORT` (scans existing
|
|
||||||
`news-digest` + `digest-*` `.env` files).
|
|
||||||
3. Creates the miniflux user via the admin API. Already-exists is
|
|
||||||
non-fatal (kept; password not reset).
|
|
||||||
4. Provisions per-user dirs at
|
|
||||||
`/opt/docker/compose/digest-<user>/` and
|
|
||||||
`/opt/docker/data/digest-<user>/` (one-time sudo prompt — the
|
|
||||||
script falls back to printing the manual command if there's no TTY).
|
|
||||||
5. Materializes a per-user `.env` (inherits `NEWS_DIGEST_TAG` from the
|
|
||||||
canonical stack so all tenants run the same image).
|
|
||||||
6. Brings the stack up via `docker compose -p digest-<user> up -d`.
|
|
||||||
7. Runs `seed-headlines.py` against miniflux as the new user (creates
|
|
||||||
the World + Local categories with default feeds).
|
|
||||||
8. Triggers a first digest run so the page isn't blank.
|
|
||||||
|
|
||||||
Outputs the digest URL, miniflux login, and rendered cron schedule.
|
|
||||||
|
|
||||||
### Per-user file layout
|
|
||||||
|
|
||||||
```
|
|
||||||
ana-docker:
|
|
||||||
/opt/docker/compose/digest-<user>/ # compose + .env + build context
|
|
||||||
.env # auto-generated, contains MINIFLUX_PASSWORD
|
|
||||||
compose.yaml
|
|
||||||
Dockerfile + digest.py + ... # build context (image is shared/cached)
|
|
||||||
/opt/docker/data/digest-<user>/ # rendered HTML + per-user hidden.json
|
|
||||||
index.html
|
|
||||||
edition-YYYY-MM-DD-{am,pm}.html
|
|
||||||
hidden.json
|
|
||||||
.article-cache.json # extracted article text, 7-day TTL
|
|
||||||
```
|
|
||||||
|
|
||||||
Container names: `digest-<user>-worker` and `digest-<user>-web`.
|
|
||||||
|
|
||||||
### Updating an existing user's schedule
|
|
||||||
|
|
||||||
Re-running the script with the same username is idempotent:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Change alice's evening run to 5:30pm
|
|
||||||
scripts/add-digest-user.sh alice --pm "30 17 * * *"
|
|
||||||
```
|
|
||||||
|
|
||||||
The miniflux user is kept (password unchanged), the .env is
|
|
||||||
re-materialized with the new schedule, and the worker container is
|
|
||||||
recreated. Use the same flow to bump cron times, rotate passwords
|
|
||||||
(by passing a new one explicitly), or rerun feed seeding.
|
|
||||||
|
|
||||||
### Removing a user
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh ana-docker '
|
|
||||||
cd /opt/docker/compose/digest-alice
|
|
||||||
docker compose -p digest-alice down -v
|
|
||||||
'
|
|
||||||
# Optional: nuke compose dir + rendered output
|
|
||||||
ssh -t ana-docker 'sudo rm -rf /opt/docker/compose/digest-alice /opt/docker/data/digest-alice'
|
|
||||||
# Optional: delete the miniflux user via the admin UI at http://10.250.50.70:8080/
|
|
||||||
```
|
|
||||||
|
|
||||||
The digest is gone immediately; the miniflux account stays around
|
|
||||||
unless you delete it explicitly (cheap to leave; ~zero resource cost
|
|
||||||
when no stack is querying its feeds).
|
|
||||||
|
|
||||||
## Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh ana-docker 'docker logs --tail 100 news-digest-worker'
|
|
||||||
```
|
|
||||||
|
|
||||||
Worker logs each phase (subreddit discovery / fetching / summarizing /
|
|
||||||
rendering) with timestamps. Per-source LLM filter results show how
|
|
||||||
many items were kept vs skipped.
|
|
||||||
|
|
||||||
## License + attribution
|
|
||||||
|
|
||||||
Reddit content surfaced here is owned by its authors and Reddit. The
|
|
||||||
digest is a derived index pointing at original sources — every item
|
|
||||||
links back to the Reddit thread (and to the external link if the
|
|
||||||
post linked out). Same for HN / Lobste.rs.
|
|
||||||
@@ -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
|
|
||||||
@@ -1,710 +0,0 @@
|
|||||||
"""news-digest — twice-daily LLM-curated briefing across subreddits + Miniflux.
|
|
||||||
|
|
||||||
Runs from cron at 0800 / 2000 local. Each invocation:
|
|
||||||
|
|
||||||
1. Pulls the subreddit list from Miniflux (any feed whose URL starts
|
|
||||||
with https://www.reddit.com/r/) — single source of truth, no
|
|
||||||
duplicated config.
|
|
||||||
2. Hits Reddit's public JSON API per subreddit for top-of-day,
|
|
||||||
filters by score + upvote ratio.
|
|
||||||
3. Pulls non-Reddit recent items from Miniflux (Tech aggregators
|
|
||||||
category — HN, Lobste.rs).
|
|
||||||
4. Batches each source through llama-swap on ana-ml2 with a
|
|
||||||
terse summarization prompt (one call per source).
|
|
||||||
5. Renders the Jinja2 template + CSS to /output/index.html
|
|
||||||
(atomic write via .tmp + rename).
|
|
||||||
6. Also writes /output/edition-YYYY-MM-DD-<am|pm>.html as an archive.
|
|
||||||
|
|
||||||
All tunables are environment-driven; see .env.example for the full
|
|
||||||
list. Designed to be a one-shot invocation — it does not loop or daemon.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Iterable, Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
||||||
|
|
||||||
|
|
||||||
def _stable_id(*parts: str) -> str:
|
|
||||||
"""12-char sha1 prefix used as the per-item id for the X-button-to-hide
|
|
||||||
feature. Stable across editions (built from the source's native id),
|
|
||||||
cross-source-unique (prefixed with the source kind), and short enough
|
|
||||||
to live in JSON without bloat."""
|
|
||||||
h = hashlib.sha1()
|
|
||||||
for p in parts:
|
|
||||||
h.update(p.encode("utf-8", errors="replace"))
|
|
||||||
h.update(b"\x00")
|
|
||||||
return h.hexdigest()[:12]
|
|
||||||
|
|
||||||
# ── env config ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
LLAMA_SWAP_URL = os.environ.get("LLAMA_SWAP_URL", "http://10.250.50.54:9292")
|
|
||||||
LLAMA_SWAP_MODEL = os.environ.get("LLAMA_SWAP_MODEL", "qwen3.5-35-a3b")
|
|
||||||
LLAMA_SWAP_TIMEOUT = int(os.environ.get("LLAMA_SWAP_TIMEOUT", "180"))
|
|
||||||
|
|
||||||
MINIFLUX_URL = os.environ.get("MINIFLUX_URL", "http://miniflux:8080")
|
|
||||||
MINIFLUX_USER = os.environ.get("MINIFLUX_USER", "lkraven")
|
|
||||||
MINIFLUX_PASS = os.environ.get("MINIFLUX_PASSWORD", "")
|
|
||||||
|
|
||||||
OUTPUT_DIR = Path(os.environ.get("DIGEST_OUTPUT_DIR", "/output"))
|
|
||||||
TEMPLATE_DIR = Path(os.environ.get("DIGEST_TEMPLATE_DIR", "/app/templates"))
|
|
||||||
|
|
||||||
REDDIT_HOURS = int(os.environ.get("DIGEST_REDDIT_HOURS", "12"))
|
|
||||||
REDDIT_MIN_SCORE = int(os.environ.get("DIGEST_MIN_SCORE", "50"))
|
|
||||||
REDDIT_MIN_RATIO = float(os.environ.get("DIGEST_MIN_RATIO", "0.85"))
|
|
||||||
REDDIT_MAX_PER_SUB = int(os.environ.get("DIGEST_MAX_PER_SUB", "8"))
|
|
||||||
REDDIT_USER_AGENT = os.environ.get(
|
|
||||||
"DIGEST_REDDIT_USER_AGENT",
|
|
||||||
"news-digest:phasefinal:0.1.0 (anonymous)",
|
|
||||||
)
|
|
||||||
|
|
||||||
MINIFLUX_TECH_CATEGORY = os.environ.get(
|
|
||||||
"DIGEST_MINIFLUX_TECH_CATEGORY", "Tech aggregators"
|
|
||||||
)
|
|
||||||
MINIFLUX_HOURS = int(os.environ.get("DIGEST_MINIFLUX_HOURS", "12"))
|
|
||||||
MINIFLUX_MAX_PER_SOURCE = int(os.environ.get("DIGEST_MINIFLUX_MAX", "8"))
|
|
||||||
|
|
||||||
# Headlines (world + local) — high-volume sections, no LLM summarization.
|
|
||||||
MINIFLUX_WORLD_CATEGORY = os.environ.get(
|
|
||||||
"DIGEST_MINIFLUX_WORLD_CATEGORY", "World"
|
|
||||||
)
|
|
||||||
MINIFLUX_LOCAL_CATEGORY = os.environ.get(
|
|
||||||
"DIGEST_MINIFLUX_LOCAL_CATEGORY", "Local"
|
|
||||||
)
|
|
||||||
MINIFLUX_HEADLINES_HOURS = int(os.environ.get("DIGEST_MINIFLUX_HEADLINES_HOURS", "8"))
|
|
||||||
MINIFLUX_HEADLINES_MAX = int(os.environ.get("DIGEST_MINIFLUX_HEADLINES_MAX", "15"))
|
|
||||||
|
|
||||||
TZ_NAME = os.environ.get("TZ", "America/Los_Angeles")
|
|
||||||
|
|
||||||
# ── data shapes ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Item:
|
|
||||||
"""A single curated post — Reddit or Miniflux origin."""
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
url: str # external link or HTML permalink
|
|
||||||
permalink: str # discussion / source URL (Reddit thread, HN comments)
|
|
||||||
body: str # selftext / description (may be empty)
|
|
||||||
author: str
|
|
||||||
score: Optional[int] # Reddit upvotes if known
|
|
||||||
comments: Optional[int]
|
|
||||||
upvote_ratio: Optional[float]
|
|
||||||
posted_at: datetime
|
|
||||||
# Filled by summarize step:
|
|
||||||
tldr: str = ""
|
|
||||||
tag: str = ""
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Source:
|
|
||||||
"""A logical grouping of items shown as one section in the digest."""
|
|
||||||
name: str # display name ("r/selfhosted", "Hacker News")
|
|
||||||
kind: str # "reddit" | "miniflux"
|
|
||||||
href: str # link to the source's homepage / sub
|
|
||||||
items: list[Item] = field(default_factory=list)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Headline:
|
|
||||||
"""One row in the dense world/local headlines list."""
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
url: str
|
|
||||||
source: str # display name of the originating feed
|
|
||||||
posted_at: datetime
|
|
||||||
tldr: str = "" # 2-3 sentence LLM summary of the linked article
|
|
||||||
|
|
||||||
# ── http session shared across calls ─────────────────────────────────
|
|
||||||
|
|
||||||
S = requests.Session()
|
|
||||||
S.headers["User-Agent"] = REDDIT_USER_AGENT
|
|
||||||
|
|
||||||
def log(msg: str) -> None:
|
|
||||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
||||||
|
|
||||||
# ── article-text cache ───────────────────────────────────────────────
|
|
||||||
# Most feeds ship just titles + thin excerpts. Real summaries need the
|
|
||||||
# article body, so we fetch + extract with trafilatura. Cache to disk
|
|
||||||
# so re-runs on the same window don't re-pull.
|
|
||||||
ARTICLE_CACHE_PATH = OUTPUT_DIR / ".article-cache.json"
|
|
||||||
ARTICLE_CACHE_TTL_HOURS = 7 * 24 # keep extracted text ~1 week
|
|
||||||
ARTICLE_FETCH_TIMEOUT = 12 # seconds per URL
|
|
||||||
ARTICLE_TEXT_CAP = 4000 # chars; LLM doesn't need more
|
|
||||||
ARTICLE_FETCH_WORKERS = 10 # parallel fetches per warm pass
|
|
||||||
REDDIT_DOMAIN_RE = re.compile(r"^https?://(?:[^/]*\.)?reddit\.com/", re.I)
|
|
||||||
|
|
||||||
|
|
||||||
def article_cache_load() -> dict:
|
|
||||||
if not ARTICLE_CACHE_PATH.exists():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
return json.loads(ARTICLE_CACHE_PATH.read_text())
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def article_cache_save(cache: dict) -> None:
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = ARTICLE_CACHE_PATH.with_suffix(".json.tmp")
|
|
||||||
tmp.write_text(json.dumps(cache))
|
|
||||||
tmp.rename(ARTICLE_CACHE_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_article_text(url: str, cache: dict) -> str:
|
|
||||||
"""Return main-content text for `url`, cached. Empty string on any
|
|
||||||
failure — caller is expected to fall back to the feed body / title.
|
|
||||||
|
|
||||||
Skips reddit.com URLs (callers already have selftext as `body`)
|
|
||||||
and anything that 404s, paywalls, or extracts to less than a
|
|
||||||
paragraph."""
|
|
||||||
if not url or REDDIT_DOMAIN_RE.match(url):
|
|
||||||
return ""
|
|
||||||
key = hashlib.sha1(url.encode("utf-8")).hexdigest()
|
|
||||||
now = int(time.time())
|
|
||||||
cached = cache.get(key)
|
|
||||||
if cached and (now - int(cached.get("ts", 0))) < ARTICLE_CACHE_TTL_HOURS * 3600:
|
|
||||||
return cached.get("text", "")
|
|
||||||
try:
|
|
||||||
import trafilatura
|
|
||||||
downloaded = trafilatura.fetch_url(url)
|
|
||||||
if not downloaded:
|
|
||||||
cache[key] = {"ts": now, "text": ""}
|
|
||||||
return ""
|
|
||||||
text = trafilatura.extract(
|
|
||||||
downloaded,
|
|
||||||
include_comments=False,
|
|
||||||
include_tables=False,
|
|
||||||
no_fallback=False,
|
|
||||||
) or ""
|
|
||||||
text = text.strip()[:ARTICLE_TEXT_CAP]
|
|
||||||
cache[key] = {"ts": now, "text": text}
|
|
||||||
return text
|
|
||||||
except Exception as e:
|
|
||||||
log(f" ! article fetch failed for {url[:80]}: {e!r}")
|
|
||||||
cache[key] = {"ts": now, "text": ""}
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def warm_article_cache(urls: Iterable[str], cache: dict) -> None:
|
|
||||||
"""Parallel-prefetch article text for `urls` into `cache`."""
|
|
||||||
pending = []
|
|
||||||
seen_urls: set[str] = set()
|
|
||||||
cutoff = int(time.time()) - ARTICLE_CACHE_TTL_HOURS * 3600
|
|
||||||
for url in urls:
|
|
||||||
if not url or url in seen_urls or REDDIT_DOMAIN_RE.match(url):
|
|
||||||
continue
|
|
||||||
seen_urls.add(url)
|
|
||||||
key = hashlib.sha1(url.encode("utf-8")).hexdigest()
|
|
||||||
cached = cache.get(key)
|
|
||||||
if cached and int(cached.get("ts", 0)) > cutoff:
|
|
||||||
continue
|
|
||||||
pending.append(url)
|
|
||||||
if not pending:
|
|
||||||
return
|
|
||||||
log(f" warming article cache: {len(pending)} URLs ({ARTICLE_FETCH_WORKERS} parallel)")
|
|
||||||
t0 = time.time()
|
|
||||||
with ThreadPoolExecutor(max_workers=ARTICLE_FETCH_WORKERS) as ex:
|
|
||||||
list(ex.map(lambda u: fetch_article_text(u, cache), pending))
|
|
||||||
log(f" done in {time.time() - t0:.1f}s")
|
|
||||||
|
|
||||||
# ── miniflux: discover subreddits + pull tech-aggregator items ───────
|
|
||||||
|
|
||||||
def miniflux_get(path: str, **params) -> Any:
|
|
||||||
url = f"{MINIFLUX_URL.rstrip('/')}{path}"
|
|
||||||
r = S.get(url, params=params, auth=(MINIFLUX_USER, MINIFLUX_PASS), timeout=20)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
REDDIT_FEED_RE = re.compile(r"^https?://(?:www\.)?reddit\.com/r/([^/]+)/", re.I)
|
|
||||||
|
|
||||||
def discover_subreddits_from_miniflux() -> list[str]:
|
|
||||||
"""Return list of subreddit names extracted from Miniflux's feed URLs."""
|
|
||||||
feeds = miniflux_get("/v1/feeds")
|
|
||||||
subs: list[str] = []
|
|
||||||
for f in feeds:
|
|
||||||
m = REDDIT_FEED_RE.match(f.get("feed_url", ""))
|
|
||||||
if m:
|
|
||||||
subs.append(m.group(1))
|
|
||||||
seen, deduped = set(), []
|
|
||||||
for s in subs:
|
|
||||||
k = s.lower()
|
|
||||||
if k not in seen:
|
|
||||||
deduped.append(s)
|
|
||||||
seen.add(k)
|
|
||||||
return deduped
|
|
||||||
|
|
||||||
def fetch_miniflux_tech_items() -> list[Source]:
|
|
||||||
"""Return one Source per non-Reddit feed in the configured category."""
|
|
||||||
cats = miniflux_get("/v1/categories")
|
|
||||||
tech_cat = next(
|
|
||||||
(c for c in cats if c["title"].lower() == MINIFLUX_TECH_CATEGORY.lower()),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not tech_cat:
|
|
||||||
log(f"miniflux: category {MINIFLUX_TECH_CATEGORY!r} not found, skipping")
|
|
||||||
return []
|
|
||||||
|
|
||||||
cutoff = int((datetime.now(timezone.utc) - timedelta(hours=MINIFLUX_HOURS)).timestamp())
|
|
||||||
entries = miniflux_get(
|
|
||||||
"/v1/entries",
|
|
||||||
category_id=tech_cat["id"],
|
|
||||||
published_after=cutoff,
|
|
||||||
order="published_at",
|
|
||||||
direction="desc",
|
|
||||||
limit=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
by_feed: dict[int, Source] = {}
|
|
||||||
for e in entries.get("entries", []):
|
|
||||||
feed = e.get("feed") or {}
|
|
||||||
if REDDIT_FEED_RE.match(feed.get("feed_url", "")):
|
|
||||||
continue # handled in Reddit pass
|
|
||||||
fid = feed.get("id")
|
|
||||||
if fid is None:
|
|
||||||
continue
|
|
||||||
src = by_feed.get(fid)
|
|
||||||
if src is None:
|
|
||||||
src = Source(
|
|
||||||
name=feed.get("title", "?"),
|
|
||||||
kind="miniflux",
|
|
||||||
href=feed.get("site_url") or feed.get("feed_url") or "",
|
|
||||||
)
|
|
||||||
by_feed[fid] = src
|
|
||||||
if len(src.items) >= MINIFLUX_MAX_PER_SOURCE:
|
|
||||||
continue
|
|
||||||
src.items.append(Item(
|
|
||||||
id=_stable_id("miniflux", str(e["id"])),
|
|
||||||
title=e.get("title", "(untitled)"),
|
|
||||||
url=e.get("url", ""),
|
|
||||||
permalink=e.get("url", ""),
|
|
||||||
body=(e.get("content") or "")[:1500],
|
|
||||||
author=e.get("author", ""),
|
|
||||||
score=None,
|
|
||||||
comments=None,
|
|
||||||
upvote_ratio=None,
|
|
||||||
posted_at=_parse_dt(e.get("published_at")),
|
|
||||||
))
|
|
||||||
return [s for s in by_feed.values() if s.items]
|
|
||||||
|
|
||||||
def fetch_miniflux_headlines(category_name: str) -> list[Headline]:
|
|
||||||
"""Pull recent items from a miniflux category as flat headlines.
|
|
||||||
|
|
||||||
Used for high-volume sections (world / local) where headlines move
|
|
||||||
fast and the volume justifies a dense list rather than the per-source
|
|
||||||
cards used for tech / reddit. No LLM summarization — the title is
|
|
||||||
the deliverable. Cross-feed dedup by lowercased title (different
|
|
||||||
feeds syndicate the same wire stories)."""
|
|
||||||
cats = miniflux_get("/v1/categories")
|
|
||||||
cat = next(
|
|
||||||
(c for c in cats if c["title"].lower() == category_name.lower()),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not cat:
|
|
||||||
log(f"miniflux: category {category_name!r} not found, skipping")
|
|
||||||
return []
|
|
||||||
|
|
||||||
cutoff = int(
|
|
||||||
(datetime.now(timezone.utc) - timedelta(hours=MINIFLUX_HEADLINES_HOURS)).timestamp()
|
|
||||||
)
|
|
||||||
entries = miniflux_get(
|
|
||||||
"/v1/entries",
|
|
||||||
category_id=cat["id"],
|
|
||||||
published_after=cutoff,
|
|
||||||
order="published_at",
|
|
||||||
direction="desc",
|
|
||||||
limit=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
headlines: list[Headline] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for e in entries.get("entries", []):
|
|
||||||
title = (e.get("title") or "(untitled)").strip()
|
|
||||||
key = title.lower()
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
feed = e.get("feed") or {}
|
|
||||||
headlines.append(Headline(
|
|
||||||
id=_stable_id("headline", str(e["id"])),
|
|
||||||
title=title,
|
|
||||||
url=e.get("url", ""),
|
|
||||||
source=feed.get("title", "?"),
|
|
||||||
posted_at=_parse_dt(e.get("published_at")),
|
|
||||||
))
|
|
||||||
if len(headlines) >= MINIFLUX_HEADLINES_MAX:
|
|
||||||
break
|
|
||||||
return headlines
|
|
||||||
|
|
||||||
def _parse_dt(s: Optional[str]) -> datetime:
|
|
||||||
if not s:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
||||||
except Exception:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# ── reddit JSON: top-of-day per subreddit ────────────────────────────
|
|
||||||
|
|
||||||
def fetch_reddit_top(sub: str) -> Source:
|
|
||||||
log(f"reddit: r/{sub}")
|
|
||||||
url = f"https://www.reddit.com/r/{sub}/top/.json"
|
|
||||||
params = {"t": "day", "limit": 25}
|
|
||||||
r = S.get(url, params=params, timeout=20)
|
|
||||||
if not r.ok:
|
|
||||||
log(f" HTTP {r.status_code}: skipping")
|
|
||||||
return Source(name=f"r/{sub}", kind="reddit", href=f"https://reddit.com/r/{sub}")
|
|
||||||
posts = r.json().get("data", {}).get("children", [])
|
|
||||||
cutoff_ts = (datetime.now(timezone.utc) - timedelta(hours=REDDIT_HOURS)).timestamp()
|
|
||||||
|
|
||||||
items: list[Item] = []
|
|
||||||
for p in posts:
|
|
||||||
d = p.get("data", {})
|
|
||||||
score = d.get("score", 0)
|
|
||||||
ratio = d.get("upvote_ratio", 0.0)
|
|
||||||
created = d.get("created_utc", 0)
|
|
||||||
if score < REDDIT_MIN_SCORE: continue
|
|
||||||
if ratio < REDDIT_MIN_RATIO: continue
|
|
||||||
if created < cutoff_ts: continue
|
|
||||||
items.append(Item(
|
|
||||||
id=_stable_id("reddit", d.get("id", "")),
|
|
||||||
title=d.get("title", "(untitled)"),
|
|
||||||
url=d.get("url", ""),
|
|
||||||
permalink=f"https://reddit.com{d.get('permalink', '')}",
|
|
||||||
body=(d.get("selftext") or "")[:1500],
|
|
||||||
author=d.get("author", "[deleted]"),
|
|
||||||
score=score,
|
|
||||||
comments=d.get("num_comments"),
|
|
||||||
upvote_ratio=ratio,
|
|
||||||
posted_at=datetime.fromtimestamp(created, tz=timezone.utc),
|
|
||||||
))
|
|
||||||
items.sort(key=lambda x: (x.score or 0), reverse=True)
|
|
||||||
items = items[:REDDIT_MAX_PER_SUB]
|
|
||||||
log(f" kept {len(items)} (score>={REDDIT_MIN_SCORE}, ratio>={REDDIT_MIN_RATIO})")
|
|
||||||
return Source(name=f"r/{sub}", kind="reddit",
|
|
||||||
href=f"https://reddit.com/r/{sub}", items=items)
|
|
||||||
|
|
||||||
# ── llama-swap: batched summarization per source ─────────────────────
|
|
||||||
|
|
||||||
SUMMARIZE_SYSTEM = (
|
|
||||||
"You are a curator producing a tight intelligence briefing for an "
|
|
||||||
"engineer who reads many feeds. You are concise, neutral, and never "
|
|
||||||
"editorialize. You write summaries grounded in the article body — "
|
|
||||||
"never paraphrase the title back at the reader. You skip pure "
|
|
||||||
"shitposts and screenshots-without-context."
|
|
||||||
)
|
|
||||||
|
|
||||||
SUMMARIZE_USER_TEMPLATE = """Given the {n} posts from {source} below, return a JSON ARRAY where each element has:
|
|
||||||
|
|
||||||
- "id": the post id from the input
|
|
||||||
- "tldr": 2-3 sentences (40-80 words) summarizing the SUBSTANCE — what happened, what was announced, what conclusion the author drew. Pull facts, names, numbers from the body. Do NOT restate the title; the reader already sees it. Do NOT begin with "this post" / "the article" / "a user". If the body is too thin to add anything beyond the title, return tldr="".
|
|
||||||
- "tag": ONE word from {{news, tutorial, release, discussion, question, showcase, drama, meme, other}}
|
|
||||||
|
|
||||||
If a post is a pure shitpost / screenshot-without-context / duplicate of another item in this batch, set "tldr" to "" and "tag" to "skip".
|
|
||||||
|
|
||||||
Output ONLY the JSON array. No prose, no markdown fence.
|
|
||||||
|
|
||||||
POSTS:
|
|
||||||
{posts_json}
|
|
||||||
"""
|
|
||||||
|
|
||||||
HEADLINE_SUMMARIZE_USER_TEMPLATE = """Given the {n} {label} headlines below, return a JSON ARRAY where each element has:
|
|
||||||
|
|
||||||
- "id": the headline id from the input
|
|
||||||
- "tldr": 2-3 sentences (40-80 words) summarizing the article body — who, what, when, where, why. Pull names, numbers, places from the body. Do NOT restate the headline; the reader already sees it. Do NOT editorialize. If the body is too thin (e.g. just the headline rehashed), return tldr="".
|
|
||||||
|
|
||||||
Output ONLY the JSON array. No prose, no markdown fence.
|
|
||||||
|
|
||||||
HEADLINES:
|
|
||||||
{posts_json}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _llm_chat(messages: list[dict], label: str) -> dict[str, dict]:
|
|
||||||
"""Send a chat request and parse the JSON-array reply into a
|
|
||||||
{id: row} map. Returns {} on any failure (caller falls back to
|
|
||||||
raw titles)."""
|
|
||||||
try:
|
|
||||||
r = S.post(
|
|
||||||
f"{LLAMA_SWAP_URL.rstrip('/')}/v1/chat/completions",
|
|
||||||
json={
|
|
||||||
"model": LLAMA_SWAP_MODEL,
|
|
||||||
"messages": messages,
|
|
||||||
"temperature": 0.2,
|
|
||||||
"max_tokens": 4000,
|
|
||||||
},
|
|
||||||
timeout=LLAMA_SWAP_TIMEOUT,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
msg = r.json()["choices"][0]["message"]
|
|
||||||
# Extended-thinking models (Qwen3.x) put output in
|
|
||||||
# reasoning_content while content is still streaming. Fall back
|
|
||||||
# so we get something to parse.
|
|
||||||
content = (msg.get("content") or msg.get("reasoning_content") or "").strip()
|
|
||||||
# Some models wrap JSON in ```...``` even when told not to.
|
|
||||||
content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.M).strip()
|
|
||||||
return {x.get("id"): x for x in json.loads(content)}
|
|
||||||
except Exception as e:
|
|
||||||
log(f" ! llm failed for {label}: {e!r}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_source(src: Source, cache: dict) -> None:
|
|
||||||
if not src.items:
|
|
||||||
return
|
|
||||||
posts_json = json.dumps([
|
|
||||||
{
|
|
||||||
"id": it.id,
|
|
||||||
"title": it.title,
|
|
||||||
# Real article text (cached) wins over feed-shipped excerpt.
|
|
||||||
# Falls back to feed body for self-posts (Reddit selftext)
|
|
||||||
# and any URL where extraction failed.
|
|
||||||
"body": (fetch_article_text(it.url, cache) or it.body or "")[:2500],
|
|
||||||
"url": it.url,
|
|
||||||
}
|
|
||||||
for it in src.items
|
|
||||||
], ensure_ascii=False)
|
|
||||||
user = SUMMARIZE_USER_TEMPLATE.format(
|
|
||||||
n=len(src.items),
|
|
||||||
source=src.name,
|
|
||||||
posts_json=posts_json,
|
|
||||||
)
|
|
||||||
log(f" llm: summarizing {len(src.items)} items from {src.name}")
|
|
||||||
mapped = _llm_chat(
|
|
||||||
[
|
|
||||||
{"role": "system", "content": SUMMARIZE_SYSTEM},
|
|
||||||
{"role": "user", "content": user},
|
|
||||||
],
|
|
||||||
src.name,
|
|
||||||
)
|
|
||||||
if not mapped:
|
|
||||||
return
|
|
||||||
|
|
||||||
for it in src.items:
|
|
||||||
m = mapped.get(it.id, {})
|
|
||||||
it.tldr = (m.get("tldr") or "").strip()
|
|
||||||
it.tag = (m.get("tag") or "").strip().lower()
|
|
||||||
|
|
||||||
# Drop skipped entries from the source.
|
|
||||||
src.items = [it for it in src.items if it.tag != "skip" and (it.tldr or it.score is None)]
|
|
||||||
log(f" -> {len(src.items)} kept after llm filter")
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_headlines(headlines: list[Headline], label: str, cache: dict) -> None:
|
|
||||||
"""Batch-summarize a headline list in-place. One LLM call for the
|
|
||||||
whole batch. Quietly leaves tldr empty on failure so the dense
|
|
||||||
list still renders (just without summaries)."""
|
|
||||||
if not headlines:
|
|
||||||
return
|
|
||||||
posts_json = json.dumps([
|
|
||||||
{
|
|
||||||
"id": h.id,
|
|
||||||
"title": h.title,
|
|
||||||
"source": h.source,
|
|
||||||
"body": fetch_article_text(h.url, cache)[:2000],
|
|
||||||
}
|
|
||||||
for h in headlines
|
|
||||||
], ensure_ascii=False)
|
|
||||||
user = HEADLINE_SUMMARIZE_USER_TEMPLATE.format(
|
|
||||||
n=len(headlines),
|
|
||||||
label=label,
|
|
||||||
posts_json=posts_json,
|
|
||||||
)
|
|
||||||
log(f" llm: summarizing {len(headlines)} {label} headlines")
|
|
||||||
mapped = _llm_chat(
|
|
||||||
[
|
|
||||||
{"role": "system", "content": SUMMARIZE_SYSTEM},
|
|
||||||
{"role": "user", "content": user},
|
|
||||||
],
|
|
||||||
f"{label} headlines",
|
|
||||||
)
|
|
||||||
if not mapped:
|
|
||||||
return
|
|
||||||
for h in headlines:
|
|
||||||
m = mapped.get(h.id, {})
|
|
||||||
h.tldr = (m.get("tldr") or "").strip()
|
|
||||||
|
|
||||||
# ── render ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def render(reddit_sources: list[Source], tech_sources: list[Source],
|
|
||||||
world_headlines: list[Headline], local_headlines: list[Headline],
|
|
||||||
generated_at: datetime) -> str:
|
|
||||||
env = Environment(
|
|
||||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
|
||||||
autoescape=select_autoescape(["html"]),
|
|
||||||
trim_blocks=True,
|
|
||||||
lstrip_blocks=True,
|
|
||||||
)
|
|
||||||
env.filters["humanago"] = _humanago
|
|
||||||
env.filters["domain"] = _domain
|
|
||||||
template = env.get_template("digest.html.j2")
|
|
||||||
edition = "morning" if generated_at.hour < 14 else "evening"
|
|
||||||
reddit_kept = [s for s in reddit_sources if s.items]
|
|
||||||
tech_kept = [s for s in tech_sources if s.items]
|
|
||||||
return template.render(
|
|
||||||
reddit_sources=reddit_kept,
|
|
||||||
tech_sources=tech_kept,
|
|
||||||
world_headlines=world_headlines,
|
|
||||||
local_headlines=local_headlines,
|
|
||||||
reddit_total=sum(len(s.items) for s in reddit_kept),
|
|
||||||
tech_total=sum(len(s.items) for s in tech_kept),
|
|
||||||
world_total=len(world_headlines),
|
|
||||||
local_total=len(local_headlines),
|
|
||||||
generated_at=generated_at,
|
|
||||||
edition=edition,
|
|
||||||
edition_short="AM" if edition == "morning" else "PM",
|
|
||||||
model=LLAMA_SWAP_MODEL,
|
|
||||||
date_long=generated_at.strftime("%A %B %-d, %Y"),
|
|
||||||
time_short=generated_at.strftime("%-I:%M %p"),
|
|
||||||
tz=generated_at.tzname() or TZ_NAME,
|
|
||||||
next_edition=("evening" if edition == "morning" else "morning"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _humanago(d: datetime) -> str:
|
|
||||||
delta = datetime.now(timezone.utc) - d
|
|
||||||
s = int(delta.total_seconds())
|
|
||||||
if s < 60: return f"{s}s"
|
|
||||||
if s < 3600: return f"{s // 60}m"
|
|
||||||
if s < 86400: return f"{s // 3600}h"
|
|
||||||
return f"{s // 86400}d"
|
|
||||||
|
|
||||||
def _domain(url: str) -> str:
|
|
||||||
m = re.match(r"^https?://(?:www\.)?([^/]+)", url or "")
|
|
||||||
return m.group(1) if m else ""
|
|
||||||
|
|
||||||
def write_output(html: str, generated_at: datetime) -> None:
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
edition = "am" if generated_at.hour < 14 else "pm"
|
|
||||||
archive = OUTPUT_DIR / f"edition-{generated_at:%Y-%m-%d}-{edition}.html"
|
|
||||||
index = OUTPUT_DIR / "index.html"
|
|
||||||
|
|
||||||
archive_tmp = archive.with_suffix(".html.tmp")
|
|
||||||
archive_tmp.write_text(html, encoding="utf-8")
|
|
||||||
archive_tmp.rename(archive)
|
|
||||||
|
|
||||||
index_tmp = index.with_suffix(".html.tmp")
|
|
||||||
index_tmp.write_text(html, encoding="utf-8")
|
|
||||||
index_tmp.rename(index)
|
|
||||||
log(f"wrote {index} (and archive {archive.name})")
|
|
||||||
write_archive_index(generated_at)
|
|
||||||
|
|
||||||
ARCHIVE_FNAME_RE = re.compile(r"^edition-(\d{4}-\d{2}-\d{2})-(am|pm)\.html$")
|
|
||||||
|
|
||||||
def write_archive_index(generated_at: datetime) -> None:
|
|
||||||
"""Render /output/archive.html — list every edition-*.html in
|
|
||||||
OUTPUT_DIR, newest-first. Cheap (re-runs every digest fire);
|
|
||||||
template loads from the same TEMPLATE_DIR."""
|
|
||||||
editions = []
|
|
||||||
for p in OUTPUT_DIR.glob("edition-*.html"):
|
|
||||||
m = ARCHIVE_FNAME_RE.match(p.name)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
date_str, ed = m.group(1), m.group(2)
|
|
||||||
try:
|
|
||||||
date = datetime.strptime(date_str, "%Y-%m-%d").date()
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
editions.append({
|
|
||||||
"filename": p.name,
|
|
||||||
"date": date,
|
|
||||||
"edition": "morning" if ed == "am" else "evening",
|
|
||||||
"edition_short": ed.upper(),
|
|
||||||
"date_long": date.strftime("%A %B %-d, %Y"),
|
|
||||||
# Sort key: date descending, then PM before AM (within a day,
|
|
||||||
# PM is the most recent edition).
|
|
||||||
"_sort": (date, 1 if ed == "pm" else 0),
|
|
||||||
})
|
|
||||||
editions.sort(key=lambda e: e["_sort"], reverse=True)
|
|
||||||
|
|
||||||
env = Environment(
|
|
||||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
|
||||||
autoescape=select_autoescape(["html"]),
|
|
||||||
trim_blocks=True,
|
|
||||||
lstrip_blocks=True,
|
|
||||||
)
|
|
||||||
tpl = env.get_template("archive.html.j2")
|
|
||||||
html = tpl.render(
|
|
||||||
editions=editions,
|
|
||||||
generated_at=generated_at,
|
|
||||||
total=len(editions),
|
|
||||||
)
|
|
||||||
|
|
||||||
out = OUTPUT_DIR / "archive.html"
|
|
||||||
out_tmp = out.with_suffix(".html.tmp")
|
|
||||||
out_tmp.write_text(html, encoding="utf-8")
|
|
||||||
out_tmp.rename(out)
|
|
||||||
log(f"wrote {out} ({len(editions)} editions indexed)")
|
|
||||||
|
|
||||||
# ── main ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
if not MINIFLUX_PASS:
|
|
||||||
print("MINIFLUX_PASSWORD not set — bailing", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
try:
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
now_local = datetime.now(ZoneInfo(TZ_NAME))
|
|
||||||
except Exception:
|
|
||||||
now_local = datetime.now()
|
|
||||||
log(f"starting digest run at {now_local.isoformat()} ({'AM' if now_local.hour < 14 else 'PM'})")
|
|
||||||
|
|
||||||
log("phase 1: discovering subreddits from miniflux")
|
|
||||||
subs = discover_subreddits_from_miniflux()
|
|
||||||
log(f" found {len(subs)} subreddits: {', '.join(subs) or '(none)'}")
|
|
||||||
|
|
||||||
log("phase 2: fetching reddit top-of-day per subreddit")
|
|
||||||
reddit_sources: list[Source] = []
|
|
||||||
for sub in subs:
|
|
||||||
reddit_sources.append(fetch_reddit_top(sub))
|
|
||||||
time.sleep(1.5) # gentle to anonymous Reddit
|
|
||||||
|
|
||||||
log("phase 3a: fetching world headlines from miniflux")
|
|
||||||
world_headlines = fetch_miniflux_headlines(MINIFLUX_WORLD_CATEGORY)
|
|
||||||
log(f" found {len(world_headlines)} world headlines")
|
|
||||||
|
|
||||||
log("phase 3b: fetching local headlines from miniflux")
|
|
||||||
local_headlines = fetch_miniflux_headlines(MINIFLUX_LOCAL_CATEGORY)
|
|
||||||
log(f" found {len(local_headlines)} local headlines")
|
|
||||||
|
|
||||||
log("phase 3c: fetching tech-aggregator items from miniflux")
|
|
||||||
tech_sources = fetch_miniflux_tech_items()
|
|
||||||
log(f" found {len(tech_sources)} non-reddit feeds with recent items")
|
|
||||||
|
|
||||||
log("phase 3d: warming article-text cache (parallel)")
|
|
||||||
article_cache = article_cache_load()
|
|
||||||
all_urls: list[str] = []
|
|
||||||
for src in tech_sources + reddit_sources:
|
|
||||||
for it in src.items:
|
|
||||||
all_urls.append(it.url)
|
|
||||||
for h in world_headlines + local_headlines:
|
|
||||||
all_urls.append(h.url)
|
|
||||||
warm_article_cache(all_urls, article_cache)
|
|
||||||
|
|
||||||
log("phase 4a: summarizing reddit + tech sources via llama-swap")
|
|
||||||
for src in reddit_sources + tech_sources:
|
|
||||||
summarize_source(src, article_cache)
|
|
||||||
|
|
||||||
log("phase 4b: summarizing world + local headlines via llama-swap")
|
|
||||||
summarize_headlines(world_headlines, "world", article_cache)
|
|
||||||
summarize_headlines(local_headlines, "local", article_cache)
|
|
||||||
|
|
||||||
article_cache_save(article_cache)
|
|
||||||
|
|
||||||
log("phase 5: rendering")
|
|
||||||
html = render(reddit_sources, tech_sources, world_headlines, local_headlines, now_local)
|
|
||||||
write_output(html, now_local)
|
|
||||||
log("done")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -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
|
|
||||||
@@ -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')] /"
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""One-shot setup for the digest's World + Local headlines sections.
|
|
||||||
|
|
||||||
Creates the two miniflux categories (idempotent — no-op if present)
|
|
||||||
and subscribes a curated default feed list into each. Skips any feed
|
|
||||||
that's already subscribed in miniflux, so reruns are safe.
|
|
||||||
|
|
||||||
Run from inside the news-digest-worker container so the MINIFLUX_*
|
|
||||||
env vars are already set:
|
|
||||||
|
|
||||||
docker exec news-digest-worker python3 /app/seed-headlines.py
|
|
||||||
|
|
||||||
After this completes, the next digest run picks up the categories
|
|
||||||
automatically — no app restart needed.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
MINIFLUX_URL = os.environ.get("MINIFLUX_URL", "http://miniflux:8080").rstrip("/")
|
|
||||||
MINIFLUX_USER = os.environ.get("MINIFLUX_USER", "lkraven")
|
|
||||||
MINIFLUX_PASS = os.environ.get("MINIFLUX_PASSWORD", "")
|
|
||||||
|
|
||||||
WORLD_CATEGORY = os.environ.get("DIGEST_MINIFLUX_WORLD_CATEGORY", "World")
|
|
||||||
LOCAL_CATEGORY = os.environ.get("DIGEST_MINIFLUX_LOCAL_CATEGORY", "Local")
|
|
||||||
|
|
||||||
# Default feed lists. Picked for: well-maintained RSS, low duplication
|
|
||||||
# across the set, mix of styles (wire-service vs editorial). Edit here
|
|
||||||
# and rerun the script to add/remove — already-subscribed feeds are
|
|
||||||
# skipped, so adding a new one only adds.
|
|
||||||
# (feed_url, display_title) — display_title overrides whatever miniflux
|
|
||||||
# pulls from the feed's <title> element. Keeps the dense headlines rail
|
|
||||||
# tidy ("BBC" beats "BBC News"; "Al Jazeera" beats the 60-char default).
|
|
||||||
WORLD_FEEDS = [
|
|
||||||
("http://feeds.bbci.co.uk/news/world/rss.xml", "BBC"),
|
|
||||||
("https://feeds.npr.org/1001/rss.xml", "NPR"),
|
|
||||||
("https://www.aljazeera.com/xml/rss/all.xml", "Al Jazeera"),
|
|
||||||
]
|
|
||||||
LOCAL_FEEDS = [
|
|
||||||
("https://www.latimes.com/local/rss2.0.xml", "LA Times"),
|
|
||||||
("https://www.latimes.com/california/rss2.0.xml", "LA Times CA"),
|
|
||||||
("https://voiceofoc.org/feed/", "Voice of OC"),
|
|
||||||
# OC Register blocks miniflux's fetcher (403). KTLA / Daily Pilot
|
|
||||||
# are options if more OC-specific coverage is needed later.
|
|
||||||
]
|
|
||||||
|
|
||||||
S = requests.Session()
|
|
||||||
S.auth = (MINIFLUX_USER, MINIFLUX_PASS)
|
|
||||||
|
|
||||||
|
|
||||||
def get(path: str) -> object:
|
|
||||||
r = S.get(f"{MINIFLUX_URL}{path}", timeout=20)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def post(path: str, body: dict) -> requests.Response:
|
|
||||||
return S.post(f"{MINIFLUX_URL}{path}", json=body, timeout=30)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_category(title: str) -> int:
|
|
||||||
"""Return the ID of the category named `title`, creating it if missing."""
|
|
||||||
cats = get("/v1/categories")
|
|
||||||
for c in cats:
|
|
||||||
if c["title"].lower() == title.lower():
|
|
||||||
print(f" category {title!r} already exists (id={c['id']})")
|
|
||||||
return c["id"]
|
|
||||||
r = post("/v1/categories", {"title": title})
|
|
||||||
r.raise_for_status()
|
|
||||||
cat_id = r.json()["id"]
|
|
||||||
print(f" created category {title!r} (id={cat_id})")
|
|
||||||
return cat_id
|
|
||||||
|
|
||||||
|
|
||||||
def find_feed_id(feed_url: str) -> int | None:
|
|
||||||
"""Return miniflux feed ID for `feed_url` if subscribed; else None.
|
|
||||||
Compares with both the original URL and miniflux's canonical
|
|
||||||
rewrite (some feeds get http→https'd or trailing-slashed at fetch
|
|
||||||
time)."""
|
|
||||||
feeds = get("/v1/feeds")
|
|
||||||
target = feed_url.rstrip("/")
|
|
||||||
for f in feeds:
|
|
||||||
url = (f.get("feed_url") or "").rstrip("/")
|
|
||||||
if url == target or url == target.replace("http://", "https://", 1):
|
|
||||||
return f["id"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def set_feed_title(feed_id: int, title: str) -> None:
|
|
||||||
r = S.put(f"{MINIFLUX_URL}/v1/feeds/{feed_id}", json={"title": title}, timeout=20)
|
|
||||||
if not r.ok:
|
|
||||||
print(f" ! rename failed ({r.status_code}): {r.text[:120]}")
|
|
||||||
|
|
||||||
|
|
||||||
def subscribe(feed_url: str, display_title: str, category_id: int) -> None:
|
|
||||||
existing = find_feed_id(feed_url)
|
|
||||||
if existing is not None:
|
|
||||||
print(f" skip (already subscribed): {feed_url}")
|
|
||||||
set_feed_title(existing, display_title)
|
|
||||||
return
|
|
||||||
r = post("/v1/feeds", {"feed_url": feed_url, "category_id": category_id})
|
|
||||||
if r.status_code in (200, 201):
|
|
||||||
new_id = r.json().get("feed_id")
|
|
||||||
print(f" + subscribed: {feed_url}")
|
|
||||||
if new_id:
|
|
||||||
set_feed_title(new_id, display_title)
|
|
||||||
else:
|
|
||||||
# Most common failure mode is a feed-discovery hiccup at
|
|
||||||
# miniflux's end — log loudly and continue so one bad URL
|
|
||||||
# doesn't block the rest.
|
|
||||||
print(f" ! FAILED ({r.status_code}): {feed_url} {r.text[:200]}")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
if not MINIFLUX_PASS:
|
|
||||||
print("MINIFLUX_PASSWORD not set — bailing", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
print(f"miniflux: {MINIFLUX_URL} user={MINIFLUX_USER}")
|
|
||||||
|
|
||||||
print(f"\n→ {WORLD_CATEGORY}")
|
|
||||||
world_id = ensure_category(WORLD_CATEGORY)
|
|
||||||
for url, title in WORLD_FEEDS:
|
|
||||||
subscribe(url, title, world_id)
|
|
||||||
|
|
||||||
print(f"\n→ {LOCAL_CATEGORY}")
|
|
||||||
local_id = ensure_category(LOCAL_CATEGORY)
|
|
||||||
for url, title in LOCAL_FEEDS:
|
|
||||||
subscribe(url, title, local_id)
|
|
||||||
|
|
||||||
print("\ndone — next digest run will pick up the new sections.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
// news-digest — per-item × button + hidden tray.
|
|
||||||
//
|
|
||||||
// State lives server-side at /api/hidden (a JSON array of item IDs the
|
|
||||||
// user has hidden). Same set is shared across every device the user
|
|
||||||
// opens the digest from. Each item has a stable `data-id` (12-char sha1
|
|
||||||
// prefix computed by digest.py at render time).
|
|
||||||
//
|
|
||||||
// Lifecycle:
|
|
||||||
// 1. On load, GET /api/hidden — apply `is-hidden` to matching items
|
|
||||||
// pre-paint by hiding the <main>, then revealing it on the next
|
|
||||||
// animation frame after the class-toggle pass. (Avoids a flash of
|
|
||||||
// hide-then-show.)
|
|
||||||
// 2. × button click → POST /api/hide {id}, animate the item out, add
|
|
||||||
// a row to the hidden tray, optimistically commit (rollback on
|
|
||||||
// network error).
|
|
||||||
// 3. Tray "restore" click → POST /api/restore {id}, animate item back,
|
|
||||||
// remove tray row.
|
|
||||||
//
|
|
||||||
// Tray semantics: shows only items hidden FROM THE CURRENT PAGE, since
|
|
||||||
// titles/urls come from the DOM. Older hidden IDs not on this page just
|
|
||||||
// sit silently in /api/hidden and continue to filter future pages that
|
|
||||||
// happen to include the same article.
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const HIDE_CLASS = "is-hidden";
|
|
||||||
const TRAY = document.getElementById("hidden-tray");
|
|
||||||
const TRAY_LIST = document.getElementById("hidden-tray-list");
|
|
||||||
const TRAY_COUNT = document.getElementById("hidden-tray-count");
|
|
||||||
const TRAY_TOGGLE = TRAY && TRAY.querySelector(".hidden-tray-toggle");
|
|
||||||
|
|
||||||
/** Map<id, HTMLElement> — every hideable element on this page, keyed by
|
|
||||||
* data-id. Both .item (cards) and .headline (compact rows) qualify. */
|
|
||||||
const itemsById = new Map();
|
|
||||||
document.querySelectorAll(".item[data-id], .headline[data-id]").forEach((el) => {
|
|
||||||
itemsById.set(el.dataset.id, el);
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── network ────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
async function apiGetHidden() {
|
|
||||||
try {
|
|
||||||
const r = await fetch("/api/hidden", { credentials: "same-origin" });
|
|
||||||
if (!r.ok) throw new Error(`GET /api/hidden ${r.status}`);
|
|
||||||
return new Set(await r.json());
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[digest] failed to fetch hidden state:", e);
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiHide(id, hide) {
|
|
||||||
const path = hide ? "/api/hide" : "/api/restore";
|
|
||||||
const r = await fetch(path, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: JSON.stringify({ id }),
|
|
||||||
});
|
|
||||||
if (!r.ok) throw new Error(`POST ${path} ${r.status}`);
|
|
||||||
return r.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── live counts ─────────────────────────────────────────────────
|
|
||||||
The server-rendered .source-count and .desk-count badges are
|
|
||||||
accurate at render time, but they fall stale as soon as the user
|
|
||||||
hides anything. Recompute from the DOM whenever the visible set
|
|
||||||
changes. Empty sources / desks get an .is-empty class that hides
|
|
||||||
them entirely (no point showing "r/homelab (0)"). */
|
|
||||||
|
|
||||||
function refreshCounts() {
|
|
||||||
document.querySelectorAll(".source").forEach((src) => {
|
|
||||||
const visible = src.querySelectorAll(".item:not(.is-hidden)").length;
|
|
||||||
const badge = src.querySelector(".source-count");
|
|
||||||
if (badge) badge.textContent = String(visible);
|
|
||||||
src.classList.toggle("is-empty", visible === 0);
|
|
||||||
});
|
|
||||||
document.querySelectorAll(".desk").forEach((desk) => {
|
|
||||||
const visibleItems = desk.querySelectorAll(".item:not(.is-hidden), .headline:not(.is-hidden)").length;
|
|
||||||
const badge = desk.querySelector(".desk-count");
|
|
||||||
if (badge) badge.textContent = `${visibleItems} items`;
|
|
||||||
desk.classList.toggle("is-empty", visibleItems === 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── tray rendering ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function refreshTrayVisibility() {
|
|
||||||
if (!TRAY) return;
|
|
||||||
const n = TRAY_LIST.children.length;
|
|
||||||
TRAY_COUNT.textContent = String(n);
|
|
||||||
if (n > 0) {
|
|
||||||
TRAY.hidden = false;
|
|
||||||
} else {
|
|
||||||
TRAY.hidden = true;
|
|
||||||
// Also collapse so that re-hiding starts collapsed-with-content.
|
|
||||||
setTrayExpanded(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTrayExpanded(expanded) {
|
|
||||||
if (!TRAY_LIST || !TRAY_TOGGLE) return;
|
|
||||||
TRAY_LIST.hidden = !expanded;
|
|
||||||
TRAY_TOGGLE.setAttribute("aria-expanded", String(expanded));
|
|
||||||
TRAY.classList.toggle("is-expanded", expanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTrayRow(id, title) {
|
|
||||||
if (!TRAY_LIST) return;
|
|
||||||
if (TRAY_LIST.querySelector(`[data-id="${CSS.escape(id)}"]`)) return;
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.className = "hidden-tray-item";
|
|
||||||
li.dataset.id = id;
|
|
||||||
li.innerHTML =
|
|
||||||
`<span class="hidden-tray-title"></span>` +
|
|
||||||
`<button class="hidden-tray-restore" type="button" title="Restore" aria-label="Restore">↺</button>`;
|
|
||||||
li.querySelector(".hidden-tray-title").textContent = title;
|
|
||||||
li.querySelector(".hidden-tray-restore").addEventListener("click", () => {
|
|
||||||
restoreItem(id);
|
|
||||||
});
|
|
||||||
TRAY_LIST.appendChild(li);
|
|
||||||
refreshTrayVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTrayRow(id) {
|
|
||||||
if (!TRAY_LIST) return;
|
|
||||||
const row = TRAY_LIST.querySelector(`[data-id="${CSS.escape(id)}"]`);
|
|
||||||
if (row) row.remove();
|
|
||||||
refreshTrayVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleOf(el) {
|
|
||||||
const t = el.querySelector(".item-title, .headline-title");
|
|
||||||
return (t && t.textContent.trim()) || "(untitled)";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── hide / restore ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
async function hideItem(id) {
|
|
||||||
const el = itemsById.get(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (el.classList.contains(HIDE_CLASS)) return;
|
|
||||||
|
|
||||||
// Optimistic — flip class first, talk to server next.
|
|
||||||
el.classList.add(HIDE_CLASS);
|
|
||||||
addTrayRow(id, titleOf(el));
|
|
||||||
refreshCounts();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiHide(id, true);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[digest] hide failed, rolling back:", e);
|
|
||||||
el.classList.remove(HIDE_CLASS);
|
|
||||||
removeTrayRow(id);
|
|
||||||
refreshCounts();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreItem(id) {
|
|
||||||
const el = itemsById.get(id);
|
|
||||||
if (!el) {
|
|
||||||
// Item is in tray but not in DOM — happens if the tray was rendered
|
|
||||||
// from the API set for an item not on this page. Just clear it
|
|
||||||
// server-side and remove the row.
|
|
||||||
try { await apiHide(id, false); } catch (_) {}
|
|
||||||
removeTrayRow(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.classList.remove(HIDE_CLASS);
|
|
||||||
removeTrayRow(id);
|
|
||||||
refreshCounts();
|
|
||||||
try {
|
|
||||||
await apiHide(id, false);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[digest] restore failed, re-hiding:", e);
|
|
||||||
el.classList.add(HIDE_CLASS);
|
|
||||||
addTrayRow(id, titleOf(el));
|
|
||||||
refreshCounts();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── wiring ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
// × button — single delegated handler at the document level so we don't
|
|
||||||
// need to attach to each .item-hide individually (cheaper, also handles
|
|
||||||
// dynamically-rendered items if we ever add them).
|
|
||||||
document.addEventListener("click", (ev) => {
|
|
||||||
const btn = ev.target.closest(".item-hide");
|
|
||||||
if (!btn) return;
|
|
||||||
const item = btn.closest("[data-id]");
|
|
||||||
if (!item) return;
|
|
||||||
ev.preventDefault();
|
|
||||||
hideItem(item.dataset.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (TRAY_TOGGLE) {
|
|
||||||
TRAY_TOGGLE.addEventListener("click", () => {
|
|
||||||
const wasExpanded = TRAY.classList.contains("is-expanded");
|
|
||||||
setTrayExpanded(!wasExpanded);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── desk collapse (per-device, localStorage) ───────────────────── */
|
|
||||||
// Stored as a JSON object {deskId: true} of collapsed desks. Per
|
|
||||||
// device by design — collapse is a viewing preference, not content
|
|
||||||
// state, so no server roundtrip.
|
|
||||||
const COLLAPSE_KEY = "digest:collapsed-desks";
|
|
||||||
function readCollapsed() {
|
|
||||||
try { return JSON.parse(localStorage.getItem(COLLAPSE_KEY) || "{}"); }
|
|
||||||
catch (_) { return {}; }
|
|
||||||
}
|
|
||||||
function writeCollapsed(obj) {
|
|
||||||
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(obj)); } catch (_) {}
|
|
||||||
}
|
|
||||||
function applyCollapseState() {
|
|
||||||
const state = readCollapsed();
|
|
||||||
document.querySelectorAll(".desk[id]").forEach((desk) => {
|
|
||||||
desk.classList.toggle("is-collapsed", !!state[desk.id]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
applyCollapseState();
|
|
||||||
document.addEventListener("click", (ev) => {
|
|
||||||
const btn = ev.target.closest(".desk-collapse");
|
|
||||||
if (!btn) return;
|
|
||||||
const desk = btn.closest(".desk[id]");
|
|
||||||
if (!desk) return;
|
|
||||||
ev.preventDefault();
|
|
||||||
desk.classList.toggle("is-collapsed");
|
|
||||||
const state = readCollapsed();
|
|
||||||
if (desk.classList.contains("is-collapsed")) state[desk.id] = true;
|
|
||||||
else delete state[desk.id];
|
|
||||||
writeCollapsed(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── initial paint ──────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
apiGetHidden().then((hidden) => {
|
|
||||||
let trayHadAdditions = false;
|
|
||||||
hidden.forEach((id) => {
|
|
||||||
const el = itemsById.get(id);
|
|
||||||
if (el) {
|
|
||||||
el.classList.add(HIDE_CLASS);
|
|
||||||
addTrayRow(id, titleOf(el));
|
|
||||||
trayHadAdditions = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (trayHadAdditions) refreshTrayVisibility();
|
|
||||||
refreshCounts();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<title>Daily Digest · Archive</title>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
|
||||||
<link rel="stylesheet" href="style.css?v={{ generated_at.strftime('%Y%m%d%H%M%S') }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="masthead" role="banner">
|
|
||||||
<div class="masthead-inner">
|
|
||||||
<a class="masthead-brand" href="index.html" title="Latest edition">
|
|
||||||
<span class="brand-glyph" aria-hidden="true">◢</span>
|
|
||||||
<span class="brand-mark">DAILY DIGEST</span>
|
|
||||||
</a>
|
|
||||||
<div class="masthead-edition">
|
|
||||||
<span class="edition-stamp">ARCHIVE</span>
|
|
||||||
<a class="archive-link" href="index.html">← LATEST</a>
|
|
||||||
</div>
|
|
||||||
<div class="masthead-meta">
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="meta-label">EDITIONS ON FILE</span>
|
|
||||||
<span class="meta-value">{{ total }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="meta-label">CADENCE</span>
|
|
||||||
<span class="meta-value">2× DAILY · 0800 / 2000</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="meta-label">RETENTION</span>
|
|
||||||
<span class="meta-value">UNLIMITED</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aurora-rule" aria-hidden="true"></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="brief">
|
|
||||||
<section class="desk">
|
|
||||||
<header class="desk-head">
|
|
||||||
<span class="desk-num">∞</span>
|
|
||||||
<h2 class="desk-title">All Editions</h2>
|
|
||||||
<span class="desk-sub">newest first</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if editions %}
|
|
||||||
<ol class="archive-list">
|
|
||||||
{% for e in editions %}
|
|
||||||
<li class="archive-item">
|
|
||||||
<a class="archive-row" href="{{ e.filename }}">
|
|
||||||
<span class="archive-date">{{ e.date_long }}</span>
|
|
||||||
<span class="archive-edition archive-edition-{{ e.edition_short|lower }}">
|
|
||||||
{{ e.edition_short }}
|
|
||||||
</span>
|
|
||||||
<span class="archive-arrow">→</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-msg">No editions on file yet. Cron fires twice daily at 0800 / 2000.</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="colophon">
|
|
||||||
<div class="colophon-inner">
|
|
||||||
<div class="colophon-block">
|
|
||||||
<span class="meta-label">INDEXED</span>
|
|
||||||
<span class="meta-value">{{ generated_at.isoformat(timespec="seconds") }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="colophon-block">
|
|
||||||
<span class="meta-label">STORE</span>
|
|
||||||
<span class="meta-value">/opt/docker/data/news-digest</span>
|
|
||||||
</div>
|
|
||||||
<div class="colophon-block">
|
|
||||||
<span class="meta-label">FORMAT</span>
|
|
||||||
<span class="meta-value">edition-YYYY-MM-DD-{am,pm}.html</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<title>Daily Digest · {{ date_long }} · {{ edition_short }}</title>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
|
||||||
{# Cache-bust on every digest run so frontend asset updates land
|
|
||||||
immediately — browsers were serving stale app.js / style.css after
|
|
||||||
rebuilds, which was hiding headline desks (old JS treated them as
|
|
||||||
empty because it only counted .item, not .headline). #}
|
|
||||||
<link rel="stylesheet" href="style.css?v={{ generated_at.strftime('%Y%m%d%H%M%S') }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{# ────── header ────── #}
|
|
||||||
<header class="masthead" role="banner">
|
|
||||||
<div class="masthead-inner">
|
|
||||||
<a class="masthead-brand" href="index.html" title="Latest edition">
|
|
||||||
<span class="brand-glyph" aria-hidden="true">◢</span>
|
|
||||||
<span class="brand-mark">DAILY DIGEST</span>
|
|
||||||
</a>
|
|
||||||
<div class="masthead-edition">
|
|
||||||
<span class="edition-num">№ {{ generated_at.strftime("%j") }}</span>
|
|
||||||
<span class="edition-stamp">{{ edition|upper }} EDITION</span>
|
|
||||||
<a class="archive-link" href="archive.html">ARCHIVE →</a>
|
|
||||||
</div>
|
|
||||||
<div class="masthead-meta">
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="meta-label">FILED</span>
|
|
||||||
<span class="meta-value">{{ date_long|upper }} · {{ time_short }} {{ tz }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="meta-label">DESK</span>
|
|
||||||
<span class="meta-value">WORLD · LOCAL · SUBREDDITS · TECH FEEDS</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="meta-label">CURATED BY</span>
|
|
||||||
<span class="meta-value">{{ model }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{# A thin aurora-glow rule under the masthead — the only sanctioned
|
|
||||||
Australis gradient. #}
|
|
||||||
<div class="aurora-rule" aria-hidden="true"></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{# ────── nav strip ────── #}
|
|
||||||
<nav class="jumpnav" aria-label="sections">
|
|
||||||
<ol>
|
|
||||||
{% if world_headlines %}
|
|
||||||
<li><a href="#world"><span class="jump-num">01</span> WORLD</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if local_headlines %}
|
|
||||||
<li><a href="#local"><span class="jump-num">02</span> LOCAL</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if reddit_sources %}
|
|
||||||
<li><a href="#reddit"><span class="jump-num">03</span> REDDIT</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if tech_sources %}
|
|
||||||
<li><a href="#tech"><span class="jump-num">04</span> TECH FEEDS</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="brief">
|
|
||||||
|
|
||||||
{# ────── WORLD HEADLINES ────── #}
|
|
||||||
{% if world_headlines %}
|
|
||||||
<section id="world" class="desk desk-headlines">
|
|
||||||
<header class="desk-head">
|
|
||||||
<span class="desk-num">01</span>
|
|
||||||
<h2 class="desk-title">World</h2>
|
|
||||||
<span class="desk-sub">top of last {{ generated_at.hour < 14 and "8" or "8" }} hours · headlines</span>
|
|
||||||
<span class="desk-count">{{ world_total }} items</span>
|
|
||||||
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
|
|
||||||
</header>
|
|
||||||
<ol class="headlines-list">
|
|
||||||
{% for h in world_headlines %}
|
|
||||||
<li class="headline" data-id="{{ h.id }}">
|
|
||||||
<button class="item-hide" type="button" title="Hide this headline" aria-label="Hide this headline">×</button>
|
|
||||||
<h3 class="headline-title">
|
|
||||||
<a href="{{ h.url }}" target="_blank" rel="noopener">{{ h.title }}</a>
|
|
||||||
</h3>
|
|
||||||
<span class="headline-source">{{ h.source }}</span>
|
|
||||||
<span class="headline-time">{{ h.posted_at|humanago }}</span>
|
|
||||||
{% if h.tldr %}
|
|
||||||
<p class="headline-tldr">{{ h.tldr }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ────── LOCAL HEADLINES ────── #}
|
|
||||||
{% if local_headlines %}
|
|
||||||
<section id="local" class="desk desk-headlines">
|
|
||||||
<header class="desk-head">
|
|
||||||
<span class="desk-num">02</span>
|
|
||||||
<h2 class="desk-title">Local</h2>
|
|
||||||
<span class="desk-sub">orange county · greater LA · last 8 hours</span>
|
|
||||||
<span class="desk-count">{{ local_total }} items</span>
|
|
||||||
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
|
|
||||||
</header>
|
|
||||||
<ol class="headlines-list">
|
|
||||||
{% for h in local_headlines %}
|
|
||||||
<li class="headline" data-id="{{ h.id }}">
|
|
||||||
<button class="item-hide" type="button" title="Hide this headline" aria-label="Hide this headline">×</button>
|
|
||||||
<h3 class="headline-title">
|
|
||||||
<a href="{{ h.url }}" target="_blank" rel="noopener">{{ h.title }}</a>
|
|
||||||
</h3>
|
|
||||||
<span class="headline-source">{{ h.source }}</span>
|
|
||||||
<span class="headline-time">{{ h.posted_at|humanago }}</span>
|
|
||||||
{% if h.tldr %}
|
|
||||||
<p class="headline-tldr">{{ h.tldr }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ────── REDDIT SECTION ────── #}
|
|
||||||
{% if reddit_sources %}
|
|
||||||
<section id="reddit" class="desk">
|
|
||||||
<header class="desk-head">
|
|
||||||
<span class="desk-num">03</span>
|
|
||||||
<h2 class="desk-title">Reddit</h2>
|
|
||||||
<span class="desk-sub">top of last {{ generated_at.hour < 14 and "12" or "12" }} hours · score-filtered</span>
|
|
||||||
<span class="desk-count">{{ reddit_total }} items</span>
|
|
||||||
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% for src in reddit_sources %}
|
|
||||||
<article class="source">
|
|
||||||
<header class="source-head">
|
|
||||||
<a class="source-name" href="{{ src.href }}" target="_blank" rel="noopener">
|
|
||||||
{{ src.name }}
|
|
||||||
</a>
|
|
||||||
<span class="source-count">{{ src.items|length }}</span>
|
|
||||||
</header>
|
|
||||||
<ol class="items">
|
|
||||||
{% for it in src.items %}
|
|
||||||
<li class="item" data-tag="{{ it.tag }}" data-id="{{ it.id }}">
|
|
||||||
<button class="item-hide" type="button" title="Hide this item" aria-label="Hide this item">×</button>
|
|
||||||
<div class="item-rail" aria-hidden="true">
|
|
||||||
{% if it.score %}
|
|
||||||
<span class="chip chip-score" title="upvotes">▲ {{ it.score }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if it.comments %}
|
|
||||||
<span class="chip chip-comments" title="comments">{{ it.comments }} ⌥</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if it.tag and it.tag != 'other' %}
|
|
||||||
<span class="chip chip-tag chip-tag-{{ it.tag }}">{{ it.tag }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="item-body">
|
|
||||||
<h3 class="item-title">
|
|
||||||
<a href="{{ it.url or it.permalink }}" target="_blank" rel="noopener">{{ it.title }}</a>
|
|
||||||
</h3>
|
|
||||||
{% if it.tldr %}
|
|
||||||
<p class="item-tldr">{{ it.tldr }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<footer class="item-foot">
|
|
||||||
<div class="item-actions">
|
|
||||||
{% if it.url and it.url != it.permalink %}
|
|
||||||
<a class="action action-source" href="{{ it.url }}" target="_blank" rel="noopener">
|
|
||||||
<span class="action-arrow">↗</span>
|
|
||||||
<span class="action-label">SOURCE</span>
|
|
||||||
<span class="action-domain">{{ it.url|domain }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<a class="action action-thread" href="{{ it.permalink }}" target="_blank" rel="noopener">
|
|
||||||
<span class="action-arrow">⌥</span>
|
|
||||||
<span class="action-label">REDDIT THREAD</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="item-meta-row">
|
|
||||||
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
|
|
||||||
<span class="sep">·</span>
|
|
||||||
<span class="item-meta">u/{{ it.author }}</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ────── TECH FEEDS SECTION ────── #}
|
|
||||||
{% if tech_sources %}
|
|
||||||
<section id="tech" class="desk">
|
|
||||||
<header class="desk-head">
|
|
||||||
<span class="desk-num">04</span>
|
|
||||||
<h2 class="desk-title">Tech Feeds</h2>
|
|
||||||
<span class="desk-sub">non-reddit · last {{ generated_at.hour < 14 and "12" or "12" }} hours</span>
|
|
||||||
<span class="desk-count">{{ tech_total }} items</span>
|
|
||||||
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% for src in tech_sources %}
|
|
||||||
<article class="source">
|
|
||||||
<header class="source-head">
|
|
||||||
<a class="source-name" href="{{ src.href }}" target="_blank" rel="noopener">
|
|
||||||
{{ src.name }}
|
|
||||||
</a>
|
|
||||||
<span class="source-count">{{ src.items|length }}</span>
|
|
||||||
</header>
|
|
||||||
<ol class="items">
|
|
||||||
{% for it in src.items %}
|
|
||||||
<li class="item" data-tag="{{ it.tag }}" data-id="{{ it.id }}">
|
|
||||||
<button class="item-hide" type="button" title="Hide this item" aria-label="Hide this item">×</button>
|
|
||||||
<div class="item-rail" aria-hidden="true">
|
|
||||||
{% if it.tag and it.tag != 'other' %}
|
|
||||||
<span class="chip chip-tag chip-tag-{{ it.tag }}">{{ it.tag }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="item-body">
|
|
||||||
<h3 class="item-title">
|
|
||||||
<a href="{{ it.url }}" target="_blank" rel="noopener">{{ it.title }}</a>
|
|
||||||
</h3>
|
|
||||||
{% if it.tldr %}
|
|
||||||
<p class="item-tldr">{{ it.tldr }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<footer class="item-foot">
|
|
||||||
{% if it.url %}
|
|
||||||
<div class="item-actions">
|
|
||||||
<a class="action action-source" href="{{ it.url }}" target="_blank" rel="noopener">
|
|
||||||
<span class="action-arrow">↗</span>
|
|
||||||
<span class="action-label">SOURCE</span>
|
|
||||||
<span class="action-domain">{{ it.url|domain }}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="item-meta-row">
|
|
||||||
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
|
|
||||||
{% if it.author %}
|
|
||||||
<span class="sep">·</span>
|
|
||||||
<span class="item-meta">{{ it.author }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if not reddit_sources and not tech_sources and not world_headlines and not local_headlines %}
|
|
||||||
<section class="desk empty">
|
|
||||||
<p class="empty-msg">
|
|
||||||
No items cleared the filters in the last window.<br>
|
|
||||||
Lower <code>DIGEST_MIN_SCORE</code> or widen
|
|
||||||
<code>DIGEST_REDDIT_HOURS</code> if this looks wrong.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ────── HIDDEN TRAY ──────
|
|
||||||
Hidden by default. app.js unhides it once it has anything to show
|
|
||||||
(i.e. at least one .item on this page is in the user's hidden set).
|
|
||||||
Click the header to expand/collapse; click a row's restore button
|
|
||||||
to bring the item back into the desk it came from. #}
|
|
||||||
<section id="hidden-tray" class="hidden-tray" hidden>
|
|
||||||
<header class="hidden-tray-head">
|
|
||||||
<button class="hidden-tray-toggle" type="button" aria-expanded="false">
|
|
||||||
<span class="tray-glyph" aria-hidden="true">▾</span>
|
|
||||||
<span class="tray-label">Hidden</span>
|
|
||||||
<span class="tray-count" id="hidden-tray-count">0</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<ol class="hidden-tray-list" id="hidden-tray-list" hidden></ol>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="colophon">
|
|
||||||
<div class="colophon-inner">
|
|
||||||
<div class="colophon-block">
|
|
||||||
<span class="meta-label">FILED</span>
|
|
||||||
<span class="meta-value">{{ generated_at.isoformat(timespec="seconds") }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="colophon-block">
|
|
||||||
<span class="meta-label">NEXT EDITION</span>
|
|
||||||
<span class="meta-value">{{ next_edition|upper }} · 12 H</span>
|
|
||||||
</div>
|
|
||||||
<div class="colophon-block">
|
|
||||||
<span class="meta-label">PIPELINE</span>
|
|
||||||
<span class="meta-value">REDDIT JSON + MINIFLUX → {{ model }} → JINJA2</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
<script src="app.js?v={{ generated_at.strftime('%Y%m%d%H%M%S') }}" defer></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
||||||
<title>Daily Digest</title>
|
|
||||||
<desc>Masthead rule across the top with descending text-line indicators below — reads as "stacked headlines / news columns" at any size. Australis palette: cool-black surface, bright-cyan masthead, bright-70 / dark-50 text lines.</desc>
|
|
||||||
<rect width="32" height="32" rx="5" fill="#222531"/>
|
|
||||||
<!-- masthead rule (echoes the page's aurora-rule + cyan brand glyph) -->
|
|
||||||
<rect x="4" y="5" width="24" height="2" rx="1" fill="#42dcd1"/>
|
|
||||||
<!-- four text-line indicators of varying widths — like a newspaper -->
|
|
||||||
<rect x="4" y="11" width="20" height="2" rx="1" fill="#b3cbcf"/>
|
|
||||||
<rect x="4" y="16" width="24" height="2" rx="1" fill="#9daeb6"/>
|
|
||||||
<rect x="4" y="21" width="14" height="2" rx="1" fill="#9daeb6"/>
|
|
||||||
<rect x="4" y="26" width="18" height="2" rx="1" fill="#6e7882"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 925 B |
File diff suppressed because it is too large
Load Diff
@@ -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")
|
|
||||||
Reference in New Issue
Block a user