feat(sources): 站点快照连续失败 3 次开站点不可达事项,按站点去重,恢复后自动关闭(M9)

连续失败次数记在 site.snapshot_cursor.failStreak;凭据失效仍走原有登录失效事项。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 23:31:20 +08:00
1 parent 074dab04ca
commit 1959e62f3f
1 file changed
+34 -1
@@ -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<SnapshotResult> {
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(),
}),
})