diff --git a/apps/server/src/modules/sources-identity/service/snapshot.ts b/apps/server/src/modules/sources-identity/service/snapshot.ts index 63a65e29..d0dbee3d 100644 --- a/apps/server/src/modules/sources-identity/service/snapshot.ts +++ b/apps/server/src/modules/sources-identity/service/snapshot.ts @@ -8,7 +8,7 @@ import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { siteCan } from "../site-kinds.ts"; import { enqueueRelogin } from "./account.ts"; -import { type Exec, openDecision } from "./common.ts"; +import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { hasDetailPage, saveDetailPage } from "./detail-page.ts"; // 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。 @@ -129,6 +129,35 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site await enqueueRelogin(rt, site.id); } +/** 站点快照连续失败(改版 / Cloudflare 挑战 / 限流 / 网络,凭据失效另有事项)多少次后开待处理 */ +export const SNAPSHOT_FAIL_DECISION_AT = 3; + +/** 快照连续失败达到阈值 → 开站点不可达事项(按站点去重);恢复(本次成功或部分成功)→ 自动关闭 */ +async function syncSnapshotFailureDecision( + rt: SourcesRuntime, + trx: Exec, + site: SiteRow, + failStreak: number, + err: { kind: string; message: string } | null, +) { + const dedupeKey = `site-snapshot:${site.id}`; + if (failStreak === 0) { + await closeDecisions(rt.ctx, trx, dedupeKey, "resolved", { by: "snapshot_recovered" }, "system"); + return; + } + if (failStreak < SNAPSHOT_FAIL_DECISION_AT || !err) return; + await openDecision(rt.ctx, trx, { + kind: "source_unreachable", + severity: "warning", + title: `站点「${site.name}」列表抓取连续失败`, + summary: `连续 ${failStreak} 次没抓到列表(${err.kind}:${err.message})。可能是站点改版、Cloudflare 验证或限流;检查站点设置或稍后重试,恢复后自动关闭。`, + subjectType: "site", + subjectId: site.id, + dedupeKey, + evidence: { siteId: site.id, kind: err.kind, message: err.message.slice(0, 300), failStreak }, + }); +} + export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Promise { const { db } = rt.ctx; const none = { snapshotId: null, pages: [], itemCount: 0, newCount: 0, releaseIds: [] }; @@ -215,6 +244,9 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr if (error instanceof SiteError && error.isAuthError) { await onAuthError(rt, trx, site, error); } else if (input.kind !== "live_search") { + const prevStreak = Number((site.snapshot_cursor as { failStreak?: unknown } | null)?.failStreak) || 0; + const failStreak = status === "failed" ? prevStreak + 1 : 0; + await syncSnapshotFailureDecision(rt, trx, site, failStreak, errJson); await trx .updateTable("site") .set({ @@ -226,6 +258,7 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr lastItemCount: releases.length, lastStatus: status, lastError: errJson, + failStreak, at: rt.now().toISOString(), }), })