95195daff8
- 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>
464 lines
20 KiB
HTML
464 lines
20 KiB
HTML
<!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 & 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 & 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>
|