Make paywalls systemic + fix ArticleCard reactivity

- ArticleCard: derive safeHref from article.url and reset image-failure state
  when the article changes, so in-place replacements re-evaluate correctly
  (clears the Svelte capture warning; build is warning-free again).
- Downweight paywalled stories below readable ones (stable sort) when composing
  the daily five and in feed results — the brief now leads readable and rarely
  hands over a locked door.
- review_sources gains a 'paywall-heavy' advisory flag (Nature, New Scientist
  flag at 100%); never auto-deactivates.
- New Scientist/Nature kept active but no longer reach the daily five; they
  remain browsable with the label + Replace.
- Tests: brief readability preference + paywall-heavy flag (79 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-31 01:36:53 +00:00
parent bfd612eb9b
commit ba801d90f6
7 changed files with 89 additions and 8 deletions
+3
View File
@@ -281,6 +281,9 @@ def create_app() -> FastAPI:
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
limit=limit, offset=offset, **kw,
)
# Keep the top of a browse view readable: stable-sort paywalled items
# below readable ones (composite order preserved within each group).
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
return FeedResponse(
topic=topic,
flavor=flavor,
+6
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import sqlite3
from datetime import date
from .paywall import is_paywalled
def build_daily_brief(
conn: sqlite3.Connection,
@@ -24,6 +26,10 @@ def build_daily_brief(
).lastrowid
rows = _candidate_articles(conn, target_date, window_days)
# A calm daily brief shouldn't repeatedly hand the reader a locked door:
# push paywalled candidates below readable ones (stable, so composite order
# is preserved within each group) before selecting the five.
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
selected = _select_diverse(rows, limit)
for index, row in enumerate(selected, start=1):
conn.execute(
+6 -1
View File
@@ -7,6 +7,8 @@ from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit
from .paywall import is_paywalled
def load_sources(path: Path | str) -> list[dict]:
data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
@@ -183,7 +185,7 @@ def review_sources(
recent = conn.execute(
"""
SELECT sc.accepted, sc.cortisol_score, sc.ragebait_score, a.duplicate_of,
COALESCE(a.published_at, a.discovered_at) AS dt
a.canonical_url, COALESCE(a.published_at, a.discovered_at) AS dt
FROM articles a
JOIN article_scores sc ON sc.article_id = a.id
WHERE a.source_id = ?
@@ -220,6 +222,9 @@ def review_sources(
avg_rage = sum(r["ragebait_score"] or 0 for r in recent) / n
if avg_rage > 3:
reasons.append(f"high ragebait (avg {avg_rage:.1f})")
paywalled = sum(1 for r in recent if is_paywalled(r["canonical_url"])) / n
if paywalled > 0.5:
reasons.append(f"paywall-heavy ({paywalled * 100:.0f}%)")
flag = 1 if reasons else 0
reason = "; ".join(reasons) if reasons else None