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; } 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; /** 各 .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, """); } function shell(siteName: string, title: string, body: string, loggedIn: boolean): string { const userbar = loggedIn ? `
mockuser 12.5 TB 3.2 TB 退出
` : ""; return `${esc(siteName)} :: ${esc(title)} ${userbar}
${body}
`; } function loginPage(siteName: string): string { return `${esc(siteName)} :: 登录

你需要启用cookies才能登录

`; } 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` : ""; 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, "")}个下载者
简介
${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 broken = false; 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) => { 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)); 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)); 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; }, infoHashOf(id) { const t = torrents.get(id); if (!t) throw new Error(`no torrent ${id}`); return t.infoHash; }, }; }