Merge branch 'hc1'
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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="[{"href":"usercp.php","title":"控制面板"},{"href":"logout.php","title":"退出"}]">
|
||||
<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 });
|
||||
});
|
||||
});
|
||||
@@ -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 与空列表。 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
} from "./mock-mteam.ts";
|
||||
export {
|
||||
createMockNexusPhpSite,
|
||||
type MockHeaderLayout,
|
||||
type MockLoginGuard,
|
||||
type MockNexusPhpOptions,
|
||||
type MockNexusPhpSite,
|
||||
|
||||
@@ -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="[{"href":"usercp.php","title":"控制面板"},{"href":"logout.php","title":"退出"}]"><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",
|
||||
|
||||
Reference in new issue
Block a user