feat(sites): 出口统一与 Cloudflare 过挑战级联(CF-2)

- 出口:站点请求不再隐式继承 HTTP(S)_PROXY,没开代理明确直连(创建客户端时临时拿掉代理环境变量,Deno 2.9 语义见 egress.ts),
  开了走站点代理;TMDB / Brave / 图片改为显式按环境变量(行为不变)
- CloudflareGate:浏览器代理期直接代抓 → HTTP(可复用时带 cf_clearance)→ 挑战 → 浏览器过挑战(同站 30 分钟一次、单飞)
  → cf_clearance 仅在 UA 跟随浏览器且两端同出口时给 HTTP,重试 1 次记 cfReuse → 仍被挑战则浏览器代抓并进入 6 小时代理期
- 浏览器:solveSiteChallenge(自动放行 ≤15s / 系统发起视觉子流程最多 2 次 / 真验证码转人工待处理)、fetchSiteHtml(profile 锁 + 站点令牌)、
  交还后读回 cf_clearance 继续;site.config.cloudflare 状态契约

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 21:26:54 +08:00
1 parent 6b14b0da45
commit 16083942c0
14 files changed
+1711 -43

No files matched your search

+35
View File
@@ -124,6 +124,41 @@ export const SiteLastHealth = z.object({
});
export type SiteLastHealth = z.infer<typeof SiteLastHealth>;
/**
* 站点的 Cloudflare 处理状态(CF-2,存 site.config.cloudflare;docs/runbook/sites.md「Cloudflare」):
* - browserUntil:在此之前该站能走浏览器的页面请求直接由浏览器代抓(HTTP 仍被挑战时置 6 小时,到期再试 HTTP);
* - cfReuse:浏览器拿到的 cf_clearance 给 HTTP 用的结果(ok 有效 / fails 无效,之后不再复用);
* - lastSolvedBy:最近一次怎么过的挑战(auto 等它自己过 / vision 视觉子流程点了一下 / human 人在原站视图里过);
* - pending = human:遇到选图 / 输码类验证码,等人在原站视图里过一次;
* - lastAttemptAt:最近一次尝试过挑战(同站 30 分钟最多一次)。
*/
export const SiteCloudflareState = z.object({
browserUntil: Timestamp.nullable().default(null),
cfReuse: z.enum(["ok", "fails"]).nullable().default(null),
lastChallengeAt: Timestamp.nullable().default(null),
lastSolvedBy: z.enum(["auto", "vision", "human"]).nullable().default(null),
lastSolvedAt: Timestamp.nullable().default(null),
lastAttemptAt: Timestamp.nullable().default(null),
pending: z.enum(["human"]).nullable().default(null),
/** 等人处理的浏览器会话(原站视图接管入口) */
sessionId: Id.nullable().default(null),
/** 最近一次处理结果(技术信息):passed / reuse_ok / reuse_fails / browser / needs_human / failed / throttled / skipped */
lastResult: z.string().nullable().default(null),
/** 命中的挑战规则(技术信息) */
lastRule: z.string().nullable().default(null),
/** 最近一次失败原因(技术信息) */
lastError: z.string().nullable().default(null),
});
export type SiteCloudflareState = z.infer<typeof SiteCloudflareState>;
/** 从 site.config 读 Cloudflare 状态;没有或格式不对返回 null */
export function siteCloudflareState(config: unknown): SiteCloudflareState | null {
const raw = (config as { cloudflare?: unknown } | null | undefined)?.cloudflare;
if (!raw || typeof raw !== "object") return null;
const r = SiteCloudflareState.safeParse(raw);
return r.success ? r.data : null;
}
export const Site = EntityBase.extend({
key: z.string().regex(/^[a-z][a-z0-9_-]*$/),
name: z.string().min(1),