Add FastAPI web/API layer and static site

- queries.py: shared read-only query helpers (feed, brief, category counts)
  returning plain dicts, used by the API and available to the CLI.
- api.py: FastAPI service with Pydantic response models (the companion-app
  contract), CORS, and endpoints for categories, feed, brief, and health;
  mounts a static site at /.
- static/index.html: minimal dependency-free site rendering the daily five
  and topic/flavor category browsing.
- 'goodnews serve' command launches uvicorn (lazy import; core CLI stays
  pure-stdlib). Web deps live behind the optional [web] extra.
- Dockerfile + .dockerignore + build-system metadata so the service installs
  and deploys cleanly, with the DB mounted as a shared volume.
- README: web/API and deployment docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-30 13:51:07 +00:00
parent b33f58e3e5
commit 2f4bdf2d00
10 changed files with 624 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.venv
data
__pycache__
*.pyc
*.sqlite3
*.sqlite3-*
+1
View File
@@ -1,6 +1,7 @@
__pycache__/
*.py[cod]
.venv/
*.egg-info/
data/*.sqlite3
data/*.sqlite3-*
+21
View File
@@ -0,0 +1,21 @@
# goodNews web/API image.
#
# The SQLite database is NOT baked into the image — mount it at /data so the API
# and the ingestion CLI (run separately, e.g. via cron on the host) share one
# file. Build: docker build -t goodnews .
# Run: docker run -p 8000:8000 -v /srv/goodnews/data:/data goodnews
FROM python:3.13-slim
WORKDIR /app
# Install dependencies first for better layer caching.
COPY pyproject.toml README.md ./
COPY goodnews ./goodnews
RUN pip install --no-cache-dir ".[web]"
# API reads the database from here; mount a host dir or named volume.
ENV GOODNEWS_DB=/data/goodnews.sqlite3
VOLUME ["/data"]
EXPOSE 8000
CMD ["uvicorn", "goodnews.api:app", "--host", "0.0.0.0", "--port", "8000"]
+40
View File
@@ -64,6 +64,46 @@ For each article, the database stores:
- hashes used for dedupe
- heuristic scores and reason codes
## Web / API
The optional `web` extra adds a FastAPI service and a small static site that
consumes it. The same JSON API backs both the website and any future companion
app; its auto-generated OpenAPI docs at `/docs` are the shared contract.
```bash
pip install -e '.[web]' # or: .venv/bin/pip install -e '.[web]'
python3 -m goodnews serve # http://127.0.0.1:8000
python3 -m goodnews serve --host 0.0.0.0 # expose on the network
```
Endpoints:
- `GET /` — the static site (daily five + topic/flavor browsing)
- `GET /healthz` — liveness + scored-article count
- `GET /api/categories` — the topic/flavor taxonomy
- `GET /api/category-counts` — article counts per topic/flavor
- `GET /api/feed?topic=&flavor=&limit=&offset=` — ranked, filtered articles
- `GET /api/brief?date=&limit=` — a daily brief (latest if no date)
- `GET /api/brief-dates` — available brief dates
- `GET /docs` — interactive OpenAPI documentation
The ingestion CLI stays pure-stdlib; only the `web` extra pulls in FastAPI/uvicorn,
so the two halves can be deployed and upgraded independently.
## Deployment
The database is never baked into the image — the API and the ingestion CLI share
one SQLite file via a mounted volume. Run ingestion (`poll`, `classify`,
`build-brief`) on a schedule against the same file.
```bash
docker build -t goodnews .
docker run -p 8000:8000 -v /srv/goodnews/data:/data goodnews
```
`GOODNEWS_DB` controls the database path (defaults to `data/goodnews.sqlite3`).
Put a reverse proxy (Caddy/nginx) in front for TLS once a domain is attached.
## Next Steps
1. Run the poller for a few days and inspect which sources produce useful candidates.
+206
View File
@@ -0,0 +1,206 @@
"""FastAPI service for goodNews.
A read-only JSON API over the ingestion database, plus a small static site that
consumes it. The same endpoints back both the website and any future companion
app; the auto-generated OpenAPI docs at /docs are that shared contract.
Run with the bundled CLI: goodnews serve
Or directly: uvicorn goodnews.api:app --host 0.0.0.0 --port 8000
The database path comes from GOODNEWS_DB (falling back to the repo's data dir),
so the API and CLI always read the same file.
"""
from __future__ import annotations
import os
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import queries
from .db import connect, init_db
from .taxonomy import FLAVORS, TOPICS
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
STATIC_DIR = Path(__file__).resolve().parent / "static"
def db_path() -> Path:
return Path(os.environ.get("GOODNEWS_DB", str(DEFAULT_DB)))
@contextmanager
def get_conn():
conn = connect(db_path())
try:
yield conn
finally:
conn.close()
# --- Response models (the companion-app contract) ---------------------------
class Category(BaseModel):
key: str
description: str
class CategoriesResponse(BaseModel):
topics: list[Category]
flavors: list[Category]
class CategoryCount(BaseModel):
topic: str | None
flavor: str | None
count: int
class Article(BaseModel):
id: int
title: str
description: str | None = None
url: str
image_url: str | None = None
published_at: str | None = None
source: str
topic: str | None = None
flavor: str | None = None
accepted: bool
rank_score: int | None = None
reason_code: str | None = None
reason_text: str | None = None
model_name: str | None = None
rank: int | None = None # position within a brief, when applicable
@classmethod
def from_row(cls, row: dict) -> "Article":
return cls(
id=row["id"],
title=row["title"],
description=row.get("description"),
url=row["canonical_url"],
image_url=row.get("image_url"),
published_at=row.get("published_at"),
source=row["source_name"],
topic=row.get("topic"),
flavor=row.get("flavor"),
accepted=bool(row.get("accepted")),
rank_score=row.get("rank_score"),
reason_code=row.get("reason_code"),
reason_text=row.get("reason_text"),
model_name=row.get("model_name"),
rank=row.get("rank"),
)
class FeedResponse(BaseModel):
topic: str | None
flavor: str | None
count: int
items: list[Article]
class BriefResponse(BaseModel):
brief_date: str | None
title: str | None
items: list[Article]
# --- App --------------------------------------------------------------------
def create_app() -> FastAPI:
app = FastAPI(
title="goodNews API",
version="0.1.0",
description="Constructive, uplifting news — metadata and links only.",
)
# The website and companion app may live on other origins; allow them.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_headers=["*"],
)
@app.get("/healthz")
def healthz() -> dict:
with get_conn() as conn:
init_db(conn)
scored = conn.execute("SELECT COUNT(*) FROM article_scores").fetchone()[0]
return {"status": "ok", "scored_articles": scored}
@app.get("/api/categories", response_model=CategoriesResponse)
def categories() -> CategoriesResponse:
return CategoriesResponse(
topics=[Category(key=k, description=v) for k, v in TOPICS.items()],
flavors=[Category(key=k, description=v) for k, v in FLAVORS.items()],
)
@app.get("/api/category-counts", response_model=list[CategoryCount])
def category_counts(accepted_only: bool = True) -> list[CategoryCount]:
with get_conn() as conn:
rows = queries.category_counts(conn, accepted_only=accepted_only)
return [CategoryCount(**row) for row in rows]
@app.get("/api/feed", response_model=FeedResponse)
def feed(
topic: str | None = Query(None),
flavor: str | None = Query(None),
accepted_only: bool = True,
limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> FeedResponse:
if topic and topic.lower() not in TOPICS:
raise HTTPException(400, f"unknown topic: {topic}")
if flavor and flavor.lower() not in FLAVORS:
raise HTTPException(400, f"unknown flavor: {flavor}")
with get_conn() as conn:
rows = queries.feed(
conn,
topic=topic,
flavor=flavor,
accepted_only=accepted_only,
limit=limit,
offset=offset,
)
return FeedResponse(
topic=topic,
flavor=flavor,
count=len(rows),
items=[Article.from_row(r) for r in rows],
)
@app.get("/api/brief", response_model=BriefResponse)
def brief(date: str | None = Query(None), limit: int = Query(10, ge=1, le=50)) -> BriefResponse:
with get_conn() as conn:
data = queries.brief(conn, brief_date=date, limit=limit)
return BriefResponse(
brief_date=data["brief_date"],
title=data["title"],
items=[Article.from_row(r) for r in data["items"]],
)
@app.get("/api/brief-dates", response_model=list[str])
def brief_dates(limit: int = Query(30, ge=1, le=365)) -> list[str]:
with get_conn() as conn:
return queries.available_dates(conn, limit=limit)
# Static site last, mounted at root, so /api/* and /healthz win.
if STATIC_DIR.is_dir():
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="site")
return app
app = create_app()
+25
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import os
import sqlite3
from pathlib import Path
@@ -71,7 +72,17 @@ def main() -> None:
show_brief_parser.add_argument("--date", help="Brief date in YYYY-MM-DD format; defaults to latest brief")
show_brief_parser.add_argument("--limit", type=int, default=10)
serve_parser = subparsers.add_parser("serve", help="Run the web/API server (requires the 'web' extra)")
serve_parser.add_argument("--host", default="127.0.0.1", help="Bind host; use 0.0.0.0 to expose")
serve_parser.add_argument("--port", type=int, default=8000)
serve_parser.add_argument("--reload", action="store_true", help="Auto-reload on code changes (dev)")
args = parser.parse_args()
if args.command == "serve":
serve(args)
return
conn = connect(args.db)
if args.command == "init-db":
@@ -190,6 +201,20 @@ def list_recent(conn: sqlite3.Connection, limit: int, accepted_only: bool) -> No
print(f" {row['canonical_url']}")
def serve(args: argparse.Namespace) -> None:
try:
import uvicorn
except ModuleNotFoundError:
raise SystemExit(
"The web server needs the optional 'web' extra. Install it with:\n"
" pip install -e '.[web]'"
)
# Make sure the API reads the same database the CLI was pointed at.
os.environ.setdefault("GOODNEWS_DB", str(args.db))
print(f"Serving goodNews on http://{args.host}:{args.port} (docs at /docs)")
uvicorn.run("goodnews.api:app", host=args.host, port=args.port, reload=args.reload)
def list_category(
conn: sqlite3.Connection,
topic: str | None,
+141
View File
@@ -0,0 +1,141 @@
"""Read-only query helpers over the goodNews database.
Pure stdlib and framework-agnostic: returns plain dicts so the same functions
back both the CLI and the JSON API. All article output is metadata + a link to
the original source — never stored bodies.
"""
from __future__ import annotations
import sqlite3
# Composite ranking used everywhere a "best first" order is needed. Kept as one
# expression so brief, category feeds, and the API all rank identically.
RANK_SCORE_SQL = (
"(s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score "
"- s.cortisol_score - s.ragebait_score - s.pr_risk_score)"
)
_ARTICLE_COLUMNS = f"""
a.id,
a.title,
a.description,
a.canonical_url,
a.published_at,
a.image_url,
src.name AS source_name,
s.topic,
s.flavor,
s.accepted,
s.constructive_score,
s.cortisol_score,
s.ragebait_score,
s.agency_score,
s.human_benefit_score,
s.pr_risk_score,
s.reason_code,
s.reason_text,
s.model_name,
{RANK_SCORE_SQL} AS rank_score
"""
def feed(
conn: sqlite3.Connection,
topic: str | None = None,
flavor: str | None = None,
accepted_only: bool = True,
limit: int = 30,
offset: int = 0,
) -> list[dict]:
"""Return ranked articles, optionally filtered by topic and/or flavor."""
clauses = []
params: list = []
if accepted_only:
clauses.append("s.accepted = 1")
if topic:
clauses.append("s.topic = ?")
params.append(topic.lower())
if flavor:
clauses.append("s.flavor = ?")
params.append(flavor.lower())
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.extend([limit, offset])
rows = conn.execute(
f"""
SELECT {_ARTICLE_COLUMNS}
FROM articles a
JOIN sources src ON src.id = a.source_id
JOIN article_scores s ON s.article_id = a.id
{where}
ORDER BY rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC
LIMIT ? OFFSET ?
""",
params,
).fetchall()
return [dict(row) for row in rows]
def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int = 10) -> dict:
"""Return a stored daily brief (latest if no date) with its ranked items."""
target_date = brief_date or _latest_brief_date(conn)
if not target_date:
return {"brief_date": None, "title": None, "items": []}
header = conn.execute(
"SELECT brief_date, title FROM daily_briefs WHERE brief_date = ?",
(target_date,),
).fetchone()
if not header:
return {"brief_date": target_date, "title": None, "items": []}
rows = conn.execute(
f"""
SELECT bi.rank, bi.selection_reason, {_ARTICLE_COLUMNS}
FROM daily_briefs b
JOIN daily_brief_items bi ON bi.brief_id = b.id
JOIN articles a ON a.id = bi.article_id
JOIN sources src ON src.id = a.source_id
LEFT JOIN article_scores s ON s.article_id = a.id
WHERE b.brief_date = ?
ORDER BY bi.rank
LIMIT ?
""",
(target_date, limit),
).fetchall()
return {
"brief_date": header["brief_date"],
"title": header["title"],
"items": [dict(row) for row in rows],
}
def category_counts(conn: sqlite3.Connection, accepted_only: bool = True) -> list[dict]:
"""Return per topic/flavor article counts for building browse UIs."""
where = "WHERE s.accepted = 1" if accepted_only else "WHERE s.topic IS NOT NULL"
rows = conn.execute(
f"""
SELECT s.topic, s.flavor, COUNT(*) AS count
FROM article_scores s
{where}
GROUP BY s.topic, s.flavor
ORDER BY s.topic, s.flavor
"""
).fetchall()
return [dict(row) for row in rows]
def available_dates(conn: sqlite3.Connection, limit: int = 30) -> list[str]:
rows = conn.execute(
"SELECT brief_date FROM daily_briefs ORDER BY brief_date DESC LIMIT ?",
(limit,),
).fetchall()
return [row["brief_date"] for row in rows]
def _latest_brief_date(conn: sqlite3.Connection) -> str | None:
row = conn.execute(
"SELECT brief_date FROM daily_briefs ORDER BY brief_date DESC LIMIT 1"
).fetchone()
return row["brief_date"] if row else None
+163
View File
@@ -0,0 +1,163 @@
<!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);
}
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; }
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; }
.empty { color: var(--muted); text-align: center; padding: 30px; }
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>
</header>
<main>
<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>
</main>
<footer>
goodNews · metadata &amp; links only, no stored articles ·
<a href="/docs">API</a>
</footer>
<script>
const state = { topic: null, flavor: null };
const el = (id) => document.getElementById(id);
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
function articleCard(a, showRank) {
const rank = showRank && a.rank ? `<span class="rank-badge">${a.rank}</span>` : "";
const tags = [a.topic, a.flavor].filter(Boolean)
.map(t => `<span class="tag">${t}</span>`).join(" ");
const desc = a.description ? `<p class="desc">${a.description}</p>` : "";
const why = a.reason_text ? `<div class="why">${a.reason_text}</div>` : "";
return `<article>
<div class="meta">${rank}${tags}<span>${a.source}</span>
${a.published_at ? `<span>· ${a.published_at.slice(0,10)}</span>` : ""}</div>
<h3><a href="${a.url}" target="_blank" rel="noopener">${a.title}</a></h3>
${desc}${why}
</article>`;
}
function renderList(target, items, showRank) {
target.innerHTML = items.length
? items.map(a => articleCard(a, showRank)).join("")
: `<div class="empty">Nothing here yet.</div>`;
}
function chip(label, active, onClick) {
const c = document.createElement("button");
c.className = "chip" + (active ? " active" : "");
c.textContent = label;
c.onclick = onClick;
return c;
}
async function loadBrief() {
try {
const b = await getJSON("/api/brief?limit=5");
if (b.title) el("brief-block").querySelector(".section-title").textContent = b.title;
renderList(el("brief"), b.items, true);
} catch (e) { el("brief").innerHTML = `<div class="empty">No brief yet.</div>`; }
}
async function loadFeed() {
const params = new URLSearchParams({ limit: "30" });
if (state.topic) params.set("topic", state.topic);
if (state.flavor) params.set("flavor", state.flavor);
const f = await getJSON("/api/feed?" + params.toString());
renderList(el("feed"), f.items, false);
}
async function init() {
loadBrief();
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))));
loadFeed();
}
function refreshActive(containerId, value) {
[...el(containerId).querySelectorAll(".chip")].forEach(c => {
c.classList.toggle("active", c.textContent === (value || "all"));
});
}
function setTopic(t) { state.topic = t; refreshActive("topic-chips", t); loadFeed(); }
function setFlavor(f) { state.flavor = f; refreshActive("flavor-chips", f); loadFeed(); }
init();
</script>
</body>
</html>
+2
View File
@@ -1 +1,3 @@
- Ability to silence some categories temporarily (Maybe a user doesn't even want to see health-related articles, even good ones, so they're not reminded of an ongoing medical issue -- a way to avoid something purposely for a bit)
- Terms to avoid list (To filter even good news that you'd rather not hear about)
-
+18
View File
@@ -1,10 +1,28 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "goodnews"
version = "0.1.0"
description = "Local-first constructive news ingestion and filtering prototype."
requires-python = ">=3.11"
# The ingestion CLI is intentionally pure-stdlib. The optional `web` extra adds
# the API/site layer so the two halves can be deployed and upgraded independently.
dependencies = []
[project.optional-dependencies]
web = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
]
[project.scripts]
goodnews = "goodnews.cli:main"
[tool.setuptools]
packages = ["goodnews"]
[tool.setuptools.package-data]
goodnews = ["static/*"]