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:
vh
2026-04-26 13:14:13 -07:00
parent 8351e0c325
commit 2e80e69ef5
11 changed files with 1560 additions and 0 deletions
+202
View File
@@ -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>
+497
View File
@@ -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; }
}