Merge branch 'hc1'

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-30 20:39:03 +08:00
commit d403514adc
16 files changed
+433 -27

No files matched your search

@@ -5,6 +5,7 @@ import {
type MteamDefinition,
mteamApiBase,
refreshSiteUserData,
type SiteHealthDetail,
SiteLoginError,
type SiteLoginFailure,
type SiteUserInfo,
@@ -789,8 +790,12 @@ const HEALTH_OF: Record<string, SiteHealth> = {
unreachable: "degraded",
site_maintenance: "degraded",
parse_error: "degraded",
challenge: "degraded",
};
/** 用户数据多久内解析成功可以当作"已登录"的证据(与 MP 一致:user_info 解析成功即判登录) */
const USER_DATA_EVIDENCE_MS = 24 * 3_600_000;
/** 首页连通 + 登录态(≤ 20 s,走站点限流);写 site.last_health* 与 health_status(已关闭的站点保持 disabled) */
export async function checkSiteHealth(
rt: SourcesRuntime,
@@ -802,6 +807,7 @@ export async function checkSiteHealth(
let status = "unreachable";
let latencyMs: number | null = null;
let message: string | null = null;
let detail: SiteHealthDetail | null = null;
const def = getSiteDefinition(site.key);
if (!def) {
message = "站点没有适配器定义,无法检测";
@@ -813,6 +819,30 @@ export async function checkSiteHealth(
status = h.status;
latencyMs = h.latencyMs;
message = h.message ?? null;
detail = h.detail ?? null;
// 首页不是登录页、只是没找到登录标志:以用户数据解析成功为准(避免与"测试登录"两处判定打架)
if (!ok && detail?.reason === "marker_missing") {
const acc = await rt.ctx.db
.selectFrom("site_account")
.select(["id", "user_data_at"])
.where("site_id", "=", site.id)
.where("enabled", "=", true)
.where("status", "=", "active")
.where("user_data_at", ">=", new Date(now.getTime() - USER_DATA_EVIDENCE_MS))
.orderBy("user_data_at", "desc")
.executeTakeFirst();
if (acc?.user_data_at) {
ok = true;
status = "ok";
message = null;
detail = {
...detail,
reason: "user_data",
homepage: "marker_missing",
userDataAt: acc.user_data_at.toISOString(),
};
}
}
}
const loggedIn = ok ? true : status.startsWith("auth_") ? false : null;
const healthStatus: SiteHealth = !site.enabled
@@ -820,7 +850,7 @@ export async function checkSiteHealth(
: def
? (HEALTH_OF[status] ?? "degraded")
: site.health_status;
const last = { ok, status, latencyMs, message };
const last = { ok, status, latencyMs, message, detail };
await rt.ctx.db.transaction().execute(async (trx) => {
await trx
.updateTable("site")
@@ -218,9 +218,35 @@ describe.skipIf(!up)("站点中心(mock NexusPHP)", () => {
mock.setLoggedIn(false);
const bad = await json(await h.req(`/sites/${siteId}/health-check`, { method: "POST" }));
expect(bad).toMatchObject({ ok: false, loggedIn: false, healthStatus: "failing" });
expect(bad.detail).toMatchObject({ reason: "login_page", path: "index.php" });
expect(bad.message).toBe("健康检查:首页返回登录页,Cookie 已失效");
mock.setLoggedIn(true);
});
it("HC-1 健康检测:首页不是登录页、只是找不到登录标志时,以近期用户数据解析成功为准", async () => {
mock.setHeaderLayout("bare");
try {
const r = await json(await h.req(`/sites/${siteId}/health-check`, { method: "POST" }));
expect(r).toMatchObject({ ok: true, status: "ok", loggedIn: true, healthStatus: "healthy", message: null });
expect(r.detail).toMatchObject({ reason: "user_data", homepage: "marker_missing" });
expect(r.detail.userDataAt).toBeTruthy();
// 没有近期用户数据:照实报"找不到登录标志(选择器 …)"
await h.db
.updateTable("site_account")
.set({ user_data_at: new Date(Date.now() - 3 * 86_400_000) })
.where("site_id", "=", siteId)
.execute();
const bad = await json(await h.req(`/sites/${siteId}/health-check`, { method: "POST" }));
expect(bad).toMatchObject({ ok: false, status: "auth_expired", loggedIn: false });
expect(bad.message).toContain("首页里找不到登录标志(选择器 #info_block");
expect(bad.detail).toMatchObject({ reason: "marker_missing" });
const site = await json(await h.req(`/sites/${siteId}`));
expect(site.lastHealth.detail).toMatchObject({ reason: "marker_missing" });
} finally {
mock.setHeaderLayout("classic");
}
});
it("删除站点:账号与凭证删除,资源保留并标站点已关闭;同一定义可再登记", async () => {
const rel = await h.db
.insertInto("source_release")
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { healthLine, healthMessage, healthTechItems } from "./site-fields.ts";
describe("健康检测文案(HC-1)", () => {
it("人机验证单独成类;失败原因一句,原始判定进技术信息", () => {
const h = {
ok: false,
status: "auth_expired",
latencyMs: 120,
message: '健康检查:首页里找不到登录标志(选择器 a[href*="logout"])',
detail: { reason: "marker_missing", selector: 'a[href*="logout"]', path: "index.php", title: "Site :: 首页" },
};
expect(healthMessage(h)).toContain("找不到登录标志");
expect(healthTechItems(h)).toEqual([
{ label: "判定", value: "页面里找不到登录标志(marker_missing)" },
{ label: "登录标志选择器", value: 'a[href*="logout"]', mono: true },
{ label: "检测页面", value: "index.php", mono: true },
{ label: "页面标题", value: "Site :: 首页" },
]);
expect(healthLine({ ok: false, status: "challenge", latencyMs: null, message: null }, null).text).toContain(
"需要人机验证",
);
// 旧记录没有 detail
expect(healthTechItems({ ok: true, status: "ok", latencyMs: 1, message: null })).toEqual([]);
});
});
@@ -134,6 +134,7 @@ const HEALTH_STATUS_TEXT: Record<string, string> = {
unreachable: "连不上站点",
site_maintenance: "站点维护中",
parse_error: "页面解析失败",
challenge: "需要人机验证",
};
/** 健康检测一行:"正常 · 320 ms · 5 分钟前" / 失败原因一句 */
@@ -151,6 +152,34 @@ export function healthMessage(h: SiteLastHealth | null): string | null {
return h.message;
}
const HEALTH_REASON_TEXT: Record<string, string> = {
logged_in: "首页有登录标志",
user_data: "首页没找到登录标志,按用户数据解析成功判为已登录",
login_page: "返回登录页",
permission_page: "返回权限提示页",
challenge: "疑似人机验证",
marker_missing: "页面里找不到登录标志",
redirect_login: "跳转登录页",
http: "HTTP 错误",
};
/** 健康检测的原始判定 → 技术信息条目(判定、规则、选择器、页面标题、HTTP 状态) */
export function healthTechItems(h: SiteLastHealth | null): { label: string; value: string; mono?: boolean }[] {
const d = h?.detail;
if (!d) return [];
const str = (k: string) => (typeof d[k] === "string" || typeof d[k] === "number" ? String(d[k]) : "");
const reason = str("reason");
return [
{ label: "判定", value: reason ? `${HEALTH_REASON_TEXT[reason] ?? reason}(${reason})` : "" },
{ label: "命中规则", value: str("rule"), mono: true },
{ label: "登录标志选择器", value: str("selector"), mono: true },
{ label: "检测页面", value: str("path"), mono: true },
{ label: "页面标题", value: str("title") },
{ label: "HTTP 状态", value: str("httpStatus") },
{ label: "用户数据时间", value: str("userDataAt") },
].filter((it) => it.value);
}
/** 限流:"20 次/分";空对象 = 默认 */
export function rateLimitText(r: SiteRateLimit | null | undefined): string {
if (!r || (!r.requestsPerMinute && !r.minIntervalMs && !r.concurrency)) return "默认";
@@ -6,6 +6,7 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import {
ACCOUNT_OVERALL,
@@ -15,6 +16,7 @@ import {
hasLogin,
healthLine,
healthMessage,
healthTechItems,
kindLabel,
overallStatus,
primaryAccount,
@@ -182,6 +184,7 @@ function AccountSiteOverview({ site }: { site: SiteListItem }) {
{ label: "获取方式", value: capabilityText(site) },
]}
/>
<TechInfo className="mt-2" columns={1} items={healthTechItems(site.lastHealth)} />
</DetailSection>
</div>
</div>
@@ -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 {
+6 -1
View File
@@ -112,10 +112,15 @@ export type SiteRateLimit = z.infer<typeof SiteRateLimit>;
/** 站点行 last_health:最近一次健康检测结果 */
export const SiteLastHealth = z.object({
ok: z.boolean(),
/** ok / auth_missing / auth_expired / auth_invalid / rate_limited / unreachable / site_maintenance / parse_error */
/** ok / auth_missing / auth_expired / auth_invalid / rate_limited / unreachable / site_maintenance / parse_error / challenge */
status: z.string(),
latencyMs: z.number().int().nonnegative().nullable(),
message: z.string().nullable(),
/**
* 原始判定(技术信息):reason = logged_in / user_data / login_page / permission_page / challenge /
* marker_missing / redirect_login / http;rule 命中的规则,selector 登录标志选择器。旧记录没有这个字段
*/
detail: z.record(z.string(), z.unknown()).nullable().optional(),
});
export type SiteLastHealth = z.infer<typeof SiteLastHealth>;
+1
View File
@@ -10,6 +10,7 @@ site.baseUrl; site.cookie; // 请求 Cookie 需包含 site.cookie
site.setLoggedIn(false); // 全站返回登录页(或 302 login.php)
site.failNext(1, 429, "7"); // 限流 / 5xx
site.setBrokenLayout(true); // 改版模拟
site.setHeaderLayout("no_logout"); // 页头布局:classic / no_logout(观众 2026 版无 logout 链接)/ bare(无登录标志)
site.infoHashOf("812345"); // 下载 .torrent 的 infohash
```
+1
View File
@@ -17,6 +17,7 @@ export {
} from "./mock-mteam.ts";
export {
createMockNexusPhpSite,
type MockHeaderLayout,
type MockLoginGuard,
type MockNexusPhpOptions,
type MockNexusPhpSite,
+34 -6
View File
@@ -22,6 +22,7 @@ export interface MockNexusPhpOptions {
}
export type MockLoginGuard = "none" | "captcha" | "two_factor" | "blocked";
export type MockHeaderLayout = "classic" | "no_logout" | "bare";
export interface MockNexusPhpSite extends StartedServer {
cookie: string;
@@ -34,6 +35,12 @@ export interface MockNexusPhpSite extends StartedServer {
setBrokenLayout(v: boolean): void;
/** 切换登录关卡 */
setLoginGuard(v: MockLoginGuard): void;
/**
* 已登录页头布局:classic(info_block 里有 logout 链接,缺省);
* no_logout(观众 2026 版:退出只在 userbar 的 data-uploader-actions JSON 里,页面没有 logout 链接);
* bare(已登录但没有 info_block / usercp / logout 任何登录标志)
*/
setHeaderLayout(v: MockHeaderLayout): void;
/** takelogin.php 收到的 POST 次数 */
readonly loginAttempts: number;
/** 各 .torrent 的 infohash,供断言 */
@@ -60,13 +67,30 @@ const AUDIENCES_USERBAR = `<div class="site-userbar">
<a class="site-userbar__compact-tool" href="messages.php" title="收件箱 12/0">信</a>
</div>`;
function shell(siteName: string, title: string, body: string, loggedIn: boolean): string {
const userbar = loggedIn
? `<div id="info_block"><a href="userdetails.php?id=10001">mockuser</a>
/** 观众 2026 版 info_block:退出 / 控制面板在 data-uploader-actions JSON 里由脚本渲染(脱敏结构样本) */
const AUDIENCES_NO_LOGOUT_BLOCK = `<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="mockuser" 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=10001" class="VIP_Name"><b>mockuser</b></a></span></span>
<a href="https://audiences.example/usercp.php?action=personal">设置</a></div></div>
</td><td class="bottom"></td></tr></table></td></tr></table>`;
function shell(
siteName: string,
title: string,
body: string,
loggedIn: boolean,
layout: MockHeaderLayout = "classic",
): string {
const userbar = !loggedIn
? ""
: layout === "no_logout"
? `${AUDIENCES_NO_LOGOUT_BLOCK}${AUDIENCES_USERBAR}`
: layout === "bare"
? `<div class="welcome">mockuser</div>`
: `<div id="info_block"><a href="userdetails.php?id=10001">mockuser</a>
<span class="site-userbar__compact-metric--uploaded">12.5 TB</span>
<span class="site-userbar__compact-metric--downloaded">3.2 TB</span>
<a href="logout.php">退出</a></div>${AUDIENCES_USERBAR}`
: "";
<a href="logout.php">退出</a></div>${AUDIENCES_USERBAR}`;
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(siteName)} :: ${esc(title)}</title></head>
<body>${userbar}<div id="outer">${body}</div></body></html>`;
}
@@ -166,6 +190,7 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
const username = opts.username ?? "mockuser";
const password = opts.password ?? "mock-password";
let broken = false;
let layout: MockHeaderLayout = "classic";
const failures: Array<{ status: number; retryAfter?: string }> = [];
const torrents = new Map(releases.map((r) => [r.id, buildFakeTorrent({ name: r.title, length: 4 * 1024 * 1024 })]));
@@ -212,7 +237,7 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
switch (url.pathname) {
case "/index.php":
case "/":
return send(res, 200, shell(siteName, "首页", "<h2>最新公告</h2>", true));
return send(res, 200, shell(siteName, "首页", "<h2>最新公告</h2>", true, layout));
case "/userdetails.php":
return send(res, 200, USER_DETAILS);
case "/getusertorrentlistajax.php":
@@ -278,6 +303,9 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
setLoginGuard(v) {
guard = v;
},
setHeaderLayout(v) {
layout = v;
},
get loginAttempts() {
return loginAttempts;
},
@@ -159,7 +159,7 @@
}
},
"umt": {
"version": "[email protected].0-unverified",
"version": "[email protected].1-unverified",
"kind": "nexusphp",
"displayName": "观众",
"timezone": "+08:00",
@@ -180,7 +180,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",