diff --git a/apps/server/src/modules/sources-identity/service/account.ts b/apps/server/src/modules/sources-identity/service/account.ts index bc8f6d6e..4d295555 100644 --- a/apps/server/src/modules/sources-identity/service/account.ts +++ b/apps/server/src/modules/sources-identity/service/account.ts @@ -5,6 +5,7 @@ import { type MteamDefinition, mteamApiBase, refreshSiteUserData, + type SiteHealthDetail, SiteLoginError, type SiteLoginFailure, type SiteUserInfo, @@ -789,8 +790,12 @@ const HEALTH_OF: Record = { 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") diff --git a/apps/server/test/sources-identity/site-center.test.ts b/apps/server/test/sources-identity/site-center.test.ts index 23772b93..172f9520 100644 --- a/apps/server/test/sources-identity/site-center.test.ts +++ b/apps/server/test/sources-identity/site-center.test.ts @@ -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") diff --git a/apps/web/app/components/sites/site-fields.test.ts b/apps/web/app/components/sites/site-fields.test.ts new file mode 100644 index 00000000..c51bd956 --- /dev/null +++ b/apps/web/app/components/sites/site-fields.test.ts @@ -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([]); + }); +}); diff --git a/apps/web/app/components/sites/site-fields.ts b/apps/web/app/components/sites/site-fields.ts index 19a2241e..04430cac 100644 --- a/apps/web/app/components/sites/site-fields.ts +++ b/apps/web/app/components/sites/site-fields.ts @@ -134,6 +134,7 @@ const HEALTH_STATUS_TEXT: Record = { 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 = { + 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 "默认"; diff --git a/apps/web/app/components/sites/site-overview.tsx b/apps/web/app/components/sites/site-overview.tsx index f21a120c..e094c930 100644 --- a/apps/web/app/components/sites/site-overview.tsx +++ b/apps/web/app/components/sites/site-overview.tsx @@ -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) }, ]} /> + diff --git a/packages/adapters/src/sites/definitions/audiences.json b/packages/adapters/src/sites/definitions/audiences.json index 35e49702..d4d47e59 100644 --- a/packages/adapters/src/sites/definitions/audiences.json +++ b/packages/adapters/src/sites/definitions/audiences.json @@ -168,7 +168,7 @@ } }, "umt": { - "version": "audiences@0.2.0-unverified", + "version": "audiences@0.2.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", diff --git a/packages/adapters/src/sites/index.ts b/packages/adapters/src/sites/index.ts index 484623a1..4c4a0bb4 100644 --- a/packages/adapters/src/sites/index.ts +++ b/packages/adapters/src/sites/index.ts @@ -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, diff --git a/packages/adapters/src/sites/spider/adapter.ts b/packages/adapters/src/sites/spider/adapter.ts index 8d7de10a..becf4251 100644 --- a/packages/adapters/src/sites/spider/adapter.ts +++ b/packages/adapters/src/sites/spider/adapter.ts @@ -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); } diff --git a/packages/adapters/src/sites/spider/health.test.ts b/packages/adapters/src/sites/spider/health.test.ts new file mode 100644 index 00000000..7a01e130 --- /dev/null +++ b/packages/adapters/src/sites/spider/health.test.ts @@ -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 = `Audiences :: 首页 - Powered by NexusPHP + +
+
+
种子
`; + +const LOGIN_PAGE = `Audiences :: 登录 - Powered by NexusPHP +
`; +const PERMISSION_PAGE = `Audiences :: 错误 +
对不起,必须在登录后才能访问该页面
`; +const CF_CHALLENGE = `Just a moment... +`; +const BARE_HOME = `Audiences :: 首页
欢迎
`; + +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("Just a moment...", { 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 }); + }); +}); diff --git a/packages/adapters/src/sites/spider/spider.ts b/packages/adapters/src/sites/spider/spider.ts index 015646e4..d197da06 100644 --- a/packages/adapters/src/sites/spider/spider.ts +++ b/packages/adapters/src/sites/spider/spider.ts @@ -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 与空列表。 */ diff --git a/packages/adapters/src/sites/types.ts b/packages/adapters/src/sites/types.ts index 77bc1c3d..d297cca7 100644 --- a/packages/adapters/src/sites/types.ts +++ b/packages/adapters/src/sites/types.ts @@ -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 { diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts index c45167b8..c40512a4 100644 --- a/packages/contracts/src/sources.ts +++ b/packages/contracts/src/sources.ts @@ -112,10 +112,15 @@ export type SiteRateLimit = z.infer; /** 站点行 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; diff --git a/packages/testkit/src/sites/README.md b/packages/testkit/src/sites/README.md index ab186f98..ca34ab19 100644 --- a/packages/testkit/src/sites/README.md +++ b/packages/testkit/src/sites/README.md @@ -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 ``` diff --git a/packages/testkit/src/sites/index.ts b/packages/testkit/src/sites/index.ts index 7f427ec6..367c747b 100644 --- a/packages/testkit/src/sites/index.ts +++ b/packages/testkit/src/sites/index.ts @@ -17,6 +17,7 @@ export { } from "./mock-mteam.ts"; export { createMockNexusPhpSite, + type MockHeaderLayout, type MockLoginGuard, type MockNexusPhpOptions, type MockNexusPhpSite, diff --git a/packages/testkit/src/sites/mock-nexusphp.ts b/packages/testkit/src/sites/mock-nexusphp.ts index 2a1451cd..544d7bd7 100644 --- a/packages/testkit/src/sites/mock-nexusphp.ts +++ b/packages/testkit/src/sites/mock-nexusphp.ts @@ -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 = `
信
`; -function shell(siteName: string, title: string, body: string, loggedIn: boolean): string { - const userbar = loggedIn - ? `
mockuser +/** 观众 2026 版 info_block:退出 / 控制面板在 data-uploader-actions JSON 里由脚本渲染(脱敏结构样本) */ +const AUDIENCES_NO_LOGOUT_BLOCK = `
+ +
`; + +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" + ? `
mockuser
` + : `
mockuser 12.5 TB 3.2 TB - 退出
${AUDIENCES_USERBAR}` - : ""; + 退出
${AUDIENCES_USERBAR}`; return `${esc(siteName)} :: ${esc(title)} ${userbar}
${body}
`; } @@ -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, "首页", "

最新公告

", true)); + return send(res, 200, shell(siteName, "首页", "

最新公告

", 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; }, diff --git a/scripts/mp-sync/movieclaw-overrides/audiences.json b/scripts/mp-sync/movieclaw-overrides/audiences.json index 25e2eba0..e7ac5573 100644 --- a/scripts/mp-sync/movieclaw-overrides/audiences.json +++ b/scripts/mp-sync/movieclaw-overrides/audiences.json @@ -159,7 +159,7 @@ } }, "umt": { - "version": "audiences@0.2.0-unverified", + "version": "audiences@0.2.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",