- paywall.py: conservative domain-level paywall detection (New Scientist, Nature, and common hard/soft paywalls). Never fetches pages — an honest hint. - API: Article gains a 'paywalled' flag; the brief now leads with a gentle AND readable story (paywalled/charged stories stay in the five, just not first). - New GET /api/replacement returns the next-best readable, unshown article (honors mood+prefs via the merged prefs param; gentle=true for hero swaps). - UI: paywalled cards show 'May need a subscription'; a Replace / 'Find one I can read' action (always visible, while tuning actions stay tucked) swaps the card for a readable alternative, with a gentle notice when none remain. - Tests: paywall detection + replacement behavior (77 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+57
-5
@@ -30,9 +30,10 @@ from pydantic import BaseModel
|
||||
from . import feeds, queries
|
||||
from .db import connect, init_db
|
||||
from .filters import filter_articles, prefs_from_json
|
||||
from .hero import lead_with_gentle
|
||||
from .hero import safe_to_lead
|
||||
from .llm import LocalModelClient
|
||||
from .moods import MOODS
|
||||
from .moods import MOODS, mood_filter
|
||||
from .paywall import is_paywalled
|
||||
from .taxonomy import FLAVORS, TOPICS
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -56,6 +57,22 @@ def get_conn():
|
||||
conn.close()
|
||||
|
||||
|
||||
def _pick_lead(items: list[dict]) -> list[dict]:
|
||||
"""Lead with a gentle, readable story when possible.
|
||||
|
||||
Tries gentle-and-readable first, then gentle, then leaves the order alone.
|
||||
Charged or paywalled stories still appear in the set — they just don't lead.
|
||||
"""
|
||||
for ok in (
|
||||
lambda a: safe_to_lead(a) and not is_paywalled(a.get("canonical_url")),
|
||||
safe_to_lead,
|
||||
):
|
||||
for i, a in enumerate(items):
|
||||
if ok(a):
|
||||
return items if i == 0 else [a, *items[:i], *items[i + 1:]]
|
||||
return items
|
||||
|
||||
|
||||
# --- Response models (the companion-app contract) ---------------------------
|
||||
|
||||
|
||||
@@ -91,6 +108,7 @@ class Article(BaseModel):
|
||||
reason_text: str | None = None
|
||||
model_name: str | None = None
|
||||
rank: int | None = None # position within a brief, when applicable
|
||||
paywalled: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: dict) -> "Article":
|
||||
@@ -110,6 +128,7 @@ class Article(BaseModel):
|
||||
reason_text=row.get("reason_text"),
|
||||
model_name=row.get("model_name"),
|
||||
rank=row.get("rank"),
|
||||
paywalled=is_paywalled(row.get("canonical_url")),
|
||||
)
|
||||
|
||||
|
||||
@@ -283,9 +302,9 @@ def create_app() -> FastAPI:
|
||||
# MVP: filter the stored brief DOWN; no refill from outside the brief.
|
||||
# Runs before hero selection, so personal avoid-terms take precedence.
|
||||
items = filter_articles(items, fp, datetime.now(timezone.utc))
|
||||
# Lead with an emotionally-safe story (constructive-but-charged stories
|
||||
# stay in the five, just not as the first thing seen).
|
||||
items = lead_with_gentle(items)
|
||||
# Lead with a gentle, readable story (charged or paywalled stories stay
|
||||
# in the five, just not as the first thing seen).
|
||||
items = _pick_lead(items)
|
||||
return BriefResponse(
|
||||
brief_date=data["brief_date"],
|
||||
title=data["title"],
|
||||
@@ -297,6 +316,39 @@ def create_app() -> FastAPI:
|
||||
with get_conn() as conn:
|
||||
return queries.available_dates(conn, limit=limit)
|
||||
|
||||
@app.get("/api/replacement", response_model=Article | None)
|
||||
def replacement(
|
||||
exclude: str = Query("", description="comma-separated article ids already shown"),
|
||||
prefs: str | None = Query(None),
|
||||
avoid_paywall: bool = True,
|
||||
gentle: bool = Query(False, description="also require lead-safe (for replacing the hero)"),
|
||||
) -> Article | None:
|
||||
# Swap a read or paywalled item for the next-best one the reader can
|
||||
# actually open. The client merges any active mood into `prefs` (same as
|
||||
# the feed), so this needs no mood param.
|
||||
fp = prefs_from_json(prefs)
|
||||
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
|
||||
now = datetime.now(timezone.utc)
|
||||
kw = dict(
|
||||
include_topics=fp.include_topics or None,
|
||||
include_flavors=fp.include_flavors or None,
|
||||
mute_topics=list(fp.muted_topics(now)) or None,
|
||||
mute_flavors=list(fp.muted_flavors(now)) or None,
|
||||
max_cortisol=fp.max_cortisol,
|
||||
max_ragebait=fp.max_ragebait,
|
||||
)
|
||||
with get_conn() as conn:
|
||||
rows = queries.feed(conn, accepted_only=True, limit=120, offset=0, **kw)
|
||||
for r in filter_articles(rows, fp, now):
|
||||
if r["id"] in excl:
|
||||
continue
|
||||
if avoid_paywall and is_paywalled(r["canonical_url"]):
|
||||
continue
|
||||
if gentle and not safe_to_lead(r):
|
||||
continue
|
||||
return Article.from_row(r)
|
||||
return None
|
||||
|
||||
@app.get("/api/candidates", response_model=list[Candidate])
|
||||
def candidates(status: str | None = Query(None)) -> list[Candidate]:
|
||||
from .sources import list_candidates
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Domain-level paywall hints.
|
||||
|
||||
We never fetch article pages, so a paywall can only be inferred from the host.
|
||||
This is a curated, conservative list of hard/soft paywalls — enough to label a
|
||||
card "subscription may be required" and to prefer readable stories for the lead
|
||||
and for replacements. It will never be perfect; it's an honest hint, not a gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# Host suffixes considered paywalled. Subdomains match (news.nature.com → nature.com).
|
||||
PAYWALL_DOMAINS = {
|
||||
"newscientist.com",
|
||||
"nature.com",
|
||||
"nytimes.com",
|
||||
"wsj.com",
|
||||
"ft.com",
|
||||
"economist.com",
|
||||
"wired.com",
|
||||
"theatlantic.com",
|
||||
"washingtonpost.com",
|
||||
"bloomberg.com",
|
||||
"technologyreview.com",
|
||||
"newyorker.com",
|
||||
"scientificamerican.com",
|
||||
"nationalgeographic.com",
|
||||
"thetimes.co.uk",
|
||||
"telegraph.co.uk",
|
||||
"foreignpolicy.com",
|
||||
"hbr.org",
|
||||
"harpers.org",
|
||||
}
|
||||
|
||||
|
||||
def is_paywalled(url: str | None) -> bool:
|
||||
host = urlsplit(url or "").netloc.lower()
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
return any(host == d or host.endswith("." + d) for d in PAYWALL_DOMAINS)
|
||||
Reference in New Issue
Block a user