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:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.venv
|
||||
data
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.sqlite3
|
||||
*.sqlite3-*
|
||||
@@ -1,6 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.egg-info/
|
||||
data/*.sqlite3
|
||||
data/*.sqlite3-*
|
||||
|
||||
|
||||
+21
@@ -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"]
|
||||
@@ -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
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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 & 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>
|
||||
@@ -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)
|
||||
-
|
||||
@@ -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/*"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user