stacks/news-digest: twice-daily LLM-curated briefing on ana-docker
The Miniflux inbox got noisy after a few subreddits + HN + Lobste.rs.
This stack distills a single static page twice a day — at 0800 and
2000 local — that surfaces only what cleared score + ratio filters,
each item tldr'd by qwen3.5-35-a3b on llama-swap.
Pipeline (digest.py, ~330 lines):
1. Discover subreddits from Miniflux feeds (any reddit.com/r/<sub>/
URL — single source of truth, no duplicated config).
2. Reddit JSON top-of-day per sub. Filter: score >= 50,
upvote_ratio >= 0.85. Cap 8 items per sub.
3. Miniflux /v1/entries for the 'Tech aggregators' category
(HN, Lobste.rs) — last 12 hours.
4. Batched per-source summarization via llama-swap
/v1/chat/completions. Each post gets a one-sentence tldr +
one-word tag (news / tutorial / release / discussion /
question / showcase / drama / meme).
5. Render Jinja2 template. Atomic write to /output/index.html
(.tmp + rename) so partial pages never get served. Per-edition
archive at /output/edition-YYYY-MM-DD-{am,pm}.html.
Two containers:
news-digest-worker python:3.12-alpine + busybox crond
news-digest-web nginx:alpine, port 8181, homepage card via
docker labels (group=News, fits next to Miniflux)
Both bind-mount /opt/docker/data/news-digest as /output and
/usr/share/nginx/html respectively.
Aesthetic — operations-center chrome (Australis cool-mono palette,
JetBrains Mono UPPERCASE eyebrows, mdi-glyph anchor) wrapping
editorial-serif news content (Fraunces variable serif w/ optical
sizes). Two type families that wouldn't normally meet, intentionally
combined: chrome says 'filed at 0800 from the bridge'; headlines say
'this is news, read it like news.' Sticky aurora-glow rule under the
masthead is the only sanctioned Australis gradient.
Edition stamp (AM/PM in big mono Australis-yellow) is the signature
piece — establishes the twice-daily rhythm at a glance.
All filtering + LLM + scheduling knobs in .env. Subreddit list is
implicit (read from Miniflux), so adding a sub = subscribing in
Miniflux, no config edit on this stack.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# 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
|
||||
LLAMA_SWAP_MODEL=qwen3.5-35-a3b
|
||||
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
|
||||
|
||||
# ── 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
|
||||
@@ -0,0 +1,28 @@
|
||||
# news-digest worker — twice-daily curated briefing generator.
|
||||
#
|
||||
# Runs cron internally (alpine's busybox crond) and a one-shot
|
||||
# digest.py per fire. Bind-mounted /output is shared with the
|
||||
# news-digest-web nginx container that serves the HTML.
|
||||
|
||||
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
|
||||
|
||||
RUN pip install --no-cache-dir requests jinja2
|
||||
|
||||
WORKDIR /app
|
||||
COPY digest.py /app/digest.py
|
||||
COPY templates /app/templates
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY crontab /etc/crontabs/root
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Sentinel + first-run output dir
|
||||
VOLUME /output
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
@@ -0,0 +1,139 @@
|
||||
# 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 `qwen3.5-35-a3b` on llama-swap (one
|
||||
batched call per source — efficient) for a one-sentence tl;dr +
|
||||
single-word tag (news / tutorial / release / discussion / question
|
||||
/ showcase / drama / meme / other).
|
||||
5. Renders an HTML page styled with Australis tokens + Fraunces
|
||||
serif headlines.
|
||||
6. Static page is served by a tiny `nginx:alpine`. Cron writes
|
||||
`/output/index.html` atomically; nginx serves whichever copy is
|
||||
there.
|
||||
|
||||
Two editions per day: 8am and 8pm local. Plus per-edition archives
|
||||
at `/edition-YYYY-MM-DD-{am,pm}.html`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two containers, both on `traefik-net`, sharing a bind-mounted
|
||||
output dir:
|
||||
|
||||
```
|
||||
news-digest-worker (python:3.12-alpine + cron)
|
||||
├── 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 served from /output (copied at deploy)
|
||||
|
||||
news-digest-web (nginx:alpine)
|
||||
└── serves /output as / on host port 8181
|
||||
└── homepage card via container labels (group=News)
|
||||
```
|
||||
|
||||
## 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;
|
||||
`qwen3.5-35-a3b` is the current default.
|
||||
|
||||
## Forcing a fresh digest now
|
||||
|
||||
```bash
|
||||
ssh ana-docker 'docker exec news-digest-worker python3 /app/digest.py'
|
||||
```
|
||||
|
||||
Runs the full pipeline once, ignoring cron. Useful after changing
|
||||
filtering knobs or adding 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.
|
||||
@@ -0,0 +1,76 @@
|
||||
# news-digest — twice-daily LLM-curated briefing.
|
||||
#
|
||||
# Two containers in this stack:
|
||||
#
|
||||
# news-digest-worker — python + cron, runs digest.py at 0800/2000
|
||||
# local, writes /output/index.html.
|
||||
# news-digest-web — tiny nginx serving the same /output dir.
|
||||
# Homepage card lives on this container's
|
||||
# labels.
|
||||
#
|
||||
# Both bind-mount the same host dir so the worker writes and the
|
||||
# web container serves without IPC. Worker writes atomically
|
||||
# (.tmp + rename), so partial pages never get served.
|
||||
|
||||
services:
|
||||
news-digest-worker:
|
||||
image: local/news-digest:${NEWS_DIGEST_TAG:-v1}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: news-digest-worker
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=${NEWS_DIGEST_TZ:-America/Los_Angeles}
|
||||
- LLAMA_SWAP_URL=${LLAMA_SWAP_URL:-http://10.250.50.54:9292}
|
||||
- LLAMA_SWAP_MODEL=${LLAMA_SWAP_MODEL:-qwen3.5-35-a3b}
|
||||
- LLAMA_SWAP_TIMEOUT=${LLAMA_SWAP_TIMEOUT:-180}
|
||||
- MINIFLUX_URL=${MINIFLUX_URL:-http://miniflux:8080}
|
||||
- MINIFLUX_USER=${MINIFLUX_USER:-lkraven}
|
||||
- MINIFLUX_PASSWORD=${MINIFLUX_PASSWORD}
|
||||
- DIGEST_OUTPUT_DIR=/output
|
||||
- DIGEST_TEMPLATE_DIR=/app/templates
|
||||
- DIGEST_REDDIT_HOURS=${DIGEST_REDDIT_HOURS:-12}
|
||||
- DIGEST_MIN_SCORE=${DIGEST_MIN_SCORE:-50}
|
||||
- DIGEST_MIN_RATIO=${DIGEST_MIN_RATIO:-0.85}
|
||||
- DIGEST_MAX_PER_SUB=${DIGEST_MAX_PER_SUB:-8}
|
||||
- DIGEST_MINIFLUX_HOURS=${DIGEST_MINIFLUX_HOURS:-12}
|
||||
- DIGEST_MINIFLUX_MAX=${DIGEST_MINIFLUX_MAX:-8}
|
||||
- DIGEST_MINIFLUX_TECH_CATEGORY=${DIGEST_MINIFLUX_TECH_CATEGORY:-Tech aggregators}
|
||||
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: nginx:alpine
|
||||
container_name: news-digest-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- news-digest-worker
|
||||
ports:
|
||||
- "${NEWS_DIGEST_BIND:-0.0.0.0}:${NEWS_DIGEST_PORT}:80"
|
||||
volumes:
|
||||
- ${NEWS_DIGEST_OUTPUT_DIR}:/usr/share/nginx/html:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=News
|
||||
- homepage.name=Daily Digest
|
||||
- homepage.icon=mdi-newspaper-variant-outline
|
||||
- homepage.description=LLM-curated briefing across feeds, twice daily
|
||||
- homepage.href=http://10.250.50.70:${NEWS_DIGEST_PORT}
|
||||
|
||||
networks:
|
||||
tnet:
|
||||
name: traefik-net
|
||||
external: true
|
||||
@@ -0,0 +1,4 @@
|
||||
# news-digest fires twice a day at 0800 and 2000 local (TZ from env).
|
||||
# Output written atomically to /output/index.html — nginx serves it
|
||||
# without restart.
|
||||
0 8,20 * * * /usr/local/bin/run-digest.sh
|
||||
@@ -0,0 +1,388 @@
|
||||
"""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 json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
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
|
||||
|
||||
# ── 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"))
|
||||
|
||||
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)
|
||||
|
||||
# ── 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)
|
||||
|
||||
# ── 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=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 _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=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 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": ONE sentence, 25 words max, capturing the substantive point. Lead with a verb. No "this post discusses". No "a user shares".
|
||||
- "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 an item already in this batch, set "tldr" to "" and "tag" to "skip".
|
||||
|
||||
Output ONLY the JSON array. No prose, no markdown fence.
|
||||
|
||||
POSTS:
|
||||
{posts_json}
|
||||
"""
|
||||
|
||||
def summarize_source(src: Source) -> None:
|
||||
if not src.items:
|
||||
return
|
||||
posts_json = json.dumps([
|
||||
{"id": it.id, "title": it.title, "body": it.body[:600], "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}")
|
||||
try:
|
||||
r = S.post(
|
||||
f"{LLAMA_SWAP_URL.rstrip('/')}/v1/chat/completions",
|
||||
json={
|
||||
"model": LLAMA_SWAP_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": SUMMARIZE_SYSTEM},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 2000,
|
||||
},
|
||||
timeout=LLAMA_SWAP_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
content = r.json()["choices"][0]["message"]["content"].strip()
|
||||
# Some models wrap JSON in ```...``` even when told not to.
|
||||
content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.M).strip()
|
||||
mapped = {x.get("id"): x for x in json.loads(content)}
|
||||
except Exception as e:
|
||||
log(f" ! llm failed for {src.name}: {e!r} — keeping raw titles")
|
||||
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")
|
||||
|
||||
# ── render ───────────────────────────────────────────────────────────
|
||||
|
||||
def render(reddit_sources: list[Source], tech_sources: list[Source],
|
||||
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"
|
||||
return template.render(
|
||||
reddit_sources=[s for s in reddit_sources if s.items],
|
||||
tech_sources=[s for s in tech_sources if s.items],
|
||||
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})")
|
||||
|
||||
# ── 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 3: 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 4: summarizing each source via llama-swap")
|
||||
for src in reddit_sources + tech_sources:
|
||||
summarize_source(src)
|
||||
|
||||
log("phase 5: rendering")
|
||||
html = render(reddit_sources, tech_sources, now_local)
|
||||
write_output(html, now_local)
|
||||
log("done")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/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
|
||||
|
||||
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
|
||||
|
||||
# Tee crontab into the place busybox expects (already done in image
|
||||
# via COPY) and run crond in foreground. -L /dev/stdout sends cron
|
||||
# stdout/stderr to docker logs.
|
||||
echo "[entrypoint] starting crond (fires per /etc/crontabs/root)"
|
||||
exec crond -f -L /dev/stdout -l 8
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/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')] /"
|
||||
@@ -0,0 +1,202 @@
|
||||
<!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="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
{# ────── header ────── #}
|
||||
<header class="masthead" role="banner">
|
||||
<div class="masthead-inner">
|
||||
<div class="masthead-brand">
|
||||
<span class="brand-glyph" aria-hidden="true">◢</span>
|
||||
<span class="brand-mark">DAILY DIGEST</span>
|
||||
</div>
|
||||
<div class="masthead-edition">
|
||||
<span class="edition-num">№ {{ generated_at.strftime("%j") }}</span>
|
||||
<span class="edition-stamp">{{ edition|upper }} EDITION</span>
|
||||
</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">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 reddit_sources %}
|
||||
<li><a href="#reddit"><span class="jump-num">01</span> REDDIT</a></li>
|
||||
{% endif %}
|
||||
{% if tech_sources %}
|
||||
<li><a href="#tech"><span class="jump-num">02</span> TECH FEEDS</a></li>
|
||||
{% endif %}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<main class="brief">
|
||||
|
||||
{# ────── REDDIT SECTION ────── #}
|
||||
{% if reddit_sources %}
|
||||
<section id="reddit" class="desk">
|
||||
<header class="desk-head">
|
||||
<span class="desk-num">01</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_sources|sum(attribute='items')|length }} items</span>
|
||||
</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 }}">
|
||||
<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">
|
||||
<a class="item-link" href="{{ it.permalink }}" target="_blank" rel="noopener">
|
||||
comments
|
||||
</a>
|
||||
{% if it.url and it.url != it.permalink %}
|
||||
<span class="sep">·</span>
|
||||
<a class="item-link" href="{{ it.url }}" target="_blank" rel="noopener">
|
||||
{{ it.url|domain }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<span class="sep">·</span>
|
||||
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="item-meta">u/{{ it.author }}</span>
|
||||
</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">02</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_sources|sum(attribute='items')|length }} items</span>
|
||||
</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 }}">
|
||||
<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 %}
|
||||
<a class="item-link" href="{{ it.url }}" target="_blank" rel="noopener">
|
||||
{{ it.url|domain }}
|
||||
</a>
|
||||
<span class="sep">·</span>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
</footer>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if not reddit_sources and not tech_sources %}
|
||||
<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 %}
|
||||
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,497 @@
|
||||
/* -------------------------------------------------------------------------
|
||||
news-digest — daily intel briefing.
|
||||
|
||||
Aesthetic: operations-center chrome (Australis cool-mono palette,
|
||||
JetBrains Mono UPPERCASE eyebrows, mdi-glyph anchor) wrapping
|
||||
editorial-serif news content (Fraunces). Two type families that
|
||||
wouldn't normally meet, intentionally combined: the chrome says
|
||||
"filed at 0800 from the bridge"; the headlines say "this is news,
|
||||
read it like news."
|
||||
|
||||
Type:
|
||||
Display (headlines): Fraunces — variable serif w/ optical sizes
|
||||
Body (summaries): Inter — same as Australis, optimized for screens
|
||||
Mono (chrome): JetBrains Mono — eyebrows / metadata / chips
|
||||
|
||||
Color: pure Australis Ice + Sea + Aurora. Score chips lean
|
||||
--aus-blue (info); release tags --aus-green; drama --aus-red
|
||||
(sparingly). No warm accents — Australis is cool-only by design.
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700;9..144,800&display=swap');
|
||||
|
||||
:root {
|
||||
/* Australis palette — vendored subset */
|
||||
--aus-black: #222531;
|
||||
--aus-white: #a9bcc3;
|
||||
--aus-bright-white: #cce7ec;
|
||||
--aus-bright-black: #373b46;
|
||||
--aus-dark-30: #414751;
|
||||
--aus-dark-40: #565f69;
|
||||
--aus-dark-50: #6e7882;
|
||||
--aus-dark-60: #86929d;
|
||||
--aus-bright-70: #9daeb6;
|
||||
--aus-bright-80: #b3cbcf;
|
||||
--aus-blue: #6388d8;
|
||||
--aus-bright-blue: #a4c4ff;
|
||||
--aus-cyan: #00b1a8;
|
||||
--aus-bright-cyan: #42dcd1;
|
||||
--aus-green: #16b866;
|
||||
--aus-bright-green: #51e08a;
|
||||
--aus-yellow: #e1c631;
|
||||
--aus-bright-yellow:#ffe14e;
|
||||
--aus-red: #ff491a;
|
||||
--aus-magenta: #9d78ff;
|
||||
|
||||
--bg: var(--aus-black);
|
||||
--bg-elev: var(--aus-bright-black);
|
||||
--surface: #2a2e3a;
|
||||
--surface-hi: var(--aus-dark-30);
|
||||
--rule: var(--aus-dark-30);
|
||||
--rule-bright: var(--aus-dark-40);
|
||||
--fg: var(--aus-bright-white);
|
||||
--fg-dim: var(--aus-bright-70);
|
||||
--fg-faint: var(--aus-dark-50);
|
||||
--accent: var(--aus-bright-cyan);
|
||||
--accent-dim: var(--aus-cyan);
|
||||
|
||||
/* Type families */
|
||||
--font-display: "Fraunces", "Times New Roman", Georgia, serif;
|
||||
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
|
||||
|
||||
/* Scale + chrome */
|
||||
--pad-x: 24px;
|
||||
--content-w: 880px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 16px/1.55 var(--font-sans);
|
||||
font-feature-settings: "ss01" 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a:hover { color: var(--accent); }
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
masthead — full-width banner; the "I am a news edition" statement
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
.masthead {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
|
||||
.masthead-inner {
|
||||
max-width: var(--content-w);
|
||||
margin: 0 auto;
|
||||
padding: 36px var(--pad-x) 24px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 32px;
|
||||
row-gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.masthead-brand {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 2;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.brand-glyph {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 12px rgba(66, 220, 209, .35);
|
||||
}
|
||||
.brand-mark {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .18em;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.masthead-edition {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 2;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
gap: 18px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.edition-num {
|
||||
color: var(--fg-faint);
|
||||
font-size: 11px;
|
||||
letter-spacing: .14em;
|
||||
}
|
||||
.edition-stamp {
|
||||
/* THE statement piece — large mono in Australis yellow.
|
||||
"This is the morning/evening brief, filed officially." */
|
||||
font-family: var(--font-mono);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .14em;
|
||||
color: var(--aus-yellow);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--aus-yellow);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.masthead-meta {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2 / 3;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, auto);
|
||||
gap: 4px 32px;
|
||||
align-items: baseline;
|
||||
font-family: var(--font-mono);
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
.meta-label {
|
||||
color: var(--fg-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .14em;
|
||||
}
|
||||
.meta-value {
|
||||
color: var(--fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The aurora glow rule — sanctioned Australis gradient, used once. */
|
||||
.aurora-rule {
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
var(--aus-bright-blue) 25%,
|
||||
var(--aus-bright-cyan) 50%,
|
||||
var(--aus-bright-green) 75%,
|
||||
transparent 100%);
|
||||
opacity: .65;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
jump nav
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
.jumpnav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
background: rgba(34, 37, 49, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.jumpnav ol {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 12px var(--pad-x);
|
||||
max-width: var(--content-w);
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.jumpnav a {
|
||||
color: var(--fg-dim);
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.jumpnav a:hover { color: var(--fg); }
|
||||
.jump-num { color: var(--fg-faint); font-size: 10px; }
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
main brief
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
.brief {
|
||||
max-width: var(--content-w);
|
||||
margin: 0 auto;
|
||||
padding: 32px var(--pad-x) 64px;
|
||||
}
|
||||
|
||||
.desk + .desk { margin-top: 64px; }
|
||||
|
||||
.desk-head {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: baseline;
|
||||
column-gap: 16px;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--rule-bright);
|
||||
}
|
||||
.desk-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .14em;
|
||||
color: var(--aus-yellow);
|
||||
padding-top: 4px; /* aligns visually with the serif baseline */
|
||||
}
|
||||
.desk-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 38px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
margin: 0;
|
||||
color: var(--fg);
|
||||
font-variation-settings: "opsz" 144;
|
||||
}
|
||||
.desk-sub {
|
||||
/* Sub-line hugs the title baseline */
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: .08em;
|
||||
color: var(--fg-faint);
|
||||
text-transform: uppercase;
|
||||
grid-column: 2 / 3;
|
||||
align-self: end;
|
||||
padding-bottom: 6px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.desk-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: .08em;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
per-source block
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
.source + .source { margin-top: 36px; }
|
||||
|
||||
.source-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.source-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
border-bottom: 1px dashed var(--accent-dim);
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
.source-name:hover { color: var(--aus-bright-cyan); }
|
||||
.source-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: .14em;
|
||||
color: var(--fg-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
item — the news card
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
.items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
column-gap: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.item:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.item-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .06em;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.chip-score { color: var(--aus-blue); }
|
||||
.chip-comments { color: var(--fg-dim); border-color: var(--rule-bright); }
|
||||
.chip-tag {
|
||||
font-size: 9.5px;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.chip-tag-news { color: var(--aus-bright-blue); }
|
||||
.chip-tag-tutorial { color: var(--aus-bright-cyan); }
|
||||
.chip-tag-release { color: var(--aus-bright-green); }
|
||||
.chip-tag-discussion{ color: var(--fg-dim); }
|
||||
.chip-tag-question { color: var(--aus-yellow); }
|
||||
.chip-tag-showcase { color: var(--aus-magenta); }
|
||||
.chip-tag-drama { color: var(--aus-red); }
|
||||
.chip-tag-meme { color: var(--fg-faint); }
|
||||
|
||||
.item-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--fg);
|
||||
font-variation-settings: "opsz" 36;
|
||||
}
|
||||
.item-title a { color: inherit; }
|
||||
.item-title a:hover { color: var(--accent); }
|
||||
|
||||
.item-tldr {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.item-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .06em;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.item-link {
|
||||
color: var(--fg-dim);
|
||||
border-bottom: 1px dotted var(--fg-faint);
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
.item-link:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.item-meta { font-variant-numeric: tabular-nums; }
|
||||
.sep { color: var(--rule-bright); }
|
||||
|
||||
/* Visual de-emphasize purely meme/skip items if any slip through. */
|
||||
.item[data-tag="meme"] { opacity: .58; }
|
||||
.item[data-tag="other"] { opacity: .85; }
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
empty state + colophon
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
.desk.empty {
|
||||
text-align: center;
|
||||
padding: 72px 24px;
|
||||
border: 1px dashed var(--rule);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.empty-msg {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--fg-faint);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.empty-msg code {
|
||||
color: var(--fg-dim);
|
||||
font-family: inherit;
|
||||
background: var(--surface);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
border-top: 1px solid var(--rule);
|
||||
background: var(--bg-elev);
|
||||
padding: 24px var(--pad-x);
|
||||
margin-top: 64px;
|
||||
}
|
||||
.colophon-inner {
|
||||
max-width: var(--content-w);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
.colophon-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.colophon-block .meta-label { color: var(--fg-faint); font-size: 10px; }
|
||||
.colophon-block .meta-value { color: var(--fg-dim); font-size: 11px; }
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
responsive
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
:root { --pad-x: 16px; }
|
||||
.masthead-inner {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
.masthead-brand,
|
||||
.masthead-edition,
|
||||
.masthead-meta { grid-column: 1 / 2; }
|
||||
.masthead-edition { justify-content: flex-start; }
|
||||
.edition-stamp { font-size: 18px; }
|
||||
.masthead-meta { grid-template-columns: 1fr; gap: 6px; }
|
||||
.desk-title { font-size: 28px; }
|
||||
.desk-head { grid-template-columns: auto 1fr; row-gap: 6px; }
|
||||
.desk-sub { grid-column: 1 / 3; padding-left: 0; }
|
||||
.desk-count { display: none; }
|
||||
.item { grid-template-columns: 64px 1fr; column-gap: 14px; }
|
||||
.item-rail { padding-top: 4px; }
|
||||
.chip { font-size: 9.5px; padding: 1px 6px; }
|
||||
.item-title { font-size: 18px; }
|
||||
.item-tldr { font-size: 14px; }
|
||||
.colophon-inner { grid-template-columns: 1fr; }
|
||||
}
|
||||
Reference in New Issue
Block a user