Sources: per-source paywall override (3-state) — fix domain-rule mis-flags
The Articles inspector revealed paywall is domain-coarse: nytimes.com is flagged, so NY Times Learning's free Word-of-the-Day inherits 🔒 — and that flag isn't cosmetic, it deprioritizes the content in feed sort + lead selection. Add a per-source override so admins can correct it after inspecting. - sources.paywall_override: NULL (domain rule) | 'free' | 'paywalled'. - paywall.py: keep low-level is_paywalled(url) (domain); add is_paywalled_for_source (url, override) for the EFFECTIVE decision — never patched the domain helper globally (per Codex), so "domain says X" stays distinguishable from "overridden". - Threaded everywhere ranking/UI touches paywall, via src.paywall_override on the shared _ARTICLE_COLUMNS + the source-aware helper: feed sort, /api/since, replace, lead selection, Article badge, brief composition (briefs.py), digest, source_health (table 🔒), the Articles inspector, and the review/attention check — so ranking and UI always agree. - Endpoint POST /api/admin/sources/{id}/paywall {override}; admin UI: a select in the inspector header (Use domain rule / Treat as free / Treat as paywalled) + the basis ("ON (domain)" / "OFF (override)"), optimistic so the panel stays open. Test: domain rule → paywalled in table+inspector+feed badge; 'free' → off in all three; validation 422 + 404. 242 pytest + 11 vitest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+26
-6
@@ -42,7 +42,7 @@ from .hero import safe_to_lead
|
||||
from .llm import LocalModelClient
|
||||
from .moods import MOODS, mood_filter
|
||||
from .lanes import build_lane_pool
|
||||
from .paywall import is_paywalled
|
||||
from .paywall import is_paywalled, is_paywalled_for_source
|
||||
from .taxonomy import FAMILIES, FLAVORS, TOPICS
|
||||
|
||||
# Edge-cache directives for GLOBAL endpoints — responses that depend only on the
|
||||
@@ -216,7 +216,7 @@ def _pick_lead(items: list[dict]) -> list[dict]:
|
||||
still appear in the set — they just don't lead.
|
||||
"""
|
||||
def gentle(a: dict) -> bool:
|
||||
return safe_to_lead(a) and not is_paywalled(a.get("canonical_url"))
|
||||
return safe_to_lead(a) and not is_paywalled_for_source(a.get("canonical_url"), a.get("paywall_override"))
|
||||
|
||||
for ok in (
|
||||
lambda a: gentle(a) and bool(a.get("image_url")),
|
||||
@@ -290,7 +290,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")),
|
||||
paywalled=is_paywalled_for_source(row.get("canonical_url"), row.get("paywall_override")),
|
||||
tags=[t for t in (raw_tags.split(",") if raw_tags else []) if t],
|
||||
)
|
||||
|
||||
@@ -448,6 +448,10 @@ class SourceVisibilityBody(BaseModel):
|
||||
visible: bool = True
|
||||
|
||||
|
||||
class SourcePaywallBody(BaseModel):
|
||||
override: str | None = None # None = use domain rule · 'free' · 'paywalled'
|
||||
|
||||
|
||||
class CandidateSuggestBody(BaseModel):
|
||||
feed_url: str = ""
|
||||
name: str | None = None
|
||||
@@ -1164,6 +1168,22 @@ def create_app() -> FastAPI:
|
||||
"has_more": len(arts) == limit,
|
||||
}
|
||||
|
||||
@app.post("/api/admin/sources/{sid}/paywall")
|
||||
def admin_source_paywall(sid: int, body: SourcePaywallBody, request: Request) -> dict:
|
||||
# Per-source paywall override: corrects domain-rule false positives
|
||||
# (NY Times Learning is free) / negatives. Threaded into feed/lead/brief
|
||||
# ranking + badges via is_paywalled_for_source.
|
||||
ov = body.override or None
|
||||
if ov not in (None, "free", "paywalled"):
|
||||
raise HTTPException(status_code=422, detail="override must be null, 'free', or 'paywalled'")
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
cur = conn.execute("UPDATE sources SET paywall_override = ? WHERE id = ?", (ov, sid))
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="source not found")
|
||||
conn.commit()
|
||||
return {"ok": True, "override": ov}
|
||||
|
||||
# --- Source candidates (supervised add-a-source pipeline) ----------------
|
||||
|
||||
def _candidate_dict(row) -> dict:
|
||||
@@ -1565,7 +1585,7 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
# 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"]))
|
||||
rows = sorted(rows, key=lambda r: is_paywalled_for_source(r["canonical_url"], r["paywall_override"]))
|
||||
return FeedResponse(
|
||||
topic=topic,
|
||||
flavor=flavor,
|
||||
@@ -1739,7 +1759,7 @@ def create_app() -> FastAPI:
|
||||
rows = queries.feed(conn, sort="latest", since=since, limit=60, **kw)
|
||||
if fp.avoid_terms:
|
||||
rows = filter_articles(rows, fp, now)
|
||||
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
|
||||
rows = sorted(rows, key=lambda r: is_paywalled_for_source(r["canonical_url"], r["paywall_override"]))
|
||||
return FeedResponse(topic=None, flavor=None, count=len(rows), items=[Article.from_row(r) for r in rows[:8]])
|
||||
|
||||
@app.get("/api/brief", response_model=BriefResponse)
|
||||
@@ -1820,7 +1840,7 @@ def create_app() -> FastAPI:
|
||||
for r in filter_articles(rows, fp, now):
|
||||
if r["id"] in excl:
|
||||
continue
|
||||
if avoid_paywall and is_paywalled(r["canonical_url"]):
|
||||
if avoid_paywall and is_paywalled_for_source(r["canonical_url"], r["paywall_override"]):
|
||||
continue
|
||||
if gentle and not safe_to_lead(r):
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user