Files
upbeatBytes/goodnews/static/index.html
T
thejayman77 95195daff8 Track 3: read-only source preview (vet a feed before adding)
- feeds.preview_feed(): fetch + score a sample WITHOUT persisting; returns
  freshness, acceptance rate, cortisol/ragebait/PR averages, and example
  accepted/rejected items. With an LLM client it also returns topic/flavor mix
  and the model's (accurate) acceptance view.
- CLI 'preview-source URL [--sample] [--classify]'.
- API 'GET /api/source-preview?url=&sample=&classify=' with an http(s)-only
  guard (SSRF note left for go-public hardening).
- Site 'Suggest a source' panel with Quick check (heuristic, instant) and Deep
  check (model, accurate), rendered DOM-safely.
- Tests: network-free preview_feed tests via monkeypatched fetch (45 total).
- README documents the command, endpoint, and updated roadmap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:37:34 +00:00

464 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>goodNews — calm, constructive news</title>
<style>
:root {
--bg: #f7f5f0; --card: #fff; --ink: #1f2a24; --muted: #6b7770;
--accent: #2f7d5b; --accent-soft: #e4efe8; --line: #e3e0d8;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
header {
padding: 28px 20px 18px; text-align: center; border-bottom: 1px solid var(--line);
background: var(--card); position: relative;
}
header h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.02em; }
header h1 span { color: var(--accent); }
header p { margin: 6px 0 0; color: var(--muted); font-size: 0.95rem; }
.calm-btn {
position: absolute; top: 20px; right: 20px; border: 1px solid var(--line);
background: var(--card); border-radius: 999px; padding: 6px 14px; cursor: pointer;
font-size: 0.85rem; color: var(--ink);
}
.calm-btn.on { background: var(--accent); color: #fff; border-color: var(--accent); }
main { max-width: 760px; margin: 0 auto; padding: 20px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0 18px; }
.chip {
border: 1px solid var(--line); background: var(--card); color: var(--ink);
border-radius: 999px; padding: 6px 13px; font-size: 0.85rem; cursor: pointer;
transition: all .12s ease;
}
.chip:hover { border-color: var(--accent); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.chip-group-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin: 4px 0; width: 100%; }
.section-title { font-size: 0.95rem; color: var(--muted); margin: 22px 0 10px;
text-transform: uppercase; letter-spacing: .06em; }
article {
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 12px;
}
article .meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
font-size: 0.78rem; color: var(--muted); margin-bottom: 6px; }
.tag { background: var(--accent-soft); color: var(--accent); border-radius: 6px;
padding: 1px 8px; font-weight: 600; }
article h3 { margin: 0 0 6px; font-size: 1.08rem; line-height: 1.35; }
article h3 a { color: var(--ink); text-decoration: none; }
article h3 a:hover { color: var(--accent); }
article p.desc { margin: 0 0 8px; color: #44514a; font-size: 0.92rem; }
article .why { font-size: 0.8rem; color: var(--muted); font-style: italic; }
.rank-badge { background: var(--accent); color: #fff; border-radius: 50%;
width: 24px; height: 24px; display: inline-flex; align-items: center;
justify-content: center; font-size: 0.8rem; font-weight: 700; }
.actions { margin-top: 10px; display: flex; gap: 14px; }
.actions button {
background: none; border: none; padding: 0; cursor: pointer; font-size: 0.78rem;
color: var(--muted); border-bottom: 1px dotted var(--line);
}
.actions button:hover { color: var(--accent); border-bottom-color: var(--accent); }
.empty { color: var(--muted); text-align: center; padding: 30px; }
/* Calm settings panel */
.panel {
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 18px; display: none;
}
.panel.open { display: block; }
.panel h2 { font-size: 1rem; margin: 0 0 4px; }
.panel .hint { color: var(--muted); font-size: 0.82rem; margin: 0 0 12px; }
.panel .group { margin-bottom: 12px; }
.panel .group-label { font-size: 0.74rem; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); margin-bottom: 6px; }
.pill {
display: inline-flex; align-items: center; gap: 6px; background: var(--accent-soft);
color: var(--accent); border-radius: 999px; padding: 3px 6px 3px 11px; font-size: 0.82rem;
margin: 0 6px 6px 0;
}
.pill button { background: none; border: none; cursor: pointer; color: var(--accent);
font-size: 0.95rem; line-height: 1; padding: 0 2px; }
.panel input[type=text] { border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px;
font-size: 0.88rem; width: 60%; }
.panel .addbtn { margin-left: 6px; border: 1px solid var(--accent); background: var(--accent);
color: #fff; border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 0.85rem; }
.panel .reset { margin-top: 6px; background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 0.8rem; text-decoration: underline; }
.calm-note { color: var(--muted); font-size: 0.8rem; margin: -8px 0 14px; }
.addsource { background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 18px; }
.addsource .hint { color: var(--muted); font-size: 0.82rem; margin: 0 0 10px; }
.addsource-row { display: flex; gap: 8px; flex-wrap: wrap; }
.addsource input { flex: 1 1 240px; border: 1px solid var(--line); border-radius: 8px;
padding: 7px 11px; font-size: 0.9rem; }
.addbtn { border: 1px solid var(--accent); background: var(--accent); color: #fff;
border-radius: 8px; padding: 7px 14px; cursor: pointer; font-size: 0.85rem; }
.addbtn.ghost { background: none; color: var(--accent); }
.preview-metrics { margin-top: 14px; }
.preview-metrics .stat { font-size: 0.9rem; margin: 2px 0; }
.preview-metrics .label { color: var(--muted); }
.preview-metrics ul { margin: 4px 0 10px; padding-left: 18px; font-size: 0.86rem; }
footer { text-align: center; color: var(--muted); font-size: 0.78rem; padding: 20px; }
footer a { color: var(--accent); }
</style>
</head>
<body>
<header>
<h1>good<span>News</span></h1>
<p>Calm, constructive news worth your attention — and nothing that isn't.</p>
<button id="calm-toggle" class="calm-btn">Calm filters</button>
</header>
<main>
<div id="panel" class="panel">
<h2>Calm filters</h2>
<p class="hint">Your boundaries, kept on this device. Nothing is sent anywhere or tied to an account.</p>
<div class="group">
<div class="group-label">Paused for now</div>
<div id="panel-pauses"></div>
</div>
<div class="group">
<div class="group-label">Always hidden</div>
<div id="panel-mutes"></div>
</div>
<div class="group">
<div class="group-label">Avoid words &amp; phrases</div>
<div id="panel-terms"></div>
<input id="term-input" type="text" placeholder="e.g. election, stock market" />
<button id="term-add" class="addbtn">Add</button>
</div>
<button id="reset" class="reset">Reset all calm filters</button>
</div>
<div id="calm-note" class="calm-note"></div>
<div id="brief-block">
<div class="section-title">Five Good Things</div>
<div id="brief"><div class="empty">Loading…</div></div>
</div>
<div class="section-title">Browse by category</div>
<div id="topic-chips" class="chips"></div>
<div id="flavor-chips" class="chips"></div>
<div id="feed"></div>
<div class="section-title">Suggest a source</div>
<div class="addsource">
<p class="hint">Paste a feed URL to see how calm it is before anyone adds it. Nothing is saved — this just samples and scores recent items.</p>
<div class="addsource-row">
<input id="src-url" type="text" placeholder="https://example.com/feed/" />
<button id="src-quick" class="addbtn">Quick check</button>
<button id="src-deep" class="addbtn ghost">Deep check (uses model)</button>
</div>
<div id="src-result"></div>
</div>
</main>
<footer>
goodNews · metadata &amp; links only, no stored articles ·
<a href="/docs">API</a>
</footer>
<script>
const PREFS_KEY = "goodnews:prefs";
const browse = { topic: null, flavor: null };
const el = (id) => document.getElementById(id);
// ---- preferences (the canonical FilterPrefs shape) ----
function blankPrefs() {
return { include_topics: [], include_flavors: [], mute_topics: [],
mute_flavors: [], avoid_terms: [], pauses: [] };
}
function loadPrefs() {
let p;
try { p = JSON.parse(localStorage.getItem(PREFS_KEY)) || {}; } catch { p = {}; }
const prefs = Object.assign(blankPrefs(), p);
// prune expired pauses so localStorage stays tidy
const now = Date.now();
prefs.pauses = (prefs.pauses || []).filter(x => x && x.until && new Date(x.until).getTime() > now);
return prefs;
}
let prefs = loadPrefs();
function savePrefs() { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); }
function prefsActive() {
return prefs.mute_topics.length || prefs.mute_flavors.length ||
prefs.avoid_terms.length || prefs.pauses.length ||
prefs.include_topics.length || prefs.include_flavors.length;
}
function prefsParam() {
return prefsActive() ? "&prefs=" + encodeURIComponent(JSON.stringify(prefs)) : "";
}
function hoursFromNow(h) { return new Date(Date.now() + h * 3600e3).toISOString(); }
// ---- gentle actions: the humane ladder ----
function notToday(topic) { // pause this topic 24h
if (!topic) return;
prefs.pauses = prefs.pauses.filter(p => !(p.kind === "topic" && p.value === topic));
prefs.pauses.push({ kind: "topic", value: topic, until: hoursFromNow(24) });
savePrefs(); refreshAll();
}
function lessLikeThis(flavor) { // ease off this flavor for a few days
if (!flavor) return;
prefs.pauses = prefs.pauses.filter(p => !(p.kind === "flavor" && p.value === flavor));
prefs.pauses.push({ kind: "flavor", value: flavor, until: hoursFromNow(72) });
savePrefs(); refreshAll();
}
function alwaysHide(topic) { // standing mute, undoable in the panel
if (!topic || prefs.mute_topics.includes(topic)) return;
prefs.mute_topics.push(topic);
savePrefs(); refreshAll();
}
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
// Build cards with the DOM API (textContent) rather than HTML strings, so
// feed-supplied text can never inject markup even if upstream cleaning misses.
const node = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
};
function articleCard(a, showRank) {
const article = node("article");
const meta = node("div", "meta");
if (showRank && a.rank) meta.append(node("span", "rank-badge", a.rank));
[a.topic, a.flavor].filter(Boolean).forEach(t => meta.append(node("span", "tag", t)));
meta.append(node("span", null, a.source));
if (a.published_at) meta.append(node("span", null, "· " + a.published_at.slice(0, 10)));
article.append(meta);
const h3 = node("h3");
const link = node("a", null, a.title);
link.href = (typeof a.url === "string" && /^https?:\/\//.test(a.url)) ? a.url : "#";
link.target = "_blank"; link.rel = "noopener";
h3.append(link);
article.append(h3);
if (a.description) article.append(node("p", "desc", a.description));
if (a.reason_text) article.append(node("div", "why", a.reason_text));
const acts = node("div", "actions");
const btn = (label, act, key, val) => {
const b = node("button", null, label);
b.dataset.act = act;
if (key) b.dataset[key] = val;
return b;
};
if (a.topic) acts.append(btn("Not today", "notToday", "topic", a.topic));
if (a.flavor) acts.append(btn("Less like this", "lessLikeThis", "flavor", a.flavor));
if (a.topic) acts.append(btn("Always hide " + a.topic, "alwaysHide", "topic", a.topic));
article.append(acts);
return article;
}
function renderList(target, items, showRank) {
target.replaceChildren();
if (!items.length) {
target.append(node("div", "empty", "Nothing here right now — try easing a filter."));
return;
}
items.forEach(a => target.append(articleCard(a, showRank)));
}
// delegated clicks for the per-article gentle actions
function wireActions(container) {
container.addEventListener("click", (e) => {
const b = e.target.closest("button[data-act]");
if (!b) return;
const act = b.dataset.act;
if (act === "notToday") notToday(b.dataset.topic);
else if (act === "lessLikeThis") lessLikeThis(b.dataset.flavor);
else if (act === "alwaysHide") alwaysHide(b.dataset.topic);
});
}
async function loadBrief() {
try {
const b = await getJSON(`/api/brief?limit=5${prefsParam()}`);
if (b.title) el("brief-block").querySelector(".section-title").textContent = b.title;
renderList(el("brief"), b.items, true);
} catch { el("brief").innerHTML = `<div class="empty">No brief yet.</div>`; }
}
async function loadFeed() {
const params = new URLSearchParams({ limit: "30" });
if (browse.topic) params.set("topic", browse.topic);
if (browse.flavor) params.set("flavor", browse.flavor);
const f = await getJSON(`/api/feed?${params.toString()}${prefsParam()}`);
renderList(el("feed"), f.items, false);
}
function chip(label, active, onClick) {
const c = document.createElement("button");
c.className = "chip" + (active ? " active" : "");
c.textContent = label;
c.onclick = onClick;
return c;
}
function pill(label, onRemove) {
const span = document.createElement("span");
span.className = "pill";
span.append(label);
const x = document.createElement("button");
x.textContent = "×"; x.title = "remove"; x.onclick = onRemove;
span.append(x);
return span;
}
function renderPanel() {
const pauses = el("panel-pauses"); pauses.innerHTML = "";
if (!prefs.pauses.length) pauses.append(Object.assign(document.createElement("span"),
{ className: "hint", textContent: "Nothing paused." }));
prefs.pauses.forEach((p, i) => {
const when = new Date(p.until).toLocaleString();
pauses.append(pill(`${p.kind}: ${p.value} (until ${when})`, () => {
prefs.pauses.splice(i, 1); savePrefs(); refreshAll();
}));
});
const mutes = el("panel-mutes"); mutes.innerHTML = "";
const allMutes = [...prefs.mute_topics.map(v => ["topic", v]), ...prefs.mute_flavors.map(v => ["flavor", v])];
if (!allMutes.length) mutes.append(Object.assign(document.createElement("span"),
{ className: "hint", textContent: "Nothing hidden." }));
allMutes.forEach(([kind, v]) => {
mutes.append(pill(`${kind}: ${v}`, () => {
const arr = kind === "topic" ? prefs.mute_topics : prefs.mute_flavors;
arr.splice(arr.indexOf(v), 1); savePrefs(); refreshAll();
}));
});
const terms = el("panel-terms"); terms.innerHTML = "";
if (!prefs.avoid_terms.length) terms.append(Object.assign(document.createElement("span"),
{ className: "hint", textContent: "No avoided words yet." }));
prefs.avoid_terms.forEach((t, i) => terms.append(pill(t, () => {
prefs.avoid_terms.splice(i, 1); savePrefs(); refreshAll();
})));
}
function renderNote() {
const n = prefsActive()
? "Calm filters on — your feed is personalized on this device."
: "";
el("calm-note").textContent = n;
el("calm-toggle").classList.toggle("on", !!prefsActive());
}
async function refreshAll() {
renderPanel(); renderNote();
await Promise.all([loadBrief(), loadFeed(), refreshCounts()]);
}
async function refreshCounts() {
// counts reflect the same filters so chip-able categories match the feed
try { await getJSON(`/api/category-counts?${prefsParam().slice(1)}`); } catch {}
}
async function init() {
wireActions(el("feed"));
wireActions(el("brief"));
const cats = await getJSON("/api/categories");
const tc = el("topic-chips");
tc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Topic" }));
tc.appendChild(chip("all", true, () => setTopic(null)));
cats.topics.forEach(t => tc.appendChild(chip(t.key, false, () => setTopic(t.key))));
const fc = el("flavor-chips");
fc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Flavor" }));
fc.appendChild(chip("all", true, () => setFlavor(null)));
cats.flavors.forEach(f => fc.appendChild(chip(f.key, false, () => setFlavor(f.key))));
// source preview controls
el("src-quick").onclick = () => previewSource(false);
el("src-deep").onclick = () => previewSource(true);
el("src-url").addEventListener("keydown", (e) => { if (e.key === "Enter") previewSource(false); });
// panel controls
el("calm-toggle").onclick = () => el("panel").classList.toggle("open");
el("term-add").onclick = addTerm;
el("term-input").addEventListener("keydown", (e) => { if (e.key === "Enter") addTerm(); });
el("reset").onclick = () => { prefs = blankPrefs(); savePrefs(); refreshAll(); };
refreshAll();
}
async function previewSource(deep) {
const url = el("src-url").value.trim();
const out = el("src-result");
out.replaceChildren();
if (!/^https?:\/\//i.test(url)) {
out.append(node("div", "empty", "Enter a URL starting with http:// or https://"));
return;
}
out.append(node("div", "empty", deep ? "Deep checking with the model — this can take a moment…" : "Checking…"));
try {
const p = await getJSON(`/api/source-preview?url=${encodeURIComponent(url)}&classify=${deep}`);
renderPreview(out, p);
} catch (e) {
out.replaceChildren(node("div", "empty", "Could not read that feed."));
}
}
function renderPreview(out, p) {
out.replaceChildren();
const box = node("div", "preview-metrics");
const stat = (label, value) => {
const d = node("div", "stat");
d.append(node("span", "label", label + " "));
d.append(document.createTextNode(String(value)));
box.append(d);
};
stat("Mode:", p.classified ? "model (accurate)" : "heuristic (quick, conservative)");
stat("Acceptance:", `${Math.round(p.acceptance_rate * 100)}% (${p.accepted}/${p.sampled})`);
stat("Freshness:", `${p.recent_7d}/${p.sampled} in last 7 days · newest ${(p.newest_published||"unknown").slice(0,10)}`);
stat("Calm averages:", `cortisol ${p.avg_cortisol} · ragebait ${p.avg_ragebait} · PR ${p.avg_pr_risk}`);
const mix = (m) => Object.entries(m).map(([k, v]) => `${k} ${v}`).join(" · ") || "—";
if (p.classified) {
stat("Topics:", mix(p.topic_mix));
stat("Flavors:", mix(p.flavor_mix));
}
if (p.examples_accepted.length) {
box.append(node("div", "stat label", "Would surface:"));
const ul = node("ul");
p.examples_accepted.forEach(t => ul.append(node("li", null, t)));
box.append(ul);
}
if (p.examples_rejected.length) {
box.append(node("div", "stat label", "Would skip:"));
const ul = node("ul");
p.examples_rejected.forEach(e => ul.append(node("li", null, e.title)));
box.append(ul);
}
out.append(box);
}
function addTerm() {
const v = el("term-input").value.trim();
if (v && !prefs.avoid_terms.includes(v)) { prefs.avoid_terms.push(v); savePrefs(); }
el("term-input").value = "";
refreshAll();
}
function refreshActive(containerId, value) {
[...el(containerId).querySelectorAll(".chip")].forEach(c => {
c.classList.toggle("active", c.textContent === (value || "all"));
});
}
function setTopic(t) { browse.topic = t; refreshActive("topic-chips", t); loadFeed(); }
function setFlavor(f) { browse.flavor = f; refreshActive("flavor-chips", f); loadFeed(); }
init();
</script>
</body>
</html>