Compare commits

..
2 Commits
Author SHA1 Message Date
krisf 92373aa3c6 fix(entrypoint): arm cron worker via self-ping after boot
instrumentationHook is disabled in the standalone build, so node-cron only
arms on POST /api/worker/ping. Without a self-ping, a fresh container or
restart runs zero ingest passes (worker silently never armed) — exactly
what happened on the :8 deploy. Adds the same self-ping block as
technews/finance entrypoints (identical).
2026-08-18 11:22:12 -04:00
krisf 08b9deb3d5 fix(pipeline): dedupe+P2002; add red maple-leaf cover system; replace dead CBC feeds (Google News CN / NatPost Canada); worker dead-letter 2026-08-18 10:24:09 -04:00
13 changed files with 228 additions and 61 deletions
+17
View File
@@ -24,4 +24,21 @@ if [ ! -f "$DB" ]; then
echo "[entrypoint] initialized $DB from seed snapshot" echo "[entrypoint] initialized $DB from seed snapshot"
fi fi
# Arm the ingest/synthesis worker once the server answers.
# The Next standalone build has instrumentationHook disabled, so node-cron
# only starts when a request reaches /api/worker/ping. Pinging ourselves
# after boot means a fresh container or restart re-arms the worker (and
# triggers the boot pipeline pass) without any external health pinger.
(
APP_IP="$(hostname -i 2>/dev/null | awk '{print $1}')"
[ -z "$APP_IP" ] && APP_IP=127.0.0.1
i=0
until curl -sf -o /dev/null --max-time 2 "http://${APP_IP}:${PORT:-3000}/api/worker/ping"; do
i=$((i + 1))
[ "$i" -ge 90 ] && exit 0
sleep 1
done
echo "[entrypoint] armed cron worker via /api/worker/ping"
) >/dev/null 2>&1 &
exec node server.js exec node server.js
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="Canada — MapleBrief"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#161614"/><stop offset="0.55" stop-color="#2b2b28"/><stop offset="1.3" stop-color="#45060c"/></linearGradient><pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse"><path d="M48 0H0V48" fill="none" stroke="#f8402f" stroke-opacity="0.10" stroke-width="1"/></pattern></defs><rect width="1200" height="630" fill="url(#bg)"/><rect width="1200" height="630" fill="url(#grid)"/><g fill="none" stroke="#f8402f" stroke-opacity="0.38" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"><path d="M880 130L904 196L964 182L934 236L1006 250L948 296L1006 338L934 346L964 402L904 386L880 452L856 386L796 402L826 346L754 338L812 296L754 250L826 236L796 182L856 196Z"/><path d="M880 452V520"/></g><g fill="none" stroke="#f8402f" stroke-opacity="0.14" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M700 452H1060"/></g><text x="80" y="556" font-family="Georgia, 'Times New Roman', serif" font-size="44" font-weight="700" fill="#ffd9d4" fill-opacity="0.92">Canada</text><text x="80" y="592" font-family="Inter, 'Segoe UI', sans-serif" font-size="18" letter-spacing="4" fill="#f8402f" fill-opacity="0.55">MAPLEBRIEF</text></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="National — MapleBrief"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#161614"/><stop offset="0.55" stop-color="#2b2b28"/><stop offset="1.3" stop-color="#45060c"/></linearGradient><pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse"><path d="M48 0H0V48" fill="none" stroke="#f8402f" stroke-opacity="0.10" stroke-width="1"/></pattern></defs><rect width="1200" height="630" fill="url(#bg)"/><rect width="1200" height="630" fill="url(#grid)"/><g fill="none" stroke="#f8402f" stroke-opacity="0.38" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"><path d="M760 440V300M880 440V180M1000 440V300"/><path d="M850 180L880 120L910 180" stroke-width="7"/><path d="M782 300L760 268L738 300M978 300L1000 268L1022 300" stroke-width="7"/><path d="M760 380H1000M820 340H940" stroke-width="7"/></g><g fill="none" stroke="#f8402f" stroke-opacity="0.14" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M700 440H1060M700 160H1060"/></g><text x="80" y="556" font-family="Georgia, 'Times New Roman', serif" font-size="44" font-weight="700" fill="#ffd9d4" fill-opacity="0.92">National</text><text x="80" y="592" font-family="Inter, 'Segoe UI', sans-serif" font-size="18" letter-spacing="4" fill="#f8402f" fill-opacity="0.55">MAPLEBRIEF</text></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="Top Stories — MapleBrief"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#161614"/><stop offset="0.55" stop-color="#2b2b28"/><stop offset="1.3" stop-color="#45060c"/></linearGradient><pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse"><path d="M48 0H0V48" fill="none" stroke="#f8402f" stroke-opacity="0.10" stroke-width="1"/></pattern></defs><rect width="1200" height="630" fill="url(#bg)"/><rect width="1200" height="630" fill="url(#grid)"/><g fill="none" stroke="#f8402f" stroke-opacity="0.38" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"><circle cx="880" cy="305" r="150"/><ellipse cx="880" cy="305" rx="64" ry="150"/><path d="M736 254H1024M730 356H1030"/></g><g fill="none" stroke="#f8402f" stroke-opacity="0.14" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M700 155H1060M700 455H1060"/></g><text x="80" y="556" font-family="Georgia, 'Times New Roman', serif" font-size="44" font-weight="700" fill="#ffd9d4" fill-opacity="0.92">Top Stories</text><text x="80" y="592" font-family="Inter, 'Segoe UI', sans-serif" font-size="18" letter-spacing="4" fill="#f8402f" fill-opacity="0.55">MAPLEBRIEF</text></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+92
View File
@@ -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 =
'<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0" stop-color="#161614"/><stop offset="0.55" stop-color="#2b2b28"/>' +
'<stop offset="1.3" stop-color="#45060c"/></linearGradient>' +
'<pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse">' +
'<path d="M48 0H0V48" fill="none" stroke="#f8402f" stroke-opacity="0.10" stroke-width="1"/>' +
'</pattern></defs>';
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:
`<g ${stroke}>` +
`<path d="M760 440V300M880 440V180M1000 440V300"/>` +
`<path d="M850 180L880 120L910 180" stroke-width="7"/>` +
`<path d="M782 300L760 268L738 300M978 300L1000 268L1022 300" stroke-width="7"/>` +
`<path d="M760 380H1000M820 340H940" stroke-width="7"/>` +
`</g>` +
`<g ${faint}>` +
`<path d="M700 440H1060M700 160H1060"/>` +
`</g>`,
// maple leaf: stem + lobe outline
canada:
`<g ${stroke}>` +
`<path d="M880 130L904 196L964 182L934 236L1006 250L948 296L1006 338L934 346L964 402L904 386L880 452L856 386L796 402L826 346L754 338L812 296L754 250L826 236L796 182L856 196Z"/>` +
`<path d="M880 452V520"/>` +
`</g>` +
`<g ${faint}>` +
`<path d="M700 452H1060"/>` +
`</g>`,
// world globe with meridian (top stories)
'top-stories':
`<g ${stroke}>` +
`<circle cx="880" cy="305" r="150"/>` +
`<ellipse cx="880" cy="305" rx="64" ry="150"/>` +
`<path d="M736 254H1024M730 356H1030"/>` +
`</g>` +
`<g ${faint}>` +
`<path d="M700 155H1060M700 455H1060"/>` +
`</g>`,
};
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 (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="${label} — MapleBrief">` +
BG +
`<rect width="1200" height="630" fill="url(#bg)"/>` +
`<rect width="1200" height="630" fill="url(#grid)"/>` +
motif +
`<text x="80" y="556" font-family="Georgia, 'Times New Roman', serif" font-size="44" font-weight="700" fill="#ffd9d4" fill-opacity="0.92">${label}</text>` +
`<text x="80" y="592" font-family="Inter, 'Segoe UI', sans-serif" font-size="18" letter-spacing="4" fill="#f8402f" fill-opacity="0.55">MAPLEBRIEF</text>` +
`</svg>`
);
}
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);
}
+9 -8
View File
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
import { getArticleFull, getRelatedArticles } from '@/lib/queries'; import { getArticleFull, getRelatedArticles } from '@/lib/queries';
import RelatedArticles from '@/components/RelatedArticles'; import RelatedArticles from '@/components/RelatedArticles';
import { InArticleAd, SidebarAd } from '@/components/ads/placements'; import { InArticleAd, SidebarAd } from '@/components/ads/placements';
import { categoryCover, resolveCoverImage } from '@/lib/cover';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { formatFull } from '@/lib/format'; import { formatFull } from '@/lib/format';
import { safeParse } from '@/lib/llm/parse'; import { safeParse } from '@/lib/llm/parse';
@@ -56,15 +57,18 @@ export async function generateMetadata({
modifiedTime: a.synthesizedAt?.toISOString(), modifiedTime: a.synthesizedAt?.toISOString(),
section: sectionLabel, section: sectionLabel,
tags: a.tags ? safeParse<string[]>(a.tags).slice(0, 5) : undefined, tags: a.tags ? safeParse<string[]>(a.tags).slice(0, 5) : undefined,
images: a.image images: [{
? [{ url: a.image, width: 1200, height: 630, alt: title }] url: (a.image && a.image.trim()) || categoryCover(a.category),
: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'MapleBrief' }], width: 1200,
height: 630,
alt: title,
}],
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
title, title,
description, 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({
</h1> </h1>
</header> </header>
{a.image && (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={a.image} src={resolveCoverImage(a.image, a.category)}
alt={title} alt={title}
className="mt-6 aspect-[16/8] w-full rounded-2xl object-cover" className="mt-6 aspect-[16/8] w-full rounded-2xl object-cover"
/> />
)}
{takeaways.length > 0 && ( {takeaways.length > 0 && (
<section className="mt-8 rounded-xl border-l-4 border-maple-600 bg-maple-50 p-5"> <section className="mt-8 rounded-xl border-l-4 border-maple-600 bg-maple-50 p-5">
+3 -11
View File
@@ -1,8 +1,8 @@
import { getPublishedArticles, getTagList } from '@/lib/queries'; import { getPublishedArticles, getTagList } from '@/lib/queries';
import ArticleCard from '@/components/ArticleCard'; import ArticleCard from '@/components/ArticleCard';
import { resolveCoverImage } from '@/lib/cover';
import { InFeedAd, SidebarAd } from '@/components/ads/placements'; import { InFeedAd, SidebarAd } from '@/components/ads/placements';
import Link from 'next/link'; import Link from 'next/link';
import { env } from '@/lib/env';
export const dynamic = 'force-dynamic'; 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" className="group mb-8 block overflow-hidden rounded-2xl border border-ink-200 bg-white shadow-sm"
> >
<div className="relative aspect-[16/8] overflow-hidden bg-ink-100"> <div className="relative aspect-[16/8] overflow-hidden bg-ink-100">
{hero.image ? ( {/* cover.ts falls back to a per-category brand cover when no photo was captured */}
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={hero.image} src={resolveCoverImage(hero.image, hero.category)}
alt="" alt=""
className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.02]" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.02]"
/> />
) : (
<div className="flex h-full items-center justify-center bg-masthead-gradient">
<span className="font-display text-2xl font-bold text-white/90">
{env.siteName}
</span>
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-ink-950/95 via-ink-950/70 to-transparent px-4 pb-4 pt-10 sm:px-5 sm:pb-5 sm:pt-16"> <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-ink-950/95 via-ink-950/70 to-transparent px-4 pb-4 pt-10 sm:px-5 sm:pb-5 sm:pt-16">
<span className="mb-2 inline-block rounded bg-maple-600 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider text-white"> <span className="mb-2 inline-block rounded bg-maple-600 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider text-white">
Lead briefing Lead briefing
+3 -8
View File
@@ -1,29 +1,24 @@
import Link from 'next/link'; import Link from 'next/link';
import type { ArticleCard as Card } from '@/lib/queries'; import type { ArticleCard as Card } from '@/lib/queries';
import { resolveCoverImage } from '@/lib/cover';
import { formatRelativeTime } from '@/lib/format'; import { formatRelativeTime } from '@/lib/format';
export default function ArticleCard({ card }: { card: Card }) { export default function ArticleCard({ card }: { card: Card }) {
const href = `/article/${card.slug}`; const href = `/article/${card.slug}`;
const title = card.headline ?? card.title; const title = card.headline ?? card.title;
const image = resolveCoverImage(card.image, card.category);
return ( return (
<article className="group flex flex-col overflow-hidden rounded-xl border border-ink-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md"> <article className="group flex flex-col overflow-hidden rounded-xl border border-ink-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<Link <Link
href={href} href={href}
className="relative block aspect-[16/9] overflow-hidden bg-ink-100" className="relative block aspect-[16/9] overflow-hidden bg-ink-100"
> >
{card.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={card.image} src={image}
alt="" alt=""
loading="lazy" loading="lazy"
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
/> />
) : (
<span className="flex h-full items-center justify-center font-display text-4xl text-ink-300">
</span>
)}
</Link> </Link>
<div className="flex flex-1 flex-col p-4"> <div className="flex flex-1 flex-col p-4">
<h3 className="font-display text-lg font-bold leading-snug text-ink-900 group-hover:text-maple-700"> <h3 className="font-display text-lg font-bold leading-snug text-ink-900 group-hover:text-maple-700">
+7 -4
View File
@@ -1,5 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import type { ArticleCard } from '@/lib/queries'; import type { ArticleCard } from '@/lib/queries';
import { resolveCoverImage } from '@/lib/cover';
import { formatRelativeTime } from '@/lib/format'; import { formatRelativeTime } from '@/lib/format';
export default function RelatedArticles({ items }: { items: ArticleCard[] }) { export default function RelatedArticles({ items }: { items: ArticleCard[] }) {
@@ -11,10 +12,12 @@ export default function RelatedArticles({ items }: { items: ArticleCard[] }) {
{items.map((a) => ( {items.map((a) => (
<li key={a.id} className="flex gap-3"> <li key={a.id} className="flex gap-3">
<div className="h-16 w-24 flex-shrink-0 overflow-hidden rounded-md bg-ink-100"> <div className="h-16 w-24 flex-shrink-0 overflow-hidden rounded-md bg-ink-100">
{a.image ? ( <img
// eslint-disable-next-line @next/next/no-img-element src={resolveCoverImage(a.image, a.category)}
<img src={a.image} alt="" loading="lazy" className="h-full w-full object-cover" /> alt=""
) : null} loading="lazy"
className="h-full w-full object-cover"
/>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<Link <Link
+14 -13
View File
@@ -14,23 +14,24 @@ export interface FeedSeed {
export const STARTER_FEEDS: FeedSeed[] = [ export const STARTER_FEEDS: FeedSeed[] = [
{ {
// NOTE: these cbc-stats URLs are the official CBC feed paths from the // Replacement for CBC News — Top Stories: cbc-stats returned 404 for
// project spec, but as of 2026-08-15 CBC returns 404 for every public // every public RSS path (re-verified from egress 2026-08-18). Google
// RSS endpoint (repeatedly re-verified with multiple UAs). Keep them so // News Canada hub gives a dense national top-stories stream (100 items).
// they light up if CBC restores the feeds; disable in `Feed.enabled` name: 'Google News — Canada',
// or swap URLs in the DB if the 404s bother you (see README → Feeds). url: 'https://news.google.com/rss/search?q=canada%20when:48h&hl=en-CA&gl=CA&ceid=CA:en',
name: 'CBC News — Top Stories', slug: 'gn-canada',
url: 'https://www.cbc.ca/cbc-stats/rss/rss-topstories.xml',
slug: 'cbc-top-stories',
category: 'top-stories', category: 'top-stories',
siteName: 'CBC News', siteName: 'Google News',
}, },
{ {
name: 'CBC News — Canada', // Replacement for CBC News — Canada (cbc-stats 404, re-verified
url: 'https://www.cbc.ca/cbc-stats/rss/rss-canada.xml', // 2026-08-18). National Post Canada section — distinct from the
slug: 'cbc-canada', // National Post News feed below; cross-feed GUIDs dedupe at ingest.
name: 'National Post — Canada',
url: 'https://nationalpost.com/category/canada/feed',
slug: 'national-post-canada',
category: 'canada', category: 'canada',
siteName: 'CBC News', siteName: 'National Post',
}, },
{ {
// Live Arc outbound feed (verified 2026-08). The legacy // Live Arc outbound feed (verified 2026-08). The legacy
+32
View File
@@ -0,0 +1,32 @@
/**
* Resolve the display image for an article.
*
* Preferred: the story photo captured at ingest (og:image → twitter card →
* hero <img>, 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);
}
+18 -3
View File
@@ -51,11 +51,15 @@ export async function ingestFeed(
(i) => !i.publishedAt || i.publishedAt >= cutoff, (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({ const existing = await prisma.article.findMany({
where: { publishedAt: { gte: cutoff }, guid: { not: null } }, where: { guid: { not: null } },
select: { guid: true, dedupKey: true, sourceUrl: true }, 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 seenGuid = new Set(existing.map((a) => a.guid).filter(Boolean) as string[]);
const seenDedup = new Set(existing.map((a) => a.dedupKey)); const seenDedup = new Set(existing.map((a) => a.dedupKey));
@@ -98,6 +102,7 @@ export async function ingestFeed(
const sourceUrl = item.link; const sourceUrl = item.link;
const canonicalUrl = `${env.siteUrl.replace(/\/$/, '')}/article/${slug}`; const canonicalUrl = `${env.siteUrl.replace(/\/$/, '')}/article/${slug}`;
try {
await prisma.article.create({ await prisma.article.create({
data: { data: {
feedId, feedId,
@@ -117,6 +122,16 @@ export async function ingestFeed(
status: 'fetched', 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; result.newArticles += 1;
} catch (err) { } catch (err) {
result.errors.push(`item "${item.title?.slice(0, 60)}": ${(err as Error).message}`); result.errors.push(`item "${item.title?.slice(0, 60)}": ${(err as Error).message}`);
+17 -1
View File
@@ -64,13 +64,29 @@ export async function synthesizePending(limit: number = 24) {
let ok = 0; let ok = 0;
let failed = 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) { for (const a of pending) {
try { try {
await synthesizeArticle(a.id); await synthesizeArticle(a.id);
ok += 1; ok += 1;
} catch (err) { } catch (err) {
failed += 1; 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 }; return { total: pending.length, ok, failed };