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}
+
+
+
+
+
+ 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