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

@@ -168,7 +168,7 @@
}
},
"umt": {
"version": "[email protected].0-unverified",
"version": "[email protected].1-unverified",
"kind": "nexusphp",
"displayName": "观众",
"timezone": "+08:00",
@@ -189,7 +189,7 @@
"403": "综艺",
"406": "纪录片"
},
"loginSuccess": "a[href*=\"logout\"]",
"loginSuccess": "#info_block, a[href*=\"usercp.php\"], a[href*=\"logout\"]",
"emptyMarkers": ["没有种子", "没有结果", "Nothing found"],
"pagination": "a[href*=\"page=\"]",
"detailPath": "details.php",
+2 -1
View File
@@ -79,7 +79,7 @@ export {
SITE_DEFINITIONS,
type SiteDefinition,
} from "./registry.ts";
export { clearSearchText, createSpiderSiteAdapter } from "./spider/adapter.ts";
export { clearSearchText, createSpiderSiteAdapter, judgeLoginPage } from "./spider/adapter.ts";
export { parseDetailPage } from "./spider/detail.ts";
export { promotionFromFactors, spiderTorrentToRelease } from "./spider/map.ts";
export { mcSelect } from "./spider/mc-selector.ts";
@@ -120,6 +120,7 @@ export {
SiteError,
type SiteErrorKind,
type SiteHealth,
type SiteHealthDetail,
type SiteHealthStatus,
type SiteKind,
type SiteListFilters,
+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);
}
@@ -0,0 +1,132 @@
import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
audiencesDefinition,
createSiteRateLimiter,
createSpiderSiteAdapter,
judgeLoginPage,
SiteError,
staticSecretResolver,
} from "../index.ts";
// HC-1:观众首页没有 logout 链接(退出按钮在 userbar 的 data-uploader-actions JSON 里由脚本渲染),
// 旧登录标志 a[href*="logout"] 永远不命中,健康检查误报"未登录"而用户数据解析正常。
/** 观众 2026 版首页的脱敏结构样本(只保留判定相关的骨架,用户名 / 数值均为假数据) */
const AUDIENCES_HOME = `<!DOCTYPE html><html><head><title>Audiences :: 首页 - Powered by NexusPHP</title>
<script src="/cdn-cgi/challenge-platform/scripts/jsd/main.js"></script></head><body>
<table id="info_block"><tr><td><table><tr><td class="bottom">
<div class="site-userbar site-userbar--torrents"><div class="site-userbar__compact-identity">
<span class="torrent-uploader-wrap site-userbar__name" title="someone"
data-uploader-actions="[{&quot;href&quot;:&quot;usercp.php&quot;,&quot;title&quot;:&quot;控制面板&quot;},{&quot;href&quot;:&quot;logout.php&quot;,&quot;title&quot;:&quot;退出&quot;}]">
<span class="nowrap"><a href="userdetails.php?id=1" class="VIP_Name"><b>someone</b></a></span></span>
<a href="https://audiences.me/usercp.php?action=personal">设置</a>
<a href="log.php">日志</a></div></div></td><td class="bottom"></td></tr></table></td></tr></table>
<table class="main"><tr><td><a href="torrents.php">种子</a></td></tr></table></body></html>`;
const LOGIN_PAGE = `<html><head><title>Audiences :: 登录 - Powered by NexusPHP</title></head>
<body><form method="post" action="takelogin.php"><input name="username"><input type="password" name="password"></form></body></html>`;
const PERMISSION_PAGE = `<html><head><title>Audiences :: 错误</title></head>
<body><table><tr><td>对不起,必须在登录后才能访问该页面</td></tr></table></body></html>`;
const CF_CHALLENGE = `<!DOCTYPE html><html><head><title>Just a moment...</title></head>
<body><script>window._cf_chl_opt={cvId:'3'};</script></body></html>`;
const BARE_HOME = `<html><head><title>Audiences :: 首页</title></head><body><div>欢迎</div></body></html>`;
const selector = audiencesDefinition.umt.loginSuccess;
describe("健康检查首页登录判定(judgeLoginPage)", () => {
it("差分:观众首页旧选择器 a[href*=logout] 不命中,新登录标志命中 info_block / usercp", () => {
const old = judgeLoginPage(AUDIENCES_HOME, 'a[href*="logout"]');
expect(old).toMatchObject({
ok: false,
message: '健康检查:首页里找不到登录标志(选择器 a[href*="logout"])',
detail: { reason: "marker_missing", selector: 'a[href*="logout"]', path: "index.php" },
});
expect(selector).toContain("#info_block");
expect(selector).toContain('a[href*="usercp.php"]');
expect(judgeLoginPage(AUDIENCES_HOME, selector)).toMatchObject({ ok: true, detail: { reason: "logged_in" } });
});
it("登录页 / 权限页 / 人机验证 / 找不到登录标志 分别报告,命中规则进 detail", () => {
expect(judgeLoginPage(LOGIN_PAGE, selector)).toMatchObject({
ok: false,
message: "健康检查:首页返回登录页,Cookie 已失效",
detail: { reason: "login_page", rule: "title*=:: 登录" },
});
expect(judgeLoginPage(PERMISSION_PAGE, selector)).toMatchObject({
ok: false,
message: "健康检查:首页返回权限提示页(要求先登录)",
detail: { reason: "permission_page", rule: "text*=必须在登录后才能访问" },
});
expect(judgeLoginPage(CF_CHALLENGE, selector)).toMatchObject({
ok: false,
detail: { reason: "challenge", rule: "title=Just a moment" },
});
expect(judgeLoginPage(BARE_HOME, selector)).toMatchObject({
ok: false,
detail: { reason: "marker_missing", selector },
});
// 正常页面里 Cloudflare 注入的 challenge-platform 脚本不算人机验证
expect(judgeLoginPage(AUDIENCES_HOME, "#nope")).toMatchObject({ detail: { reason: "marker_missing" } });
});
});
describe("spider 适配器 healthCheck(mock 观众)", () => {
let site: MockNexusPhpSite | undefined;
afterEach(async () => {
await site?.close();
site = undefined;
});
const adapterFor = (s: MockNexusPhpSite) =>
createSpiderSiteAdapter(audiencesDefinition, {
baseUrl: `${s.baseUrl}/`,
secrets: staticSecretResolver({ [audiencesDefinition.id]: { cookie: s.cookie } }),
limiter: createSiteRateLimiter({ minIntervalMs: 0 }),
});
it("首页没有 logout 链接(观众 2026 版)判已登录;没有任何登录标志 → auth_expired + 选择器", async () => {
site = await createMockNexusPhpSite();
const a = adapterFor(site);
site.setHeaderLayout("no_logout");
expect(await a.healthCheck()).toMatchObject({ ok: true, status: "ok", detail: { reason: "logged_in" } });
site.setHeaderLayout("bare");
const h = await a.healthCheck();
expect(h).toMatchObject({ ok: false, status: "auth_expired", detail: { reason: "marker_missing", selector } });
expect(h.message).toContain(selector);
site.setHeaderLayout("classic");
site.setLoggedIn(false);
expect(await a.healthCheck()).toMatchObject({
ok: false,
status: "auth_expired",
message: "健康检查:首页返回登录页,Cookie 已失效",
detail: { reason: "login_page" },
});
});
it("302 跳转登录页 → redirect_login", async () => {
site = await createMockNexusPhpSite({ loginMode: "redirect" });
site.setLoggedIn(false);
expect(await adapterFor(site).healthCheck()).toMatchObject({
ok: false,
status: "auth_expired",
detail: { reason: "redirect_login", httpStatus: 302 },
});
});
it("cf-mitigated: challenge → challenge 类错误(不是凭据错误),健康状态 challenge", async () => {
const a = createSpiderSiteAdapter(audiencesDefinition, {
secrets: staticSecretResolver({ [audiencesDefinition.id]: { cookie: "c=1" } }),
limiter: createSiteRateLimiter({ minIntervalMs: 0 }),
fetch: async () =>
new Response("<title>Just a moment...</title>", { status: 403, headers: { "cf-mitigated": "challenge" } }),
});
expect(await a.healthCheck()).toMatchObject({
ok: false,
status: "challenge",
detail: { reason: "challenge", httpStatus: 403 },
});
const err = await a.list(1).catch((e) => e);
expect(err).toBeInstanceOf(SiteError);
expect(err).toMatchObject({ kind: "challenge", isAuthError: false });
});
});
+28 -4
View File
@@ -403,12 +403,36 @@ export class SiteSpider {
/** MP `__is_login_or_permission_page` */
static isLoginOrPermissionPage($: CheerioAPI): boolean {
return SiteSpider.loginPageRule($) !== null;
}
/**
* 与 `isLoginOrPermissionPage` 同一套规则(MP `__is_login_or_permission_page`),额外说明命中的是哪条:
* 标题"登录" / 正文登录提示 → 登录页;"必须在登录后才能访问" → 权限提示页。未命中返回 null。
*/
static loginPageRule($: CheerioAPI): { kind: "login_page" | "permission_page"; rule: string } | null {
const title = $("title").first().text().trim();
if (title === "登录") return { kind: "login_page", rule: "title=登录" };
if (title.includes(":: 登录")) return { kind: "login_page", rule: "title*=:: 登录" };
const pageText = $.root().text().split(/\s+/).filter(Boolean).join(" ").slice(0, 1000);
if (title === "登录" || title.includes(":: 登录")) return true;
return ["未登录", "登录 / 注册", "必须在登录后才能访问", "你需要启用cookies才能登录"].some((m) =>
pageText.includes(m),
);
for (const m of ["未登录", "登录 / 注册", "必须在登录后才能访问", "你需要启用cookies才能登录"]) {
if (pageText.includes(m)) {
return { kind: m === "必须在登录后才能访问" ? "permission_page" : "login_page", rule: `text*=${m}` };
}
}
return null;
}
/**
* Cloudflare 人机验证页特征(标题 / 挑战参数);未命中返回 null。
* 不认 `/cdn-cgi/challenge-platform/`:Cloudflare 会往正常页面也注入这段脚本。
*/
static challengeRule($: CheerioAPI, html: string): string | null {
const title = $("title").first().text().trim();
if (/^Just a moment/i.test(title)) return "title=Just a moment";
if (/Attention Required! \| Cloudflare/i.test(title)) return "title=Attention Required";
const m = /cf-browser-verification|cf_chl_opt|cf-challenge-running/i.exec(html.slice(0, 200_000));
return m ? `html*=${m[0]}` : null;
}
/** MP `parse`:登录/权限页返回 isError=true 与空列表。 */
+27 -3
View File
@@ -130,7 +130,25 @@ export type SiteHealthStatus =
| "rate_limited"
| "unreachable"
| "site_maintenance"
| "parse_error";
| "parse_error"
/** 疑似人机验证(Cloudflare 等):连得上但无法确认登录态 */
| "challenge";
/** 健康检查的原始判定(进"技术信息"):不含 Cookie、用户名等个人数据 */
export interface SiteHealthDetail {
/** login_page / permission_page / challenge / marker_missing / redirect_login / http / user_data … */
reason: string;
/** 命中的登录页 / 人机验证规则 */
rule?: string;
/** 登录标志选择器(umt.loginSuccess) */
selector?: string;
httpStatus?: number;
/** 抓的页面路径 */
path?: string;
/** 页面标题(截断) */
title?: string;
[k: string]: unknown;
}
export interface SiteHealth {
siteId: string;
@@ -139,6 +157,7 @@ export interface SiteHealth {
checkedAt: string;
latencyMs: number | null;
message?: string;
detail?: SiteHealthDetail;
}
export interface SiteCallOptions {
@@ -170,7 +189,9 @@ export type SiteErrorKind =
| "network"
| "site_maintenance"
| "not_found"
| "unsupported";
| "unsupported"
/** 人机验证(Cloudflare 挑战等):不是凭据问题,不置账号失效 */
| "challenge";
/** 所有 message 已脱敏,不含 cookie/apikey/passkey/一次性下载令牌。 */
export class SiteError extends Error {
@@ -178,11 +199,13 @@ export class SiteError extends Error {
readonly siteId: string;
readonly httpStatus?: number;
readonly retryAfterMs?: number;
/** 原始判定(健康检查的技术信息用) */
readonly detail?: SiteHealthDetail;
constructor(
siteId: string,
kind: SiteErrorKind,
message: string,
opts: { httpStatus?: number; retryAfterMs?: number } = {},
opts: { httpStatus?: number; retryAfterMs?: number; detail?: SiteHealthDetail } = {},
) {
super(message);
this.name = "SiteError";
@@ -190,6 +213,7 @@ export class SiteError extends Error {
this.kind = kind;
if (opts.httpStatus !== undefined) this.httpStatus = opts.httpStatus;
if (opts.retryAfterMs !== undefined) this.retryAfterMs = opts.retryAfterMs;
if (opts.detail !== undefined) this.detail = opts.detail;
}
/** 凭据类错误:应立即置账号验证失败,不再重试 */
get isAuthError(): boolean {