feat(adapters): NexusPHP takelogin login, VIP and seeding page limit for user data

loginNexusPhp (captcha / two-factor / bad credentials / blocked / Cloudflare
classification, TOTP), SiteUserInfo.vip via profile.vip_css or umt.vipLevels,
maxSeedingPages option, MTorrent parser honours apiUrl; testkit mock NexusPHP
site gains takelogin with login guards and user detail pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:18:30 +08:00
1 parent 5ee7ca4fee
commit d6fca25679
10 files changed
+384 -8

No files matched your search

+6 -1
View File
@@ -14,7 +14,12 @@ export {
type MockMteamFailMode,
type MockMteamOptions,
} from "./mock-mteam.ts";
export { createMockNexusPhpSite, type MockNexusPhpOptions, type MockNexusPhpSite } from "./mock-nexusphp.ts";
export {
createMockNexusPhpSite,
type MockLoginGuard,
type MockNexusPhpOptions,
type MockNexusPhpSite,
} from "./mock-nexusphp.ts";
export { type MockMteamTorrent, MTEAM_FIXTURE_TORRENTS } from "./mteam-fixtures.ts";
export { AUDIENCES_FIXTURE_RELEASES, type MockNexusPromo, type MockNexusRelease } from "./nexusphp-fixtures.ts";
export { buildFakeTorrent, type FakeTorrent } from "./torrent.ts";
+4
View File
@@ -125,6 +125,10 @@ export async function createMockMteamApi(opts: MockMteamOptions = {}): Promise<M
memberCount: { uploaded: "1000", downloaded: "10", shareRate: "100" },
},
});
case "/api/member/getUserTorrentList":
return json(res, { code: "0", message: "SUCCESS", data: { data: [] } });
case "/api/tracker/myPeerStatus":
return json(res, { code: "0", message: "SUCCESS", data: { seeder: "0", leecher: "0" } });
default:
return json(res, { code: "1", message: "not found" }, 404);
}
+75 -5
View File
@@ -14,8 +14,15 @@ export interface MockNexusPhpOptions {
/** 登录失效时的表现: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 interface MockNexusPhpSite extends StartedServer {
cookie: string;
releases: MockNexusRelease[];
@@ -25,6 +32,10 @@ export interface MockNexusPhpSite extends StartedServer {
failNext(n: number, status: number, retryAfter?: string): void;
/** 改版模拟:列表页返回无法识别的结构 */
setBrokenLayout(v: boolean): void;
/** 切换登录关卡 */
setLoginGuard(v: MockLoginGuard): void;
/** takelogin.php 收到的 POST 次数 */
readonly loginAttempts: number;
/** 各 .torrent 的 infohash,供断言 */
infoHashOf(id: string): string;
}
@@ -42,23 +53,48 @@ function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/** 观众新版 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>`;
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>
<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>`
<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): string {
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&amp;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="text" name="username"><input type="password" name="password"><input type="submit" value="登录"></form></body></html>`;
<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 ?? "";
@@ -125,6 +161,10 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
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;
const failures: Array<{ status: number; retryAfter?: string }> = [];
const torrents = new Map(releases.map((r) => [r.id, buildFakeTorrent({ name: r.title, length: 4 * 1024 * 1024 })]));
@@ -133,14 +173,34 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(html);
};
const server = await startHttpServer((req, res) => {
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));
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();
@@ -153,6 +213,10 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
case "/index.php":
case "/":
return send(res, 200, shell(siteName, "首页", "<h2>最新公告</h2>", true));
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();
@@ -211,6 +275,12 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr
setBrokenLayout(v) {
broken = v;
},
setLoginGuard(v) {
guard = v;
},
get loginAttempts() {
return loginAttempts;
},
infoHashOf(id) {
const t = torrents.get(id);
if (!t) throw new Error(`no torrent ${id}`);