diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 055acfb..b714dd2 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -119,7 +119,15 @@ const sameServerBrief = view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at; if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) { - brief = { ...fetched, items: view.items }; + // Same server brief: keep the user's pinned order + replacements, but + // refresh server-owned metadata by id. image_url especially is enriched + // AFTER the brief is built (without bumping generated_at), so a verbatim + // pinned copy can stay imageless forever. Items the user swapped in + // (absent from the fresh brief) keep their own data. + const freshById = new Map(fetched.items.map((a) => [a.id, a])); + const items = view.items.map((it) => freshById.get(it.id) ?? it); + brief = { ...fetched, items }; + P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items }); } else { brief = fetched; P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items }); diff --git a/goodnews/enrich.py b/goodnews/enrich.py index f36a707..966e7ac 100644 --- a/goodnews/enrich.py +++ b/goodnews/enrich.py @@ -146,27 +146,36 @@ def fetch_og_image(url: str | None) -> str | None: return None # too many redirects -def enrich_brief_images(conn: sqlite3.Connection, brief_date: str, fetch=fetch_og_image, limit: int = 5) -> int: - """Fetch a hero-quality image for brief items that lack one (once each). +def enrich_brief_images( + conn: sqlite3.Connection, brief_date: str, fetch=fetch_og_image, limit: int = 7, retry_days: int = 2 +) -> int: + """Fetch a hero-quality image for brief items that lack one. - Only touches the given date's brief items with no image and not yet checked; - stamps image_checked_at either way so failures are not retried forever. + Any of the brief's items can become the hero (via the client's fallback or a + replace), so this covers the whole brief (limit defaults to the brief size, 7), + not just the top few. Items already carrying an image are left alone; items + still without one are retried after `retry_days` so a transient fetch failure + or a weaker earlier extractor doesn't mark an article imageless forever. Returns how many images were newly found. """ - # Brief items not yet checked. We fetch even when a feed image exists, - # because feed thumbnails are often tiny and the hero is shown large — a - # page's og:image (the social-share image) is the better hero visual. + # Fetch even when a feed image exists, because feed thumbnails are often tiny + # and the hero is shown large — a page's og:image is the better hero visual. rows = conn.execute( """ SELECT a.id, a.canonical_url FROM daily_briefs b JOIN daily_brief_items bi ON bi.brief_id = b.id JOIN articles a ON a.id = bi.article_id - WHERE b.brief_date = ? AND a.image_checked_at IS NULL + WHERE b.brief_date = ? + AND ( + a.image_checked_at IS NULL + OR ((a.image_url IS NULL OR a.image_url = '') + AND a.image_checked_at < datetime('now', ?)) + ) ORDER BY bi.rank LIMIT ? """, - (brief_date, limit), + (brief_date, f"-{retry_days} days", limit), ).fetchall() found = 0