Paywall awareness (#6) + replace-an-article (#7)

- 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:
jay
2026-05-31 00:39:13 +00:00
parent 06c2704ae0
commit bfd612eb9b
7 changed files with 226 additions and 16 deletions
+52 -3
View File
@@ -61,6 +61,46 @@
refreshPrefs();
}
let notice = $state('');
function flash(msg) {
notice = msg;
if (typeof window !== 'undefined') setTimeout(() => (notice = ''), 4000);
}
async function replaceArticle(article) {
const list = selected === 'today' ? brief?.items : feed;
if (!list) return;
const shown = list.map((a) => a.id).join(',');
const isHero = selected === 'today' && list[0]?.id === article.id;
const merged = P.merge(userPrefs, current?.filter ?? {});
const q = P.param(merged);
const url = `/api/replacement?exclude=${shown}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
let repl;
try {
repl = await getJSON(url);
} catch {
flash('Could not reach the feed just now.');
return;
}
if (!repl) {
flash('Nothing else to swap in right now — try easing a boundary.');
return;
}
if (selected === 'today') {
const i = brief.items.findIndex((a) => a.id === article.id);
if (i >= 0) {
brief.items[i] = repl;
brief = { ...brief, items: [...brief.items] };
}
} else {
const i = feed.findIndex((a) => a.id === article.id);
if (i >= 0) {
feed[i] = repl;
feed = [...feed];
}
}
}
onMount(async () => {
userPrefs = P.load();
try {
@@ -87,6 +127,10 @@
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
{/if}
{#if notice}
<p class="notice rise">{notice}</p>
{/if}
{#if loading}
<p class="muted center pad">Gathering the good news…</p>
{:else if error}
@@ -101,11 +145,11 @@
{#if selected === 'today'}
{#if brief?.items?.length}
<section class="rise">
<ArticleCard article={brief.items[0]} hero onaction={applyAction} />
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} />
{#if brief.items.length > 1}
<div class="grid rest">
{#each brief.items.slice(1) as a (a.id)}
<ArticleCard article={a} onaction={applyAction} />
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
{/each}
</div>
{/if}
@@ -117,7 +161,7 @@
{:else if feed.length}
<div class="grid rise">
{#each feed as a (a.id)}
<ArticleCard article={a} onaction={applyAction} />
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
{/each}
</div>
{:else}
@@ -147,6 +191,11 @@
background: var(--sage); border-radius: 2px; margin-top: 14px; opacity: 0.8;
}
.notice {
text-align: center; color: var(--sage-deep); background: var(--sage-soft);
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content;
font-size: 0.86rem;
}
.rest { margin-top: 18px; }
.center { text-align: center; }
.pad { padding: 48px 0; }