Files
upbeatBytes/goodnews/share.py
T
thejayman77 ea58039fb9 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>
2026-06-05 19:37:05 +00:00

292 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Server-rendered share/landing page for /a/<id>.
A *pointer* page (never a republished article): the story's own title, "why it's
uplifting" note, and image, wrapped with rich OpenGraph/Twitter meta and a
prominent link to the original source. Social scrapers don't run JS, so this is
plain server-rendered HTML. All dynamic values are HTML-escaped.
"""
from __future__ import annotations
from html import escape
def _tag(name: str, content: str | None, attr: str = "property") -> str:
if not content:
return ""
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
def render_share_page(article: dict, base_url: str, summary: str | None = None) -> str:
aid = article["id"]
title = (article.get("title") or "Upbeat Bytes").strip()
why = (article.get("reason_text") or article.get("description")
or "A calm, constructive story worth your attention.").strip()
source = (article.get("source_name") or "the source").strip()
src_url = article.get("canonical_url") or base_url
image = article.get("image_url")
page_url = f"{base_url}/a/{aid}"
# With an image: a large-image card. Without: a clean text unfurl (title +
# why + brand) — tidy, not a broken/muddy preview. (A branded fallback PNG
# can replace this later.)
twitter_card = "summary_large_image" if image else "summary"
meta = "\n".join(filter(None, [
_tag("og:site_name", "Upbeat Bytes"),
_tag("og:type", "article"),
_tag("og:title", title),
_tag("og:description", why),
_tag("og:url", page_url),
_tag("og:image", image),
_tag("twitter:card", twitter_card, attr="name"),
_tag("twitter:title", title, attr="name"),
_tag("twitter:description", why, attr="name"),
_tag("twitter:image", image, attr="name"),
]))
media = (
f'<img class="media" src="{escape(image)}" alt="" referrerpolicy="no-referrer">'
if image else ""
)
raw_tags = (article.get("tags") or "")
chips = "".join(
f'<span class="chip">{escape(t.replace("-", " "))}</span>'
for t in raw_tags.split(",") if t
)
groupings = f'<div class="chips">{chips}</div>' if chips else ""
source_short = source.split(" ")[0] if source else "the source"
if summary:
summary_block = f'<p class="summary" id="summary">{escape(summary)}</p>'
poll = ""
else:
pending = (
f"✦ Were putting together a quick summary — in the meantime, "
f"read the full story at {escape(source)} below."
)
summary_block = f'<p class="summary pending" id="summary">{pending}</p>'
# Quietly poll; swap the real summary in the moment it's cached.
poll = f"""<script>
(function(){{
var id={aid}, box=document.getElementById('summary'), n=0;
function go(){{
n++;
fetch('/api/summary/'+id).then(function(r){{return r.json();}}).then(function(d){{
if(d&&d.status==='ready'&&d.summary){{ box.textContent=d.summary; box.className='summary'; }}
else if(n<12){{ setTimeout(go,2500); }}
}}).catch(function(){{ if(n<12) setTimeout(go,3000); }});
}}
go();
}})();
</script>"""
# Fire a privacy-respecting 'source_click' when the reader heads to the source
# (reuses the SPA's anonymous visitor token from same-origin localStorage).
src_click = f"""<script>
(function(){{
var b=document.querySelector('[data-src-click]'); if(!b) return;
b.addEventListener('click',function(){{
try{{
var v=localStorage.getItem('goodnews:visitor')||'';
var body=JSON.stringify({{kind:'source_click',article_id:{aid},visitor:v}});
if(navigator.sendBeacon) navigator.sendBeacon('/api/events',new Blob([body],{{type:'application/json'}}));
}}catch(e){{}}
}});
}})();
</script>"""
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)} · Upbeat Bytes</title>
<meta name="description" content="{escape(why)}">
<link rel="canonical" href="{escape(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:680px; margin:0 auto; padding:12px 20px; }}
.bar img {{ height:40px; display:block; }}
.wrap {{ max-width:680px; margin:0 auto; padding:24px 20px 60px; }}
.card {{ background:var(--surface); border:1px solid var(--line); border-radius:16px;
overflow:hidden; box-shadow:0 10px 30px rgba(40,38,28,.06); }}
.media {{ width:100%; height:auto; display:block; max-height:380px; object-fit:cover; }}
.body {{ padding:24px 26px 28px; }}
.src {{ color:var(--muted); font-size:.82rem; text-transform:uppercase; letter-spacing:.08em; }}
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600;
font-size:1.7rem; line-height:1.2; margin:6px 0 14px; }}
.summary {{ font-size:1.05rem; color:var(--ink); margin:0 0 18px; }}
.summary.pending {{ color:var(--muted); font-style:italic; }}
.why {{ color:#3b4754; font-size:.92rem; margin:0 0 22px; }}
.why .lbl {{ display:block; text-transform:uppercase; letter-spacing:.08em; font-size:.7rem;
color:var(--muted); margin-bottom:3px; }}
.chips {{ display:flex; flex-wrap:wrap; gap:7px; margin:0 0 22px; }}
.chip {{ background:#e0eef3; color:var(--accent-deep); border-radius:999px; padding:3px 11px;
font-size:.7rem; text-transform:uppercase; letter-spacing:.06em; }}
.actions {{ display:flex; flex-wrap:wrap; gap:12px; align-items:center; }}
.primary {{ background:var(--accent); color:#fff; text-decoration:none; font-weight:600;
padding:12px 20px; border-radius:999px; display:inline-block; }}
.primary:hover {{ background:var(--accent-deep); }}
.secondary {{ color:var(--accent-deep); text-decoration:none; font-size:.92rem;
background:none; border:none; cursor:pointer; font-family:inherit; padding:0; }}
.secondary:hover {{ text-decoration:underline; }}
.note {{ color:var(--muted); font-size:.8rem; margin-top:22px; }}
</style>
</head>
<body>
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a></div></div>
<main class="wrap">
<article class="card">
{media}
<div class="body">
<div class="src">{escape(source)}</div>
<h1>{escape(title)}</h1>
{summary_block}
<p class="why"><span class="lbl">Why it's here</span>{escape(why)}</p>
{groupings}
<div class="actions">
<a class="primary" href="{escape(src_url)}" rel="noopener" data-src-click>Read the full story at {escape(source_short)}</a>
<button class="secondary" type="button" data-share>Copy link</button>
<a class="secondary" href="/">Explore Upbeat Bytes →</a>
</div>
<p class="note">Upbeat Bytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
</div>
</article>
</main>
<script>
(function(){{
try{{
var v=localStorage.getItem('goodnews:visitor')||'';
var b=JSON.stringify({{kind:'summary_viewed',article_id:{aid},visitor:v}});
if(navigator.sendBeacon) navigator.sendBeacon('/api/events', new Blob([b],{{type:'application/json'}}));
}}catch(e){{}}
}})();
</script>
{src_click}
<script>
(function(){{
var s=document.querySelector('[data-share]'); if(!s) return;
s.addEventListener('click',function(){{
var url=location.href;
if(navigator.share){{ navigator.share({{title:document.title,url:url}}).catch(function(){{}}); }}
else if(navigator.clipboard){{ navigator.clipboard.writeText(url).then(function(){{ s.textContent='Copied!'; setTimeout(function(){{s.textContent='Copy link';}},1500); }}); }}
}});
}})();
</script>
{poll}
</body>
</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">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Story not found · Upbeat Bytes</title><link rel="icon" href="/favicon.svg">
<style>
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:14px; text-align:center; padding:40px;
background:#f7f4ec; color:#16263a;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }}
a {{ color:#006b8e; }}
</style></head>
<body>
<img src="/logo.svg" alt="Upbeat Bytes" style="height:44px">
<h1 style="font-family:Georgia,serif;font-weight:600">That story isn't here</h1>
<p style="color:#5d6b78">It may have moved on — the good news refreshes often.</p>
<a href="/">← Back to Upbeat Bytes</a>
</body></html>"""