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:
1 parent
322ecb2f8c
commit
b1d30bd6ee
16 files changed
+433
-27
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user