import type { IncomingMessage, ServerResponse } from "node:http"; import { type StartedServer, startHttpServer } from "../rss/server.ts"; import { AUDIENCES_FIXTURE_RELEASES, type MockNexusRelease } from "./nexusphp-fixtures.ts"; import { buildFakeTorrent } from "./torrent.ts"; export interface MockNexusPhpOptions { /** 登录态 Cookie;请求 Cookie 头需包含它 */ cookie?: string; releases?: MockNexusRelease[]; /** 每页条数,默认 50 */ pageSize?: number; /** 站点页码起点(观众 0 起),默认 0 */ pageBase?: 0 | 1; /** 登录失效时的表现:200 登录页(默认)或 302 跳转 login.php */ loginMode?: "page" | "redirect"; siteName?: string; /** 账密登录(takelogin.php)接受的用户名 / 密码;缺省 mockuser / mock-password */ username?: string; password?: string; /** 登录关卡:验证码 / 二次验证 / 账号封禁;缺省无 */ loginGuard?: MockLoginGuard; } export type MockLoginGuard = "none" | "captcha" | "two_factor" | "blocked"; export type MockHeaderLayout = "classic" | "no_logout" | "bare"; export interface MockNexusPhpSite extends StartedServer { cookie: string; releases: MockNexusRelease[]; /** 让站点认为会话失效(所有页面返回登录页) */ setLoggedIn(v: boolean): void; /** 接下来 n 次请求返回指定状态(如 429 + Retry-After / 502) */ failNext(n: number, status: number, retryAfter?: string): void; /** 改版模拟:列表页返回无法识别的结构 */ 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,供断言 */ infoHashOf(id: string): string; } const PROMO_ALT: Record = { free: "Free", free2up: "2X Free", "50pctdown": "50%", "50pctdown2up": "2X 50%", "30pctdown": "30%", "2up": "2X", }; function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } /** 观众新版 userbar(NexusAudiences 用户数据解析器读 data-uploader-*;数值与 AUDIENCES_USER_INDEX 一致) */ const AUDIENCES_USERBAR = `
mockuser 分享率20.48 信
`; /** 观众 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}`; return `${esc(siteName)} :: ${esc(title)} ${userbar}
${body}
`; } function loginPage(siteName: string, guard: MockLoginGuard = "none"): string { const captcha = guard === "captcha" ? `` : ""; const twoStep = guard === "two_factor" ? `` : ""; return `${esc(siteName)} :: 登录

你需要启用cookies才能登录

${captcha}${twoStep}
`; } function loginFailedPage(siteName: string, text: string): string { return `${esc(siteName)} :: 登录失败
${esc(text)}
`; } const USER_DETAILS = `
加入日期2020-05-06 07:08:09 (5年前)
`; const USER_SEEDING = `
类型数量体积
电影202.00 TB
Total313.50 TB
`; function promoImg(r: MockNexusRelease): string { if (!r.promo) return ""; const title = r.promoUntil ?? ""; return ` ${PROMO_ALT[r.promo]}`; } function row(r: MockNexusRelease): string { const titleLink = r.truncatedText ? `${esc(r.truncatedText)}` : `${esc(r.title)}`; const hr = r.hr ? ` H&R` : ""; // 观众真实结构(DET-2 核对):副标题行里先是标签 span,正文在 span.torrent-subtitle-text const sub = r.subtitle ? `
${esc(r.subtitle)}` : ""; return ` ${esc(r.catName)}
${titleLink}${promoImg(r)}${hr}${sub} download
0 ${esc(r.relativeTime ?? r.uploadedAt)} ${esc(r.sizeText)} ${r.seeders} ${r.leechers} ${r.snatched} ${esc(r.uploader ?? "anonymous")} `; } function listBody(rows: MockNexusRelease[], pageCount: number, pageBase: number, query: URLSearchParams): string { const header = `类型标题评论存活时间大小种子下载完成发布者`; const links: string[] = []; for (let i = 0; i < pageCount; i++) { const q = new URLSearchParams(query); q.set("page", String(i + pageBase)); links.push(`${i + 1}`); } if (rows.length === 0) return `

没有种子。请用准确的关键字重试。

`; return `

${links.join(" | ")}

${header}${rows.map(row).join("\n")}
`; } function detailBody(r: MockNexusRelease): string { const hr = r.hr ? ` H&R` : ""; return `

${esc(r.title)}${promoImg(r)}${hr}

下载[Mock].${esc(r.title)}.torrent
副标题${esc(r.subtitle ?? "")}
基本信息大小:${esc(r.sizeText)}   类型: ${esc(r.catName)}  发布于 ${esc(r.relativeTime ?? r.uploadedAt)}
同伴${r.seeders.replace(/,/g, "")}个做种者 | ${r.leechers.replace(/,/g, "")}个下载者
简介
${r.descriptionHtml ?? esc(r.description ?? "暂无简介")}
`; } function hasCookie(req: IncomingMessage, cookie: string): boolean { const header = req.headers.cookie ?? ""; return cookie.split(";").every((part) => header.includes(part.trim())); } /** mock NexusPHP 站点:列表/搜索/详情/下载/首页,支持登录失效、限流与改版模拟。 */ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Promise { const cookie = opts.cookie ?? "c_secure_uid=10001; c_secure_pass=mock-session-secret"; const releases = [...(opts.releases ?? AUDIENCES_FIXTURE_RELEASES)]; const pageSize = opts.pageSize ?? 50; const pageBase = opts.pageBase ?? 0; const siteName = opts.siteName ?? "观众"; let loggedIn = true; let guard: MockLoginGuard = opts.loginGuard ?? "none"; let loginAttempts = 0; 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 })])); const send = (res: ServerResponse, status: number, html: string) => { res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(html); }; const server = await startHttpServer((req, res, raw) => { const url = new URL(req.url ?? "/", "http://mock"); const failure = failures.shift(); if (failure) { res.writeHead(failure.status, failure.retryAfter ? { "retry-after": failure.retryAfter } : {}).end("busy"); return; } if (url.pathname === "/login.php") return send(res, 200, loginPage(siteName, guard)); if (url.pathname === "/takelogin.php" && req.method === "POST") { loginAttempts++; const form = new URLSearchParams(raw); if (guard === "captcha") return send(res, 200, loginFailedPage(siteName, "验证码错误!")); if (form.get("username") !== username || form.get("password") !== password) { return send(res, 200, loginFailedPage(siteName, "用户名或密码不正确!或者你还没有通过验证?")); } if (guard === "blocked") return send(res, 200, loginFailedPage(siteName, "该账号已被禁用")); if (guard === "two_factor" && !/^\d{6}$/.test(form.get("two_step_code") ?? "")) { return send(res, 200, loginFailedPage(siteName, "两步验证码错误")); } loggedIn = true; res .writeHead(302, { location: "index.php", "set-cookie": cookie.split(";").map((p) => `${p.trim()}; path=/; httponly`), }) .end(); return; } if (!loggedIn || !hasCookie(req, cookie)) { if (opts.loginMode === "redirect") { res.writeHead(302, { location: `login.php?returnto=${encodeURIComponent(url.pathname.slice(1))}` }).end(); return; } return send(res, 200, loginPage(siteName)); } const q = url.searchParams; switch (url.pathname) { case "/index.php": case "/": return send(res, 200, shell(siteName, "首页", "

最新公告

", true, layout)); case "/userdetails.php": return send(res, 200, USER_DETAILS); case "/getusertorrentlistajax.php": return send(res, 200, USER_SEEDING); case "/torrents.php": { if (broken) return send(res, 200, shell(siteName, "种子", `
改版了
`, true)); const kw = (q.get("search") ?? "").toLowerCase().trim(); const cats = [...q.keys()].filter((k) => /^cat\d+$/.test(k)).map((k) => k.slice(3)); let rows = releases.filter((r) => { if (cats.length && !cats.includes(r.catId)) return false; if (q.get("spstate") === "2" && r.promo !== "free" && r.promo !== "free2up") return false; if (!kw) return true; return kw .split(/\s+/) .every((w) => r.title.toLowerCase().includes(w) || (r.subtitle ?? "").toLowerCase().includes(w)); }); const pageCount = Math.max(1, Math.ceil(rows.length / pageSize)); const idx = Number(q.get("page") ?? pageBase) - pageBase; rows = rows.slice(idx * pageSize, idx * pageSize + pageSize); const base = new URLSearchParams(q); base.delete("page"); return send(res, 200, shell(siteName, "种子", listBody(rows, pageCount, pageBase, base), true)); } case "/details.php": { const r = releases.find((x) => x.id === q.get("id")); if (!r) return send(res, 200, shell(siteName, "错误", "

错误!

没有该ID的种子

", true)); return send(res, 200, shell(siteName, `种子详情 "${r.title}"`, detailBody(r), true)); } case "/download.php": { const r = releases.find((x) => x.id === q.get("id")); if (!r) return send(res, 200, shell(siteName, "错误", "

没有该ID的种子

", true)); if (r.magnet) { res.writeHead(302, { location: r.magnet }).end(); return; } const t = torrents.get(r.id)!; res .writeHead(200, { "content-type": "application/x-bittorrent", "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(`[Mock].${r.title}.torrent`)}`, }) .end(Buffer.from(t.bytes)); return; } default: res.writeHead(404).end("not found"); } }); return { ...server, cookie, releases, setLoggedIn(v) { loggedIn = v; }, failNext(n, status, retryAfter) { for (let i = 0; i < n; i++) failures.push(retryAfter === undefined ? { status } : { status, retryAfter }); }, setBrokenLayout(v) { broken = v; }, setLoginGuard(v) { guard = v; }, setHeaderLayout(v) { layout = v; }, get loginAttempts() { return loginAttempts; }, infoHashOf(id) { const t = torrents.get(id); if (!t) throw new Error(`no torrent ${id}`); return t.infoHash; }, }; }