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:
jay
2026-06-12 22:10:44 -04:00
parent 7279b18fdc
commit 2dbe73430c
9 changed files with 130 additions and 28 deletions
+31 -5
View File
@@ -283,6 +283,24 @@
finally { s._artBusy = false; }
}
const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
function pwBasis(sum) {
if (!sum) return '';
if (sum.paywall_override === 'free') return 'OFF (override)';
if (sum.paywall_override === 'paywalled') return 'ON (override)';
return sum.paywalled ? 'ON (domain)' : 'off';
}
// Per-source paywall override — corrects a domain-rule false positive/negative
// and flows into feed/lead/brief ranking + the table's 🔒. Optimistic in place
// so the inspector panel stays open.
async function setPaywall(s, override) {
const ov = override || null;
try {
await postJSON(`/api/admin/sources/${s.id}/paywall`, { override: ov });
const eff = ov === 'free' ? false : ov === 'paywalled' ? true : (s._artSummary?.paywall_domain ?? s.paywalled);
s.paywall_override = ov; s.paywalled = eff; // updates the Media-column 🔒
if (s._artSummary) { s._artSummary.paywall_override = ov; s._artSummary.paywalled = eff; }
} catch (e) { s._artErr = e?.message || 'Could not set the override.'; }
}
// --- Source candidates: supervised "add a source" pipeline ---
let candidates = $state([]);
@@ -726,9 +744,15 @@
{#if s._artSummary}
<div class="artsum">
<strong>{s._artSummary.total}</strong> ingested · {s._artSummary.accepted} accepted ·
{s._artSummary.rejected} rejected · {s._artSummary.no_image} no image ·
{s._artSummary.duplicates} dup ·
paywall rule: <span class="pwrule" class:on={s._artSummary.paywalled}>{s._artSummary.paywalled ? 'ON (domain)' : 'off'}</span>
{s._artSummary.rejected} rejected · {s._artSummary.no_image} no image · {s._artSummary.duplicates} dup
</div>
<div class="pwctl">
<span>Paywall: <span class="pwrule" class:on={s._artSummary.paywalled}>{pwBasis(s._artSummary)}</span></span>
<select class="pwsel" onchange={(e) => setPaywall(s, e.currentTarget.value)}>
<option value="" selected={!s._artSummary.paywall_override}>Use domain rule ({s._artSummary.paywall_domain ? 'on' : 'off'})</option>
<option value="free" selected={s._artSummary.paywall_override === 'free'}>Treat as free</option>
<option value="paywalled" selected={s._artSummary.paywall_override === 'paywalled'}>Treat as paywalled</option>
</select>
</div>
{/if}
<div class="artfilters">
@@ -1259,8 +1283,10 @@
/* Source article inspector */
.srctable tr.artrow td { background: var(--bg); font-size: 0.84rem; padding: 10px 12px; }
.artsum { color: var(--ink); margin-bottom: 8px; }
.artsum .pwrule { color: var(--muted); font-weight: 600; }
.artsum .pwrule.on { color: #9a3b3b; }
.pwrule { color: var(--muted); font-weight: 600; }
.pwrule.on { color: #9a3b3b; }
.pwctl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; font-size: 0.8rem; color: var(--muted); }
.pwsel { font: inherit; font-size: 0.78rem; padding: 3px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); }
.artfilters { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; }
.chip.sm { font-size: 0.74rem; padding: 3px 10px; }
.artlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; max-height: 360px; overflow-y: auto; }