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:
1 parent
5ee7ca4fee
commit
d6fca25679
10 files changed
+384
-8
No files matched your search
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, "&").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>`;
|
||||
|
||||
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&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}`);
|
||||
|
||||
Reference in new issue
Block a user