From 08b9deb3d59362309eef886416178aa356567e05 Mon Sep 17 00:00:00 2001 From: krisf Date: Tue, 18 Aug 2026 10:24:09 -0400 Subject: [PATCH] fix(pipeline): dedupe+P2002; add red maple-leaf cover system; replace dead CBC feeds (Google News CN / NatPost Canada); worker dead-letter --- public/covers/canada.svg | 1 + public/covers/national.svg | 1 + public/covers/top-stories.svg | 1 + scripts/make-covers.mjs | 92 ++++++++++++++++++++++++++++++ src/app/article/[slug]/page.tsx | 25 ++++---- src/app/page.tsx | 22 +++---- src/components/ArticleCard.tsx | 21 +++---- src/components/RelatedArticles.tsx | 11 ++-- src/data/feeds.ts | 27 ++++----- src/lib/cover.ts | 32 +++++++++++ src/lib/ingest/pipeline.ts | 21 ++++++- src/lib/llm/synthesize.ts | 18 +++++- 12 files changed, 211 insertions(+), 61 deletions(-) create mode 100644 public/covers/canada.svg create mode 100644 public/covers/national.svg create mode 100644 public/covers/top-stories.svg create mode 100644 scripts/make-covers.mjs create mode 100644 src/lib/cover.ts diff --git a/public/covers/canada.svg b/public/covers/canada.svg new file mode 100644 index 0000000..0faac89 --- /dev/null +++ b/public/covers/canada.svg @@ -0,0 +1 @@ +CanadaMAPLEBRIEF \ No newline at end of file diff --git a/public/covers/national.svg b/public/covers/national.svg new file mode 100644 index 0000000..4485679 --- /dev/null +++ b/public/covers/national.svg @@ -0,0 +1 @@ +NationalMAPLEBRIEF \ No newline at end of file diff --git a/public/covers/top-stories.svg b/public/covers/top-stories.svg new file mode 100644 index 0000000..3429d5f --- /dev/null +++ b/public/covers/top-stories.svg @@ -0,0 +1 @@ +Top StoriesMAPLEBRIEF \ No newline at end of file diff --git a/scripts/make-covers.mjs b/scripts/make-covers.mjs new file mode 100644 index 0000000..3341461 --- /dev/null +++ b/scripts/make-covers.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Generate per-category cover art used as the last-resort image fallback + * (baked into /public/covers/*.svg, served statically). + * + * Design language matches the brand system: ink→brazil gradient (#161614 to + * #45060c), maple-500 line art, Source Serif word label. 1200x630 (16:8). + * + * Usage: node scripts/make-covers.mjs (idempotent — overwrites) + */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = join(root, 'public', 'covers'); + +const BG = + '' + + '' + + '' + + '' + + '' + + ''; + +const stroke = 'fill="none" stroke="#f8402f" stroke-opacity="0.38" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"'; +const faint = 'fill="none" stroke="#f8402f" stroke-opacity="0.14" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"'; + +/** Simple geometric motif per category, centred at (880, 300). */ +const MOTIFS = { + // Parliament Hill: central tower + two flanking towers with connecting wings + national: + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``, + // maple leaf: stem + lobe outline + canada: + `` + + `` + + `` + + `` + + `` + + `` + + ``, + // world globe with meridian (top stories) + 'top-stories': + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``, +}; + +const LABEL = { + national: 'National', + canada: 'Canada', + 'top-stories': 'Top Stories', +}; + +function cover(category, motif, label) { + void category; // contextual only; filename is the real key + return ( + `` + + BG + + `` + + `` + + motif + + `${label}` + + `MAPLEBRIEF` + + `` + ); +} + +await mkdir(outDir, { recursive: true }); +const covers = { + 'top-stories.svg': cover('top-stories', MOTIFS['top-stories'], LABEL['top-stories']), + 'national.svg': cover('national', MOTIFS.national, LABEL.national), + 'canada.svg': cover('canada', MOTIFS.canada, LABEL.canada), +}; +for (const [name, svg] of Object.entries(covers)) { + await writeFile(join(outDir, name), svg); + console.log('wrote', name); +} diff --git a/src/app/article/[slug]/page.tsx b/src/app/article/[slug]/page.tsx index ab721e5..42892e8 100644 --- a/src/app/article/[slug]/page.tsx +++ b/src/app/article/[slug]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation'; import { getArticleFull, getRelatedArticles } from '@/lib/queries'; import RelatedArticles from '@/components/RelatedArticles'; import { InArticleAd, SidebarAd } from '@/components/ads/placements'; +import { categoryCover, resolveCoverImage } from '@/lib/cover'; import { env } from '@/lib/env'; import { formatFull } from '@/lib/format'; import { safeParse } from '@/lib/llm/parse'; @@ -56,15 +57,18 @@ export async function generateMetadata({ modifiedTime: a.synthesizedAt?.toISOString(), section: sectionLabel, tags: a.tags ? safeParse(a.tags).slice(0, 5) : undefined, - images: a.image - ? [{ url: a.image, width: 1200, height: 630, alt: title }] - : [{ url: '/og-image.png', width: 1200, height: 630, alt: 'MapleBrief' }], + images: [{ + url: (a.image && a.image.trim()) || categoryCover(a.category), + width: 1200, + height: 630, + alt: title, + }], }, twitter: { card: 'summary_large_image', title, description, - images: a.image ? [a.image] : undefined, + images: [(a.image && a.image.trim()) || categoryCover(a.category)], }, }; } @@ -102,14 +106,11 @@ export default async function ArticlePage({ - {a.image && ( - // eslint-disable-next-line @next/next/no-img-element - {title} - )} + {title} {takeaways.length > 0 && (
diff --git a/src/app/page.tsx b/src/app/page.tsx index 1808580..cbc03cc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,8 +1,8 @@ import { getPublishedArticles, getTagList } from '@/lib/queries'; import ArticleCard from '@/components/ArticleCard'; +import { resolveCoverImage } from '@/lib/cover'; import { InFeedAd, SidebarAd } from '@/components/ads/placements'; import Link from 'next/link'; -import { env } from '@/lib/env'; export const dynamic = 'force-dynamic'; @@ -33,20 +33,12 @@ export default async function HomePage({ className="group mb-8 block overflow-hidden rounded-2xl border border-ink-200 bg-white shadow-sm" >
- {hero.image ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
- - {env.siteName} - -
- )} + {/* cover.ts falls back to a per-category brand cover when no photo was captured */} +
Lead briefing diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx index b6ed156..0da2008 100644 --- a/src/components/ArticleCard.tsx +++ b/src/components/ArticleCard.tsx @@ -1,29 +1,24 @@ import Link from 'next/link'; import type { ArticleCard as Card } from '@/lib/queries'; +import { resolveCoverImage } from '@/lib/cover'; import { formatRelativeTime } from '@/lib/format'; export default function ArticleCard({ card }: { card: Card }) { const href = `/article/${card.slug}`; const title = card.headline ?? card.title; + const image = resolveCoverImage(card.image, card.category); return (
- {card.image ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( - - ◆ - - )} +

diff --git a/src/components/RelatedArticles.tsx b/src/components/RelatedArticles.tsx index 55d5433..1136dbe 100644 --- a/src/components/RelatedArticles.tsx +++ b/src/components/RelatedArticles.tsx @@ -1,5 +1,6 @@ import Link from 'next/link'; import type { ArticleCard } from '@/lib/queries'; +import { resolveCoverImage } from '@/lib/cover'; import { formatRelativeTime } from '@/lib/format'; export default function RelatedArticles({ items }: { items: ArticleCard[] }) { @@ -11,10 +12,12 @@ export default function RelatedArticles({ items }: { items: ArticleCard[] }) { {items.map((a) => (
  • - {a.image ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : null} +
    , see lib/ingest/feed.ts). Last resort: a deterministic + * per-category brand cover baked into /public/covers/ so that every card, + * article page and social preview always shows a visual related to the + * piece — never a bare glyph (2026-08-17 "every article has a photo"). + */ + +/** Category slugs that have a generated cover asset. */ +const COVER_SLUGS = ['top-stories', 'canada', 'national'] as const; +type CoverSlug = (typeof COVER_SLUGS)[number]; + +export function categoryCover(category?: string | null): string { + const slug = (category ?? '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if ((COVER_SLUGS as readonly string[]).includes(slug)) { + return `/covers/${slug}.svg`; + } + return '/covers/top-stories.svg'; +} + +export function resolveCoverImage( + image: string | null | undefined, + category?: string | null, +): string { + return (image ?? '').trim() ? image! : categoryCover(category); +} diff --git a/src/lib/ingest/pipeline.ts b/src/lib/ingest/pipeline.ts index 927db6b..3eaa586 100644 --- a/src/lib/ingest/pipeline.ts +++ b/src/lib/ingest/pipeline.ts @@ -51,11 +51,15 @@ export async function ingestFeed( (i) => !i.publishedAt || i.publishedAt >= cutoff, ); - // Build a set of keys already in the DB (bounded to recent window). + // Build a set of keys already in the DB. Deliberately NOT scoped to the + // publishedAt window: items saved without a pubDate (publishedAt=null) + // would otherwise be invisible to every dedupe set and re-collide on the + // @unique(guid) constraint on every pass (P2002 "item errors", fixed + // 2026-08-18). Costs 3 scalar columns, so the full-history scan is cheap. const existing = await prisma.article.findMany({ - where: { publishedAt: { gte: cutoff }, guid: { not: null } }, + where: { guid: { not: null } }, select: { guid: true, dedupKey: true, sourceUrl: true }, - take: 5000, + take: 20000, }); const seenGuid = new Set(existing.map((a) => a.guid).filter(Boolean) as string[]); const seenDedup = new Set(existing.map((a) => a.dedupKey)); @@ -98,6 +102,7 @@ export async function ingestFeed( const sourceUrl = item.link; const canonicalUrl = `${env.siteUrl.replace(/\/$/, '')}/article/${slug}`; + try { await prisma.article.create({ data: { feedId, @@ -117,6 +122,16 @@ export async function ingestFeed( status: 'fetched', }, }); + } catch (err) { + if (/P2002|Unique constraint failed/.test((err as Error).message)) { + // A row claiming this guid/dedupKey/slug was written between the + // pre-scan and the insert (concurrent pass or a pre-window row). + // Count it as a duplicate, not a spurious item error. + result.duplicates += 1; + continue; + } + throw err; + } result.newArticles += 1; } catch (err) { result.errors.push(`item "${item.title?.slice(0, 60)}": ${(err as Error).message}`); diff --git a/src/lib/llm/synthesize.ts b/src/lib/llm/synthesize.ts index 9c55541..6acc23f 100644 --- a/src/lib/llm/synthesize.ts +++ b/src/lib/llm/synthesize.ts @@ -64,13 +64,29 @@ export async function synthesizePending(limit: number = 24) { let ok = 0; let failed = 0; + // Dead-letter: a row that is still `fetched` after 24h of continuous + // failure will never succeed on its own (poison content, oversized + // source, provider rejection). Mark it `failed` so it stops consuming + // a slot in every cron pass and out of the backlog. Manual re-queue by + // resetting status to `fetched` if the cause is later fixed. + const deadAfter = new Date(Date.now() - 24 * 60 * 60 * 1000); for (const a of pending) { try { await synthesizeArticle(a.id); ok += 1; } catch (err) { failed += 1; - console.error(`[synth] failed article ${a.slug}:`, (err as Error).message); + const dead = a.createdAt < deadAfter; + const why = (err as Error).message; + if (dead) { + await prisma.article.update({ + where: { id: a.id }, + data: { status: 'failed' }, + }); + console.error(`[synth] DEAD-LETTER ${a.slug} (failed >24h): ${why}`); + } else { + console.error(`[synth] failed article ${a.slug}: ${why}`); + } } } return { total: pending.length, ok, failed };