diff --git a/frontend/src/app.html b/frontend/src/app.html index 2f44b24..9204a02 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -8,6 +8,15 @@ Upbeat Bytes — calm, constructive news + + + + + + + + + %sveltekit.head% diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b06f7bc --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://upbeatbytes.com/sitemap.xml diff --git a/goodnews/api.py b/goodnews/api.py index 9b62e76..01e0bcb 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -800,6 +800,40 @@ def create_app() -> FastAPI: _kick_summary(article_id, background_tasks) return {"status": "pending", "summary": None} + @app.get("/today", response_class=HTMLResponse) + def today_digest() -> HTMLResponse: + with get_conn() as conn: + b = queries.brief(conn) + items = b.get("items") or [] + if not items: + return HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404) + return HTMLResponse(share.render_digest(items, PUBLIC_BASE_URL, b.get("brief_date"))) + + @app.get("/sitemap.xml") + def sitemap() -> Response: + with get_conn() as conn: + rows = conn.execute( + "SELECT a.id, COALESCE(a.published_at, a.discovered_at) AS lm " + "FROM articles a JOIN article_scores s ON s.article_id = a.id " + "WHERE s.accepted = 1 AND a.duplicate_of IS NULL " + "ORDER BY lm DESC LIMIT 5000" + ).fetchall() + base = PUBLIC_BASE_URL + urls = [ + f"{base}/hourly1.0", + f"{base}/todaydaily0.9", + ] + for r in rows: + lm = (r["lm"] or "")[:10] + lastmod = f"{lm}" if lm else "" + urls.append(f"{base}/a/{r['id']}{lastmod}") + xml = ( + '' + '' + + "".join(urls) + "" + ) + return Response(content=xml, media_type="application/xml") + @app.post("/api/import") def import_local(body: ImportBody, request: Request) -> dict: """Fold this device's anonymous history/saved into the account (one-time).""" diff --git a/goodnews/share.py b/goodnews/share.py index 388e580..8b37049 100644 --- a/goodnews/share.py +++ b/goodnews/share.py @@ -187,6 +187,90 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None) """ +def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> str: + """A shareable, indexable 'today's good news, summarized' page.""" + lead_img = next((i.get("image_url") for i in items if i.get("image_url")), None) + intro = "The day's calm, constructive news — summarized in plain English, so you can get the gist and go deeper only if you want." + page_url = f"{base_url}/today" + + cards = "" + for it in items: + aid = it["id"] + title = escape((it.get("title") or "").strip()) + source = escape((it.get("source_name") or "the source").strip()) + src_url = escape(it.get("canonical_url") or base_url) + gist = escape((it.get("summary") or it.get("reason_text") or it.get("description") or "").strip()) + cards += ( + '
' + f'

{title}

' + f'
{source}
' + f'

{gist}

' + f'' + "
" + ) + + meta = "\n".join(filter(None, [ + _tag("og:site_name", "Upbeat Bytes"), + _tag("og:type", "website"), + _tag("og:title", "Today's good news, summarized"), + _tag("og:description", intro), + _tag("og:url", page_url), + _tag("og:image", lead_img), + _tag("twitter:card", "summary_large_image" if lead_img else "summary", attr="name"), + _tag("twitter:title", "Today's good news, summarized", attr="name"), + _tag("twitter:description", intro, attr="name"), + _tag("twitter:image", lead_img, attr="name"), + ])) + + return f""" + + + + +Today's good news, summarized · Upbeat Bytes + + + +{meta} + + + +
Upbeat Bytes
+
+

Today's good news

+

{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}

+ {cards} +

Browse more on Upbeat Bytes →

+
+ +""" + + def render_not_found(base_url: str) -> str: return f""" diff --git a/tests/test_seo.py b/tests/test_seo.py new file mode 100644 index 0000000..9069af4 --- /dev/null +++ b/tests/test_seo.py @@ -0,0 +1,45 @@ +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(tmp_path, monkeypatch): + db = tmp_path / "t.sqlite3" + monkeypatch.setenv("GOODNEWS_DB", str(db)) + monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com") + import importlib + import goodnews.api as api + importlib.reload(api) + from goodnews.db import connect, init_db + c = connect(str(db)); init_db(c) + c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'BBC','http://s/f',5)") + c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,published_at) " + "VALUES (1,1,'https://bbc.com/x','Bees are back','h1','2026-06-05T08:00:00')") + c.execute("INSERT INTO article_scores (article_id,accepted,reason_text) VALUES (1,1,'Hopeful.')") + c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (1,'Bee numbers recovered after a restoration effort.')") + c.execute("INSERT INTO daily_briefs (id,brief_date,title) VALUES (1,'2026-06-05','B')") + c.execute("INSERT INTO daily_brief_items (brief_id,article_id,rank) VALUES (1,1,1)") + c.commit(); c.close() + return api.create_app() + + +def test_today_digest(client): + r = TestClient(client).get("/today") + assert r.status_code == 200 + html = r.text + assert "Bees are back" in html + assert "Bee numbers recovered" in html # our summary + assert 'href="/a/1"' in html and "bbc.com/x" in html # summary + source links + assert 'rel="canonical" href="https://upbeatbytes.com/today"' in html + assert 'property="og:title"' in html + + +def test_sitemap(client): + r = TestClient(client).get("/sitemap.xml") + assert r.status_code == 200 + assert "application/xml" in r.headers["content-type"] + xml = r.text + assert "2026-06-05" in xml