Archived
Compare commits
2
Commits
44d3220c4d
...
92373aa3c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92373aa3c6 | ||
|
|
08b9deb3d5 |
@@ -24,4 +24,21 @@ if [ ! -f "$DB" ]; then
|
||||
echo "[entrypoint] initialized $DB from seed snapshot"
|
||||
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
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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<string[]>(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({
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{a.image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.image}
|
||||
src={resolveCoverImage(a.image, a.category)}
|
||||
alt={title}
|
||||
className="mt-6 aspect-[16/8] w-full rounded-2xl object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{takeaways.length > 0 && (
|
||||
<section className="mt-8 rounded-xl border-l-4 border-maple-600 bg-maple-50 p-5">
|
||||
|
||||
+3
-11
@@ -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"
|
||||
>
|
||||
<div className="relative aspect-[16/8] overflow-hidden bg-ink-100">
|
||||
{hero.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
{/* cover.ts falls back to a per-category brand cover when no photo was captured */}
|
||||
<img
|
||||
src={hero.image}
|
||||
src={resolveCoverImage(hero.image, hero.category)}
|
||||
alt=""
|
||||
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">
|
||||
<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
|
||||
|
||||
@@ -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 (
|
||||
<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
|
||||
href={href}
|
||||
className="relative block aspect-[16/9] overflow-hidden bg-ink-100"
|
||||
>
|
||||
{card.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={card.image}
|
||||
src={image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
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>
|
||||
<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">
|
||||
|
||||
@@ -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) => (
|
||||
<li key={a.id} className="flex gap-3">
|
||||
<div className="h-16 w-24 flex-shrink-0 overflow-hidden rounded-md bg-ink-100">
|
||||
{a.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={a.image} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
<img
|
||||
src={resolveCoverImage(a.image, a.category)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
|
||||
+14
-13
@@ -14,23 +14,24 @@ export interface FeedSeed {
|
||||
|
||||
export const STARTER_FEEDS: FeedSeed[] = [
|
||||
{
|
||||
// NOTE: these cbc-stats URLs are the official CBC feed paths from the
|
||||
// project spec, but as of 2026-08-15 CBC returns 404 for every public
|
||||
// RSS endpoint (repeatedly re-verified with multiple UAs). Keep them so
|
||||
// they light up if CBC restores the feeds; disable in `Feed.enabled`
|
||||
// or swap URLs in the DB if the 404s bother you (see README → Feeds).
|
||||
name: 'CBC News — Top Stories',
|
||||
url: 'https://www.cbc.ca/cbc-stats/rss/rss-topstories.xml',
|
||||
slug: 'cbc-top-stories',
|
||||
// Replacement for CBC News — Top Stories: cbc-stats returned 404 for
|
||||
// every public RSS path (re-verified from egress 2026-08-18). Google
|
||||
// News Canada hub gives a dense national top-stories stream (100 items).
|
||||
name: 'Google News — Canada',
|
||||
url: 'https://news.google.com/rss/search?q=canada%20when:48h&hl=en-CA&gl=CA&ceid=CA:en',
|
||||
slug: 'gn-canada',
|
||||
category: 'top-stories',
|
||||
siteName: 'CBC News',
|
||||
siteName: 'Google News',
|
||||
},
|
||||
{
|
||||
name: 'CBC News — Canada',
|
||||
url: 'https://www.cbc.ca/cbc-stats/rss/rss-canada.xml',
|
||||
slug: 'cbc-canada',
|
||||
// Replacement for CBC News — Canada (cbc-stats 404, re-verified
|
||||
// 2026-08-18). National Post Canada section — distinct from the
|
||||
// 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',
|
||||
siteName: 'CBC News',
|
||||
siteName: 'National Post',
|
||||
},
|
||||
{
|
||||
// Live Arc outbound feed (verified 2026-08). The legacy
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user