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__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
data/*.sqlite3
|
data/*.sqlite3
|
||||||
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
|
- hashes used for dedupe
|
||||||
- heuristic scores and reason codes
|
- 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
|
## Next Steps
|
||||||
|
|
||||||
1. Run the poller for a few days and inspect which sources produce useful candidates.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
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("--date", help="Brief date in YYYY-MM-DD format; defaults to latest brief")
|
||||||
show_brief_parser.add_argument("--limit", type=int, default=10)
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "serve":
|
||||||
|
serve(args)
|
||||||
|
return
|
||||||
|
|
||||||
conn = connect(args.db)
|
conn = connect(args.db)
|
||||||
|
|
||||||
if args.command == "init-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']}")
|
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(
|
def list_category(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
topic: str | None,
|
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)
|
- 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]
|
[project]
|
||||||
name = "goodnews"
|
name = "goodnews"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Local-first constructive news ingestion and filtering prototype."
|
description = "Local-first constructive news ingestion and filtering prototype."
|
||||||
requires-python = ">=3.11"
|
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 = []
|
dependencies = []
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
web = [
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"uvicorn[standard]>=0.29",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
goodnews = "goodnews.cli:main"
|
goodnews = "goodnews.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["goodnews"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
goodnews = ["static/*"]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user