SEO flywheel: /today digest, sitemap, robots, home OG tags
Make the summary pages discoverable so traffic compounds passively: - /today: a server-rendered, shareable + indexable digest of today's brief — each item's title (→ /a summary), our summary, and a source link. OG/Twitter meta + self-canonical. - /sitemap.xml: dynamic — home, /today, and every accepted non-duplicate /a page with lastmod. robots.txt allows all and points to it. - Home (SPA shell) gains canonical + OG/Twitter tags for cleaner unfurls. - Caddy routes /today + /sitemap.xml to the API. 133 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,15 @@
|
||||
<meta name="theme-color" content="#0083ad" />
|
||||
<meta name="description" content="Calm, constructive news worth your attention — and nothing that isn't." />
|
||||
<title>Upbeat Bytes — calm, constructive news</title>
|
||||
<link rel="canonical" href="https://upbeatbytes.com/" />
|
||||
<meta property="og:site_name" content="Upbeat Bytes" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Upbeat Bytes — calm, constructive news" />
|
||||
<meta property="og:description" content="Calm, constructive news worth your attention — and nothing that isn't. Summarized, so you get the gist and go deeper only if you want." />
|
||||
<meta property="og:url" content="https://upbeatbytes.com/" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Upbeat Bytes — calm, constructive news" />
|
||||
<meta name="twitter:description" content="Calm, constructive news, summarized — get the gist, go deeper only if you want." />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://upbeatbytes.com/sitemap.xml
|
||||
@@ -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"<url><loc>{base}/</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>",
|
||||
f"<url><loc>{base}/today</loc><changefreq>daily</changefreq><priority>0.9</priority></url>",
|
||||
]
|
||||
for r in rows:
|
||||
lm = (r["lm"] or "")[:10]
|
||||
lastmod = f"<lastmod>{lm}</lastmod>" if lm else ""
|
||||
urls.append(f"<url><loc>{base}/a/{r['id']}</loc>{lastmod}</url>")
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
||||
+ "".join(urls) + "</urlset>"
|
||||
)
|
||||
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)."""
|
||||
|
||||
@@ -187,6 +187,90 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
||||
</html>"""
|
||||
|
||||
|
||||
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 += (
|
||||
'<article class="item">'
|
||||
f'<a class="t" href="/a/{aid}"><h2>{title}</h2></a>'
|
||||
f'<div class="src">{source}</div>'
|
||||
f'<p class="gist">{gist}</p>'
|
||||
f'<div class="links"><a href="/a/{aid}">Read the summary</a>'
|
||||
f'<a href="{src_url}" rel="noopener">Full story at {source} →</a></div>'
|
||||
"</article>"
|
||||
)
|
||||
|
||||
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"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Today's good news, summarized · Upbeat Bytes</title>
|
||||
<meta name="description" content="{escape(intro)}">
|
||||
<link rel="canonical" href="{page_url}">
|
||||
<link rel="icon" href="/favicon.svg">
|
||||
{meta}
|
||||
<style>
|
||||
:root {{ --accent:#0083ad; --accent-deep:#006b8e; --ink:#16263a; --muted:#5d6b78;
|
||||
--bg:#f7f4ec; --surface:#fffdf8; --line:#e8e3d8; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:var(--bg); color:var(--ink);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.6; }}
|
||||
.bar {{ background:var(--surface); border-bottom:1px solid var(--line); }}
|
||||
.bar .inner {{ max-width:720px; margin:0 auto; padding:12px 20px; }}
|
||||
.bar img {{ height:40px; display:block; }}
|
||||
.wrap {{ max-width:720px; margin:0 auto; padding:26px 20px 60px; }}
|
||||
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:2rem; margin:0 0 4px; }}
|
||||
.lede {{ color:var(--muted); margin:0 0 26px; }}
|
||||
.item {{ border-top:1px solid var(--line); padding:20px 0; }}
|
||||
.item .t {{ text-decoration:none; color:var(--ink); }}
|
||||
.item h2 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:1.3rem; margin:0 0 4px; }}
|
||||
.item .t:hover h2 {{ color:var(--accent-deep); }}
|
||||
.item .src {{ color:var(--muted); font-size:.78rem; text-transform:uppercase; letter-spacing:.07em; margin-bottom:8px; }}
|
||||
.item .gist {{ margin:0 0 10px; }}
|
||||
.item .links {{ display:flex; flex-wrap:wrap; gap:16px; font-size:.9rem; }}
|
||||
.item .links a {{ color:var(--accent-deep); text-decoration:none; }}
|
||||
.item .links a:hover {{ text-decoration:underline; }}
|
||||
.more {{ margin-top:28px; }}
|
||||
.more a {{ display:inline-block; background:var(--accent); color:#fff; text-decoration:none;
|
||||
padding:11px 20px; border-radius:999px; font-weight:600; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a></div></div>
|
||||
<main class="wrap">
|
||||
<h1>Today's good news</h1>
|
||||
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
|
||||
{cards}
|
||||
<p class="more"><a href="/">Browse more on Upbeat Bytes →</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def render_not_found(base_url: str) -> str:
|
||||
return f"""<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
|
||||
@@ -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 "<urlset" in xml
|
||||
assert "https://upbeatbytes.com/a/1" in xml
|
||||
assert "https://upbeatbytes.com/today" in xml
|
||||
assert "<lastmod>2026-06-05</lastmod>" in xml
|
||||
Reference in New Issue
Block a user