Digest v2: restrained "From what you follow" section
Per Codex — give follows an immediate payoff without making the email feel algorithmic. The editorial brief stays the star: subject + brief count unchanged. * followed_digest_items(conn, user_id, exclude_ids, limit=3): recent items from the user's followed sources/tags, same accepted/non-dup/content-visible gate, excludes anything in the brief, capped to one per source so a single follow can't dominate. Empty → section omitted (no empty state in email). * build_digest gains an optional `followed` list → a small "From what you follow" section AFTER the brief, only when there are items. Item rendering factored into shared _item_html / _item_text_lines helpers. * send_due_digests computes the followed items per user (excluding the brief). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+99
-25
@@ -52,6 +52,53 @@ def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> l
|
||||
return items
|
||||
|
||||
|
||||
def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, limit: int = 3) -> list[dict]:
|
||||
"""Up to `limit` recent items from the user's followed sources/tags for the
|
||||
"From what you follow" section — same accepted/non-dup/visible gate as the feed,
|
||||
excluding what's already in the brief, capped to one per source so a single
|
||||
follow can't dominate. Returns [] if they follow nothing (→ section omitted)."""
|
||||
frows = conn.execute("SELECT kind, value FROM user_follows WHERE user_id = ?", (user_id,)).fetchall()
|
||||
fsources = [int(r["value"]) for r in frows if r["kind"] == "source" and str(r["value"]).isdigit()]
|
||||
ftags = [str(r["value"]).lower() for r in frows if r["kind"] == "tag"]
|
||||
if not fsources and not ftags:
|
||||
return []
|
||||
ors, params = [], []
|
||||
if fsources:
|
||||
ors.append(f"a.source_id IN ({','.join('?' * len(fsources))})")
|
||||
params += fsources
|
||||
if ftags:
|
||||
ors.append(
|
||||
f"EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id "
|
||||
f"AND at.tag IN ({','.join('?' * len(ftags))}))"
|
||||
)
|
||||
params += ftags
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT a.id, a.title, a.canonical_url, s.name AS source, a.source_id, sc.reason_text,
|
||||
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
|
||||
FROM articles a
|
||||
JOIN sources s ON s.id = a.source_id
|
||||
JOIN article_scores sc ON sc.article_id = a.id
|
||||
WHERE sc.accepted = 1 AND a.duplicate_of IS NULL AND s.content_visible = 1
|
||||
AND ({' OR '.join(ors)})
|
||||
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
|
||||
LIMIT 30
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
exclude, per_source, out = set(exclude_ids), {}, []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if d["id"] in exclude or per_source.get(d["source_id"], 0) >= 1:
|
||||
continue
|
||||
per_source[d["source_id"]] = 1
|
||||
d["paywalled"] = is_paywalled(d["canonical_url"])
|
||||
out.append(d)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _weekday(brief_date: str) -> str:
|
||||
try:
|
||||
return datetime.strptime(brief_date, "%Y-%m-%d").strftime("%A")
|
||||
@@ -59,9 +106,43 @@ def _weekday(brief_date: str) -> str:
|
||||
return "today"
|
||||
|
||||
|
||||
def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str | None = None) -> tuple[str, str, str]:
|
||||
"""Return (subject, text, html) for the digest — calm and dated, no urgency."""
|
||||
def _item_text_lines(it: dict, base: str) -> list[str]:
|
||||
lines = [f"• {it['title']} ({it['source']})"]
|
||||
if it.get("summary"):
|
||||
lines.append(f" {it['summary']}")
|
||||
if it.get("reason_text"):
|
||||
lines.append(f" Why it's here: {it['reason_text']}")
|
||||
lines.append(f" Read: {base}/a/{it['id']}")
|
||||
lines.append(f" Source: {it['canonical_url']}\n")
|
||||
return lines
|
||||
|
||||
|
||||
def _item_html(it: dict, base: str) -> str:
|
||||
summary = f'<div style="font-size:15px;line-height:1.5;color:#16263a">{escape(it["summary"])}</div>' if it.get("summary") else ""
|
||||
why = f'<div style="font-size:13px;color:#5d6b78;margin-top:6px"><em>Why it’s here:</em> {escape(it["reason_text"])}</div>' if it.get("reason_text") else ""
|
||||
lock = " \U0001f512" if it.get("paywalled") else ""
|
||||
return (
|
||||
'<div style="margin:0 0 22px;padding:0 0 18px;border-bottom:1px solid #e8e3d8">'
|
||||
f'<a href="{base}/a/{it["id"]}" style="font-size:18px;font-weight:600;color:#16263a;text-decoration:none">{escape(it["title"])}</a>'
|
||||
f'<div style="color:#5d6b78;font-size:13px;margin:3px 0 8px">{escape(it["source"])}</div>'
|
||||
f'{summary}{why}'
|
||||
'<div style="margin-top:10px;font-size:14px">'
|
||||
f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on Upbeat Bytes</a>'
|
||||
f' · <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>'
|
||||
'</div></div>'
|
||||
)
|
||||
|
||||
|
||||
def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str | None = None,
|
||||
followed: list[dict] | None = None) -> tuple[str, str, str]:
|
||||
"""Return (subject, text, html) for the digest — calm and dated, no urgency.
|
||||
|
||||
`followed` (optional) adds a small "From what you follow" section AFTER the
|
||||
editorial brief — only when there are qualifying items; omitted otherwise.
|
||||
The brief stays the star: subject and brief count are unchanged.
|
||||
"""
|
||||
base = base or _base_url()
|
||||
followed = followed or []
|
||||
n = len(items)
|
||||
weekday = _weekday(brief_date)
|
||||
subject = f"{weekday}'s Upbeat Bytes · {n} calm read{'' if n == 1 else 's'}"
|
||||
@@ -75,31 +156,22 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
|
||||
"always more waiting on the site whenever you want it.\n",
|
||||
]
|
||||
for it in items:
|
||||
text_lines.append(f"• {it['title']} ({it['source']})")
|
||||
if it.get("summary"):
|
||||
text_lines.append(f" {it['summary']}")
|
||||
if it.get("reason_text"):
|
||||
text_lines.append(f" Why it's here: {it['reason_text']}")
|
||||
text_lines.append(f" Read: {base}/a/{it['id']}")
|
||||
text_lines.append(f" Source: {it['canonical_url']}\n")
|
||||
text_lines += _item_text_lines(it, base)
|
||||
if followed:
|
||||
text_lines.append("\n— From what you follow —\n")
|
||||
for it in followed:
|
||||
text_lines += _item_text_lines(it, base)
|
||||
text_lines.append(f"That's today's highlights — more good news is always waiting at {base}. See you tomorrow.")
|
||||
text_lines.append(f"\nTo stop these emails: {unsub_url}")
|
||||
text = "\n".join(text_lines)
|
||||
|
||||
blocks = []
|
||||
for it in items:
|
||||
summary = f'<div style="font-size:15px;line-height:1.5;color:#16263a">{escape(it["summary"])}</div>' if it.get("summary") else ""
|
||||
why = f'<div style="font-size:13px;color:#5d6b78;margin-top:6px"><em>Why it’s here:</em> {escape(it["reason_text"])}</div>' if it.get("reason_text") else ""
|
||||
lock = " \U0001f512" if it.get("paywalled") else ""
|
||||
blocks.append(
|
||||
'<div style="margin:0 0 22px;padding:0 0 18px;border-bottom:1px solid #e8e3d8">'
|
||||
f'<a href="{base}/a/{it["id"]}" style="font-size:18px;font-weight:600;color:#16263a;text-decoration:none">{escape(it["title"])}</a>'
|
||||
f'<div style="color:#5d6b78;font-size:13px;margin:3px 0 8px">{escape(it["source"])}</div>'
|
||||
f'{summary}{why}'
|
||||
'<div style="margin-top:10px;font-size:14px">'
|
||||
f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on Upbeat Bytes</a>'
|
||||
f' · <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>'
|
||||
'</div></div>'
|
||||
main_blocks = "".join(_item_html(it, base) for it in items)
|
||||
followed_html = ""
|
||||
if followed:
|
||||
followed_html = (
|
||||
'<div style="font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#0083ad;'
|
||||
'margin:8px 0 16px">From what you follow</div>'
|
||||
+ "".join(_item_html(it, base) for it in followed)
|
||||
)
|
||||
html = (
|
||||
'<div style="max-width:600px;margin:0 auto;padding:8px 4px;'
|
||||
@@ -114,7 +186,8 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
|
||||
'Good morning. A small, hopeful handful of what’s going right — and there’s always more '
|
||||
f'<a href="{base}" style="color:#0083ad;text-decoration:none">waiting on the site</a> when you want it.</p>'
|
||||
'<div style="border-top:1px solid #e8e3d8;margin:0 0 24px"></div>'
|
||||
+ "".join(blocks)
|
||||
+ main_blocks
|
||||
+ followed_html
|
||||
+ '<p style="font-size:15px;color:#3f7048;margin:8px 0 0">That’s today’s highlights — more good news is '
|
||||
f'always <a href="{base}" style="color:#3f7048">waiting on Upbeat Bytes</a>. See you tomorrow.</p>'
|
||||
f'<p style="font-size:12px;color:#9aa6b2;margin-top:24px">You’re getting this because you turned on '
|
||||
@@ -164,7 +237,8 @@ def send_due_digests(conn: sqlite3.Connection, force: bool = False, base: str |
|
||||
conn.commit()
|
||||
user["digest_unsub_token"] = token
|
||||
link = unsub_url(user, base)
|
||||
subject, text, html = build_digest(items, brief_date, link, base)
|
||||
followed = followed_digest_items(conn, user["id"], [it["id"] for it in items])
|
||||
subject, text, html = build_digest(items, brief_date, link, base, followed=followed)
|
||||
# RFC 2369 / 8058: let inboxes offer native one-click unsubscribe.
|
||||
headers = {"List-Unsubscribe": f"<{link}>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"}
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user