Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # packages/adapters/src/sites/definitions/audiences.json # scripts/mp-sync/movieclaw-overrides/audiences.json
322 lines
16 KiB
TypeScript
322 lines
16 KiB
TypeScript
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<string, string> = {
|
|
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, ">").replace(/"/g, """);
|
|
}
|
|
|
|
/** 观众新版 userbar(NexusAudiences 用户数据解析器读 data-uploader-*;数值与 AUDIENCES_USER_INDEX 一致) */
|
|
const AUDIENCES_USERBAR = `<div class="site-userbar">
|
|
<a class="site-userbar__user" href="userdetails.php?id=10001" data-uploader-url="userdetails.php?id=10001" data-uploader-label="mockuser" data-uploader-badge="Elite User" data-uploader-stats='[{"label":"上传量:","tone":"uploaded","value":"2.00 TB"},{"label":"下载量","tone":"downloaded","value":"100.00 GB"},{"label":"爆米花","tone":"bonus","value":"8,888.5"},{"label":"活跃","tone":"active","value":"↑ 30 / ↓ 1"}]'>mockuser</a>
|
|
<span class="site-userbar__compact-metric site-userbar__compact-metric--ratio"><span>分享率</span><span>20.48</span></span>
|
|
<a class="site-userbar__compact-tool" href="messages.php" title="收件箱 12/0">信</a>
|
|
</div>`;
|
|
|
|
/** 观众 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}`;
|
|
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>`;
|
|
}
|
|
|
|
function loginPage(siteName: string, guard: MockLoginGuard = "none"): string {
|
|
const captcha =
|
|
guard === "captcha"
|
|
? `<input type="hidden" name="imagehash" value="mockhash"><img src="imagecode.php?action=regimage&imagehash=mockhash"><input type="text" name="imagestring">`
|
|
: "";
|
|
const twoStep = guard === "two_factor" ? `<input type="text" name="two_step_code">` : "";
|
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(siteName)} :: 登录</title></head>
|
|
<body><form method="post" action="takelogin.php"><p>你需要启用cookies才能登录</p>
|
|
<input type="hidden" name="returnto" value="index.php">
|
|
<input type="text" name="username"><input type="password" name="password">${captcha}${twoStep}<input type="submit" value="登录"></form></body></html>`;
|
|
}
|
|
|
|
function loginFailedPage(siteName: string, text: string): string {
|
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(siteName)} :: 登录失败</title></head>
|
|
<body><table><tr><td class="text">${esc(text)}</td></tr></table></body></html>`;
|
|
}
|
|
|
|
const USER_DETAILS = `<html><body><table>
|
|
<tr><td class="rowhead">加入日期</td><td class="rowfollow">2020-05-06 07:08:09 (5年前)</td></tr>
|
|
</table></body></html>`;
|
|
const USER_SEEDING = `<table class="table table-bordered"><tr><td>类型</td><td>数量</td><td>体积</td></tr>
|
|
<tr><td>电影</td><td>20</td><td>2.00 TB</td></tr>
|
|
<tr><td>Total</td><td>31</td><td>3.50 TB</td></tr></table>`;
|
|
|
|
function promoImg(r: MockNexusRelease): string {
|
|
if (!r.promo) return "";
|
|
const title = r.promoUntil ?? "";
|
|
return ` <img class="pro_${r.promo}" src="pic/trans.gif" alt="${PROMO_ALT[r.promo]}" title="${esc(title)}">`;
|
|
}
|
|
|
|
function row(r: MockNexusRelease): string {
|
|
const titleLink = r.truncatedText
|
|
? `<a href="details.php?id=${r.id}&hit=1"><b>${esc(r.truncatedText)}</b></a>`
|
|
: `<a title="${esc(r.title)}" href="details.php?id=${r.id}&hit=1"><b>${esc(r.title)}</b></a>`;
|
|
const hr = r.hr ? ` <img class="hitandrun" src="pic/trans.gif" alt="H&R" title="H&R">` : "";
|
|
// 观众真实结构(DET-2 核对):副标题行里先是标签 span,正文在 span.torrent-subtitle-text
|
|
const sub = r.subtitle
|
|
? `<br><span class="torrent-subtitle-line"><span class="torrent-subtitle-text">${esc(r.subtitle)}</span></span>`
|
|
: "";
|
|
return `<tr>
|
|
<td class="rowfollow nowrap" valign="middle"><a href="?cat=${r.catId}"><img class="c_${r.catId}" src="pic/cattrans.gif" alt="${esc(r.catName)}" title="${esc(r.catName)}"></a></td>
|
|
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
|
|
<td class="embedded">${titleLink}${promoImg(r)}${hr}${sub}</td>
|
|
<td class="embedded" width="20"><a href="download.php?id=${r.id}"><img class="download" src="pic/trans.gif" alt="download"></a></td>
|
|
</tr></table></td>
|
|
<td class="rowfollow"><a href="comment.php?id=${r.id}">0</a></td>
|
|
<td class="rowfollow nowrap"><span title="${r.uploadedAt}">${esc(r.relativeTime ?? r.uploadedAt)}</span></td>
|
|
<td class="rowfollow">${esc(r.sizeText)}</td>
|
|
<td class="rowfollow" align="center"><b><a href="details.php?id=${r.id}&dllist=1#seeders">${r.seeders}</a></b></td>
|
|
<td class="rowfollow" align="center"><b><a href="details.php?id=${r.id}&dllist=1#leechers">${r.leechers}</a></b></td>
|
|
<td class="rowfollow"><a href="viewsnatches.php?id=${r.id}"><b>${r.snatched}</b></a></td>
|
|
<td class="rowfollow"><a href="userdetails.php?id=2">${esc(r.uploader ?? "anonymous")}</a></td>
|
|
</tr>`;
|
|
}
|
|
|
|
function listBody(rows: MockNexusRelease[], pageCount: number, pageBase: number, query: URLSearchParams): string {
|
|
const header = `<tr><td class="colhead">类型</td><td class="colhead">标题</td><td class="colhead">评论</td><td class="colhead">存活时间</td><td class="colhead">大小</td><td class="colhead">种子</td><td class="colhead">下载</td><td class="colhead">完成</td><td class="colhead">发布者</td></tr>`;
|
|
const links: string[] = [];
|
|
for (let i = 0; i < pageCount; i++) {
|
|
const q = new URLSearchParams(query);
|
|
q.set("page", String(i + pageBase));
|
|
links.push(`<a href="?${esc(q.toString())}"><b>${i + 1}</b></a>`);
|
|
}
|
|
if (rows.length === 0) return `<p>没有种子。请用准确的关键字重试。</p>`;
|
|
return `<p align="center">${links.join(" | ")}</p>
|
|
<table class="torrents torrents-table" cellspacing="0" cellpadding="5" width="100%"><tbody>${header}${rows.map(row).join("\n")}</tbody></table>`;
|
|
}
|
|
|
|
function detailBody(r: MockNexusRelease): string {
|
|
const hr = r.hr ? ` <img class="hitandrun" src="pic/trans.gif" alt="H&R">` : "";
|
|
return `<h1 align="center" id="top">${esc(r.title)}${promoImg(r)}${hr}</h1>
|
|
<table width="97%" cellspacing="0" cellpadding="5">
|
|
<tr><td class="rowhead" width="13%">下载</td><td class="rowfollow" align="left"><a class="index" href="download.php?id=${r.id}">[Mock].${esc(r.title)}.torrent</a></td></tr>
|
|
<tr><td class="rowhead">副标题</td><td class="rowfollow" align="left">${esc(r.subtitle ?? "")}</td></tr>
|
|
<tr><td class="rowhead">基本信息</td><td class="rowfollow" align="left"><b>大小:</b>${esc(r.sizeText)} <b>类型:</b> ${esc(r.catName)} 发布于 <span title="${r.uploadedAt}">${esc(r.relativeTime ?? r.uploadedAt)}</span></td></tr>
|
|
<tr><td class="rowhead">同伴</td><td class="rowfollow" align="left">${r.seeders.replace(/,/g, "")}个做种者 | ${r.leechers.replace(/,/g, "")}个下载者</td></tr>
|
|
<tr><td class="rowhead">简介</td><td class="rowfollow" align="left"><div id="kdescr">${r.descriptionHtml ?? esc(r.description ?? "暂无简介")}</div></td></tr>
|
|
</table>`;
|
|
}
|
|
|
|
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<MockNexusPhpSite> {
|
|
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, "首页", "<h2>最新公告</h2>", 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, "种子", `<div class="new-grid">改版了</div>`, 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, "错误", "<h2>错误!</h2><p>没有该ID的种子</p>", 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, "错误", "<p>没有该ID的种子</p>", 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;
|
|
},
|
|
};
|
|
}
|