fix(sites): 观众健康检查误报未登录,健康失败原因细分(HC-1)

观众 2026 版首页没有 <a href=logout>:退出按钮在 userbar 的 data-uploader-actions JSON
里由脚本渲染,旧登录标志 a[href*="logout"] 永远不命中,健康检查报"未登录"而用户数据正常。

- audiences 定义 loginSuccess 改为 #info_block / usercp.php / logout 任一(0.2.1)
- 健康检查判定细分:登录页 / 权限页 / 疑似人机验证 / 找不到登录标志(带选择器),
  原始判定写入 last_health.detail,站点概览技术信息展示
- 首页只是找不到登录标志时,以 24 小时内用户数据解析成功为已登录证据(同 MP)
- 新 SiteError 类 challenge(cf-mitigated: challenge),不算凭据错误;健康状态 challenge
- testkit mock 增加 setHeaderLayout;加观众首页脱敏结构样本的差分测试

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 20:38:48 +08:00
1 parent 322ecb2f8c
commit b1d30bd6ee
16 files changed
+433 -27

No files matched your search

+83 -7
View File
@@ -20,6 +20,7 @@ import {
type SiteCallOptions,
SiteError,
type SiteHealth,
type SiteHealthDetail,
type SiteListFilters,
type SitePage,
type SiteReleaseDetail,
@@ -52,11 +53,59 @@ export function healthFromError(siteId: string, e: unknown, checkedAt: string):
: e.kind === "not_found" || e.kind === "unsupported"
? "parse_error"
: e.kind;
return { siteId, status, ok: false, checkedAt, latencyMs: null, message: e.message };
const detail = e.detail ?? (e.httpStatus !== undefined ? { reason: "http", httpStatus: e.httpStatus } : undefined);
return {
siteId,
status,
ok: false,
checkedAt,
latencyMs: null,
message: e.message,
...(detail ? { detail } : {}),
};
}
return { siteId, status: "unreachable", ok: false, checkedAt, latencyMs: null, message: redactText(String(e)) };
}
/**
* 健康检查的首页登录判定:登录页 / 权限页 → 未登录;登录标志选择器命中 → 已登录;
* 否则区分"疑似人机验证"与"页面里找不到登录标志"。detail 是原始判定(不含页面里的个人数据)。
*/
export function judgeLoginPage(
html: string,
loginSuccess: string | undefined,
): { ok: true; detail: SiteHealthDetail } | { ok: false; message: string; detail: SiteHealthDetail } {
const $ = loadHtml(html);
const base = { path: "index.php", title: $("title").first().text().trim().slice(0, 80) };
const login = SiteSpider.loginPageRule($);
if (login) {
return {
ok: false,
message:
login.kind === "login_page"
? "健康检查:首页返回登录页,Cookie 已失效"
: "健康检查:首页返回权限提示页(要求先登录)",
detail: { ...base, reason: login.kind, rule: login.rule },
};
}
if (!loginSuccess || $(loginSuccess).length > 0) {
return { ok: true, detail: { ...base, reason: "logged_in", ...(loginSuccess ? { selector: loginSuccess } : {}) } };
}
const challenge = SiteSpider.challengeRule($, html);
if (challenge) {
return {
ok: false,
message: "健康检查:疑似人机验证(Cloudflare),无法确认登录态",
detail: { ...base, reason: "challenge", rule: challenge },
};
}
return {
ok: false,
message: `健康检查:首页里找不到登录标志(选择器 ${loginSuccess})`,
detail: { ...base, reason: "marker_missing", selector: loginSuccess, length: html.length },
};
}
export function createSpiderSiteAdapter(input: IndexerDefinition, deps: SiteAdapterDeps): SiteAdapter {
const def: IndexerDefinition = deps.baseUrl ? { ...input, domain: deps.baseUrl } : input;
const ext = def.umt;
@@ -79,9 +128,22 @@ export function createSpiderSiteAdapter(input: IndexerDefinition, deps: SiteAdap
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get("location") ?? "";
await res.body?.cancel().catch(() => {});
if (/login/i.test(loc)) throw new SiteError(def.id, "auth_expired", `${what}:跳转登录页,Cookie 已失效`);
if (/login/i.test(loc)) {
throw new SiteError(def.id, "auth_expired", `${what}:跳转登录页,Cookie 已失效`, {
httpStatus: res.status,
detail: { reason: "redirect_login", httpStatus: res.status, location: redactText(loc).slice(0, 200) },
});
}
throw new SiteError(def.id, "parse_error", redactText(`${what}:意外重定向到 ${loc}`), { httpStatus: res.status });
}
// Cloudflare 挑战响应带 cf-mitigated: challenge(403 / 503)
if (res.headers.get("cf-mitigated") === "challenge") {
await res.body?.cancel().catch(() => {});
throw new SiteError(def.id, "challenge", `${what}:疑似人机验证(Cloudflare)`, {
httpStatus: res.status,
detail: { reason: "challenge", rule: "header cf-mitigated=challenge", httpStatus: res.status },
});
}
if (res.status === 401 || res.status === 403) {
await res.body?.cancel().catch(() => {});
throw new SiteError(def.id, "auth_invalid", `${what}:HTTP ${res.status}`, { httpStatus: res.status });
@@ -212,6 +274,13 @@ export function createSpiderSiteAdapter(input: IndexerDefinition, deps: SiteAdap
if (/login/i.test(loc)) throw new SiteError(def.id, "auth_expired", `${what}:跳转登录页,Cookie 已失效`);
throw new SiteError(def.id, "parse_error", `${what}:意外重定向`, { httpStatus: res.status });
}
if (res.headers.get("cf-mitigated") === "challenge") {
await res.body?.cancel().catch(() => {});
throw new SiteError(def.id, "challenge", `${what}:疑似人机验证(Cloudflare)`, {
httpStatus: res.status,
detail: { reason: "challenge", rule: "header cf-mitigated=challenge", httpStatus: res.status },
});
}
if (!res.ok) {
await res.body?.cancel().catch(() => {});
throw new SiteError(def.id, res.status === 404 ? "not_found" : "network", `${what}:HTTP ${res.status}`, {
@@ -248,11 +317,18 @@ export function createSpiderSiteAdapter(input: IndexerDefinition, deps: SiteAdap
const checkedAt = http.now().toISOString();
try {
const html = await fetchPage(new URL("index.php", base).toString(), "健康检查", opts);
const $ = loadHtml(html);
if (SiteSpider.isLoginOrPermissionPage($) || (ext.loginSuccess && $(ext.loginSuccess).length === 0)) {
throw new SiteError(def.id, "auth_expired", "健康检查:未登录");
}
return { siteId: def.id, status: "ok", ok: true, checkedAt, latencyMs: Date.now() - started };
const v = judgeLoginPage(html, ext.loginSuccess);
const latencyMs = Date.now() - started;
if (v.ok) return { siteId: def.id, status: "ok", ok: true, checkedAt, latencyMs, detail: v.detail };
return {
siteId: def.id,
status: v.detail.reason === "challenge" ? "challenge" : "auth_expired",
ok: false,
checkedAt,
latencyMs,
message: v.message,
detail: v.detail,
};
} catch (e) {
return healthFromError(def.id, e, checkedAt);
}