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
@@ -103,6 +103,8 @@ const UmtNexusSchema = z.strictObject({
|
||||
})
|
||||
.optional(),
|
||||
profile: z.record(z.string(), z.string()).optional(),
|
||||
/** VIP 等级名(用户数据 vip 判定;profile.vip_css 之外的补充) */
|
||||
vipLevels: z.array(z.string()).optional(),
|
||||
detail: z
|
||||
.strictObject({
|
||||
path: z.string(),
|
||||
|
||||
@@ -16,6 +16,17 @@ export {
|
||||
SpiderFilterSchema,
|
||||
} from "./definitions/index.ts";
|
||||
export type { SiteAdapterDeps } from "./deps.ts";
|
||||
export {
|
||||
classifyLoginError,
|
||||
loginFailureText,
|
||||
loginNexusPhp,
|
||||
type NexusLoginForm,
|
||||
type NexusLoginInput,
|
||||
type NexusLoginResult,
|
||||
SiteLoginError,
|
||||
type SiteLoginFailure,
|
||||
totpCode,
|
||||
} from "./login/nexusphp.ts";
|
||||
export { createMteamAdapter, mteamTorrentToRelease } from "./mteam/adapter.ts";
|
||||
export { type MteamApiPath, mteamPost } from "./mteam/client.ts";
|
||||
export { type MteamDefinition, mteamApiBase, mteamDefinition } from "./mteam/definition.ts";
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { load } from "cheerio";
|
||||
import { redactText } from "../../rss/redact.ts";
|
||||
import { underChallenge } from "../user-parsers/common.ts";
|
||||
|
||||
/**
|
||||
* NexusPHP 账密登录(takelogin),流程对齐 MovieClaw `CredentialAuthProvider`(docs/design/site-center.md §4.4):
|
||||
* 1. 清空 Cookie GET 登录页,记下 Set-Cookie;
|
||||
* 2. 页面含 `imagecode.php` → 验证码,本期不做 OCR,直接失败(captcha);
|
||||
* 3. 取登录表单隐藏字段,填用户名 / 密码、selectDefaults、extraFormData;有 TOTP 密钥且表单有二次验证字段时填当前 6 位码;
|
||||
* 4. POST(不跟随 3xx),再 GET index.php;`loginSuccess` 命中 → 成功,拼 Cookie;
|
||||
* 5. 失败按 errorCss 文本归类。
|
||||
* 凭据只在内存中使用;错误消息不含密码 / Cookie。**未经真实站点核验**(表单字段、二次验证字段名、错误文本)。
|
||||
*/
|
||||
|
||||
export type SiteLoginFailure =
|
||||
| "captcha"
|
||||
| "two_factor"
|
||||
| "bad_credentials"
|
||||
| "account_blocked"
|
||||
| "challenge"
|
||||
| "auth_expired"
|
||||
| "network"
|
||||
| "unsupported"
|
||||
| "unknown";
|
||||
|
||||
export class SiteLoginError extends Error {
|
||||
readonly reason: SiteLoginFailure;
|
||||
constructor(reason: SiteLoginFailure, message: string) {
|
||||
super(message);
|
||||
this.name = "SiteLoginError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NexusLoginForm {
|
||||
path: string;
|
||||
usernameField: string;
|
||||
passwordField: string;
|
||||
errorCss: string;
|
||||
extraFormData?: Array<[string, string]>;
|
||||
selectDefaults?: Array<[string, string]>;
|
||||
}
|
||||
|
||||
export interface NexusLoginInput {
|
||||
/** 站点前端地址(以 / 结尾或不结尾均可) */
|
||||
baseUrl: string;
|
||||
login: NexusLoginForm;
|
||||
/** 登录成功判定选择器(定义 umt.loginSuccess;缺省 a[href*="logout"]) */
|
||||
loginSuccess?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
/** base32 TOTP 密钥 */
|
||||
totpSecret?: string | null;
|
||||
userAgent: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
limiter?: { acquire(signal?: AbortSignal): Promise<void> };
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export interface NexusLoginResult {
|
||||
cookie: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
const TWO_FACTOR_FIELDS = ["two_step_code", "otp", "2fa_code", "twostep", "totp", "securitycode"];
|
||||
|
||||
/** RFC 6238 TOTP(SHA1、30 s、6 位) */
|
||||
export function totpCode(secretBase32: string, at: Date = new Date()): string {
|
||||
const clean = secretBase32.replace(/[\s=-]/g, "").toUpperCase();
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let bits = "";
|
||||
for (const ch of clean) {
|
||||
const v = alphabet.indexOf(ch);
|
||||
if (v < 0) throw new SiteLoginError("unknown", "TOTP 密钥不是合法的 base32");
|
||||
bits += v.toString(2).padStart(5, "0");
|
||||
}
|
||||
const bytes = new Uint8Array(Math.floor(bits.length / 8));
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = Number.parseInt(bits.slice(i * 8, i * 8 + 8), 2);
|
||||
const counter = Math.floor(at.getTime() / 1000 / 30);
|
||||
const msg = Buffer.alloc(8);
|
||||
msg.writeBigUInt64BE(BigInt(counter));
|
||||
const h = createHmac("sha1", Buffer.from(bytes)).update(msg).digest();
|
||||
const off = h[h.length - 1]! & 0x0f;
|
||||
const code = ((h[off]! & 0x7f) << 24) | (h[off + 1]! << 16) | (h[off + 2]! << 8) | h[off + 3]!;
|
||||
return String(code % 1_000_000).padStart(6, "0");
|
||||
}
|
||||
|
||||
/** 失败文本归类(errorCss 取到的站点原文) */
|
||||
export function classifyLoginError(text: string): SiteLoginFailure {
|
||||
const t = text.toLowerCase();
|
||||
if (/两步|二步|二次验证|两次验证|动态码|two.?step|two.?factor|2fa|otp|authenticator/.test(t)) return "two_factor";
|
||||
if (/验证码|imagecode|captcha/.test(t)) return "captcha";
|
||||
if (/封禁|禁用|被禁|未确认|未激活|待审核|banned|disabled|not confirmed|pending|parked/.test(t))
|
||||
return "account_blocked";
|
||||
if (/密码|用户名|账号|帳號|password|username|incorrect|invalid|错误|錯誤/.test(t)) return "bad_credentials";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const REASON_TEXT: Record<SiteLoginFailure, string> = {
|
||||
captcha: "站点要求验证码",
|
||||
two_factor: "站点要求二次验证",
|
||||
bad_credentials: "用户名或密码错误",
|
||||
account_blocked: "账号被封禁或未确认",
|
||||
challenge: "站点返回 Cloudflare 挑战页",
|
||||
auth_expired: "登录后仍未检测到登录态",
|
||||
network: "无法连接站点",
|
||||
unsupported: "站点不支持账密登录",
|
||||
unknown: "登录失败",
|
||||
};
|
||||
|
||||
function pairs(v: readonly unknown[] | undefined): Array<[string, string]> {
|
||||
const out: Array<[string, string]> = [];
|
||||
for (const p of v ?? []) {
|
||||
if (Array.isArray(p) && p.length >= 2) out.push([String(p[0]), String(p[1])]);
|
||||
else if (p && typeof p === "object" && "name" in p) {
|
||||
const o = p as { name: unknown; value?: unknown };
|
||||
out.push([String(o.name), String(o.value ?? "")]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
class Jar {
|
||||
private readonly map = new Map<string, string>();
|
||||
take(res: Response) {
|
||||
const list =
|
||||
typeof (res.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie === "function"
|
||||
? (res.headers as Headers & { getSetCookie: () => string[] }).getSetCookie()
|
||||
: (res.headers.get("set-cookie") ?? "").split(/,(?=\s*[^;,=\s]+=)/).filter(Boolean);
|
||||
for (const line of list) {
|
||||
const first = line.split(";")[0] ?? "";
|
||||
const eq = first.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const name = first.slice(0, eq).trim();
|
||||
const value = first.slice(eq + 1).trim();
|
||||
if (!value || value === "deleted" || /max-age=0\b/i.test(line)) this.map.delete(name);
|
||||
else this.map.set(name, value);
|
||||
}
|
||||
}
|
||||
get size() {
|
||||
return this.map.size;
|
||||
}
|
||||
header(): string {
|
||||
return [...this.map].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
}
|
||||
}
|
||||
|
||||
/** 账密登录;失败抛 SiteLoginError */
|
||||
export async function loginNexusPhp(input: NexusLoginInput): Promise<NexusLoginResult> {
|
||||
const fetchImpl = input.fetch ?? globalThis.fetch;
|
||||
const base = input.baseUrl.endsWith("/") ? input.baseUrl : `${input.baseUrl}/`;
|
||||
const jar = new Jar();
|
||||
const timeoutMs = input.timeoutMs ?? 20_000;
|
||||
|
||||
const request = async (url: string, init: RequestInit = {}): Promise<{ res: Response; text: string }> => {
|
||||
await input.limiter?.acquire(input.signal);
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const signal = input.signal ? AbortSignal.any([input.signal, timeout]) : timeout;
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("user-agent", input.userAgent);
|
||||
if (jar.size) headers.set("cookie", jar.header());
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchImpl(url, { ...init, headers, redirect: "manual", signal });
|
||||
} catch (e) {
|
||||
if (input.signal?.aborted) throw e;
|
||||
throw new SiteLoginError(
|
||||
"network",
|
||||
redactText(`请求 ${url} 失败:${timeout.aborted ? "超时" : (e as Error).message}`),
|
||||
);
|
||||
}
|
||||
jar.take(res);
|
||||
const text = await res.text().catch(() => "");
|
||||
if (res.status >= 500 && res.status !== 503) throw new SiteLoginError("network", `站点返回 HTTP ${res.status}`);
|
||||
if ((res.status === 403 || res.status === 503) && underChallenge(text)) {
|
||||
throw new SiteLoginError("challenge", REASON_TEXT.challenge);
|
||||
}
|
||||
if (res.status === 503) throw new SiteLoginError("network", "站点返回 HTTP 503");
|
||||
return { res, text };
|
||||
};
|
||||
|
||||
// 1. 登录页
|
||||
const loginUrl = new URL(input.login.path, base).toString();
|
||||
const page = await request(loginUrl);
|
||||
if (underChallenge(page.text)) throw new SiteLoginError("challenge", REASON_TEXT.challenge);
|
||||
// 2. 验证码
|
||||
if (/imagecode\.php/i.test(page.text)) throw new SiteLoginError("captcha", REASON_TEXT.captcha);
|
||||
const $ = load(page.text);
|
||||
const byAction = $("form[action*='takelogin']").first();
|
||||
const form = byAction.length ? byAction : $(`input[name='${input.login.passwordField}']`).closest("form").first();
|
||||
const action = new URL(form.attr("action") || "takelogin.php", loginUrl).toString();
|
||||
// 3. 表单
|
||||
const body = new URLSearchParams();
|
||||
const fieldNames = new Set<string>();
|
||||
const inputs = form.length ? form.find("input") : $("input");
|
||||
inputs.each((_, el) => {
|
||||
const name = $(el).attr("name");
|
||||
if (!name) return;
|
||||
fieldNames.add(name.toLowerCase());
|
||||
const type = ($(el).attr("type") ?? "text").toLowerCase();
|
||||
if (type === "hidden") body.set(name, $(el).attr("value") ?? "");
|
||||
if (type === "checkbox" && $(el).is("[checked]")) body.set(name, $(el).attr("value") ?? "on");
|
||||
});
|
||||
for (const [k, v] of pairs(input.login.selectDefaults)) body.set(k, v);
|
||||
for (const [k, v] of pairs(input.login.extraFormData)) body.set(k, v);
|
||||
body.set(input.login.usernameField, input.username);
|
||||
body.set(input.login.passwordField, input.password);
|
||||
const tfField = [...fieldNames].find((n) => TWO_FACTOR_FIELDS.includes(n));
|
||||
if (tfField && input.totpSecret) body.set(tfField, totpCode(input.totpSecret, input.now?.() ?? new Date()));
|
||||
|
||||
// 4. 提交 → 首页判定
|
||||
const post = await request(action, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", referer: loginUrl },
|
||||
body: body.toString(),
|
||||
});
|
||||
const location = post.res.headers.get("location") ?? "";
|
||||
const home = await request(new URL("index.php", base).toString());
|
||||
const successSel = input.loginSuccess || "a[href*='logout']";
|
||||
const home$ = load(home.text);
|
||||
if (home.res.status < 300 && home$(successSel).length > 0 && jar.size) {
|
||||
return { cookie: jar.header(), userAgent: input.userAgent };
|
||||
}
|
||||
// 5. 失败归类
|
||||
if (underChallenge(post.text) || underChallenge(home.text))
|
||||
throw new SiteLoginError("challenge", REASON_TEXT.challenge);
|
||||
const post$ = load(post.text);
|
||||
const errText = post$(input.login.errorCss).text().replace(/\s+/g, " ").trim();
|
||||
const pageText = post$("body").text().replace(/\s+/g, " ").trim();
|
||||
if (/imagecode\.php/i.test(post.text)) throw new SiteLoginError("captcha", REASON_TEXT.captcha);
|
||||
const nextHasTwoFactor =
|
||||
TWO_FACTOR_FIELDS.some((f) => new RegExp(`name=["']?${f}["'\\s>]`, "i").test(post.text)) ||
|
||||
/two_step|2fa|otp/i.test(location);
|
||||
if (nextHasTwoFactor && !input.totpSecret) throw new SiteLoginError("two_factor", REASON_TEXT.two_factor);
|
||||
const reason = classifyLoginError(errText || pageText.slice(0, 400));
|
||||
if (reason !== "unknown")
|
||||
throw new SiteLoginError(reason, `${REASON_TEXT[reason]}${errText ? `:${clip(errText)}` : ""}`);
|
||||
throw new SiteLoginError("unknown", errText ? `登录失败:${clip(errText)}` : REASON_TEXT.auth_expired);
|
||||
}
|
||||
|
||||
function clip(s: string, n = 200): string {
|
||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||
}
|
||||
|
||||
export function loginFailureText(reason: SiteLoginFailure): string {
|
||||
return REASON_TEXT[reason];
|
||||
}
|
||||
@@ -145,6 +145,8 @@ export interface UmtSiteExtension {
|
||||
* 主路径仍是 MP 用户信息解析器(`schema`),此处为备用数据,M2 使用。
|
||||
*/
|
||||
profile?: Record<string, string>;
|
||||
/** VIP 等级名(用户数据 vip 判定) */
|
||||
vipLevels?: string[];
|
||||
displayName?: string;
|
||||
/** 站点时间时区偏移,如 "+08:00";pubdate/freedate 按此转 UTC */
|
||||
timezone: string;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
* 3. 网络异常与非 200/500/403 状态:与 MP 一样返回空串继续,另把脱敏后的原因记在 `httpErrors`(入 raw)。
|
||||
* 4. 响应解码:按 Content-Type / `<meta charset>` 选 TextDecoder(MP `get_decoded_html_content` 用 chardet 探测)。
|
||||
*/
|
||||
import { load as loadCheerio } from "cheerio";
|
||||
import { redactText } from "../../rss/redact.ts";
|
||||
import { parseSizeMp } from "../spider/spider.ts";
|
||||
import { extractDomain, isLoggedIn, underChallenge } from "./common.ts";
|
||||
@@ -259,7 +260,8 @@ export abstract class SiteParserBase {
|
||||
),
|
||||
);
|
||||
let guard = 0;
|
||||
while (nextPage !== null && nextPage !== undefined && nextPage !== false && guard++ < MAX_PAGES) {
|
||||
const maxExtra = Math.max(0, (this._opts.maxSeedingPages ?? MAX_PAGES + 1) - 1);
|
||||
while (nextPage !== null && nextPage !== undefined && nextPage !== false && guard++ < maxExtra) {
|
||||
nextPage = await this._parseUserTorrentSeedingInfo(
|
||||
await this._getPageContent(
|
||||
urljoin(urljoin(this._baseUrl, this._torrentSeedingPage), nextPage),
|
||||
@@ -407,6 +409,23 @@ export abstract class SiteParserBase {
|
||||
}
|
||||
|
||||
/** MP `refresh_userdata` 里构造 `SiteUserData` 的部分 */
|
||||
/** VIP 判定(本项目扩展,见 SiteUserInfo.vip) */
|
||||
protected detectVip(): boolean | null {
|
||||
const css = this._opts.vipCss?.replace(/::.*$/, "").trim();
|
||||
const levels = this._opts.vipLevels?.filter(Boolean) ?? [];
|
||||
if (!css && !levels.length) return null;
|
||||
if (css && typeof this._indexHtml === "string" && this._indexHtml) {
|
||||
try {
|
||||
if (loadCheerio(this._indexHtml)(css).length > 0) return true;
|
||||
} catch {
|
||||
// 选择器不合法按未命中处理
|
||||
}
|
||||
}
|
||||
const level = (this.userLevel ?? "").toLowerCase();
|
||||
if (level && levels.some((l) => level.includes(l.toLowerCase()))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
toUserInfo(): SiteUserInfo {
|
||||
const num = (v: unknown) => {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
@@ -435,6 +454,7 @@ export abstract class SiteParserBase {
|
||||
seedingInfo: this.seedingInfo.map(([a, b]) => [num(a), num(b)] as [number, number]),
|
||||
messageUnread: num(this.messageUnread),
|
||||
messageUnreadContents: messages,
|
||||
vip: this.detectVip(),
|
||||
errMsg: this.errMsg,
|
||||
raw: this.toDict(),
|
||||
};
|
||||
|
||||
@@ -48,7 +48,8 @@ export class MTorrentSiteUserInfo extends SiteParserBase {
|
||||
|
||||
protected _parseSitePage(_htmlText: string): void {
|
||||
// 更换 api 地址
|
||||
this._baseUrl = `https://api.${extractDomain(this._baseUrl)}`;
|
||||
// 本项目扩展:站点配置了独立 API 地址(site.config.apiBaseUrl)时用它
|
||||
this._baseUrl = this._apiUrl ? this._apiUrl.replace(/\/+$/, "") : `https://api.${extractDomain(this._baseUrl)}`;
|
||||
this._userTrafficPage = null;
|
||||
this._userDetailPage = null;
|
||||
this._userBasicPage = "api/member/profile";
|
||||
|
||||
@@ -69,6 +69,11 @@ export interface SiteUserInfo {
|
||||
messageUnreadContents: SiteUnreadMessage[];
|
||||
/** H&R 未达标数:MP 解析器不提供,保留字段供站点扩展 */
|
||||
hrCount?: number;
|
||||
/**
|
||||
* VIP(本项目扩展):定义 `umt.profile.vip_css` 在首页命中,或 `umt.vipLevels` 与等级匹配 → true;
|
||||
* 两者都没配置为 null(不显示)
|
||||
*/
|
||||
vip: boolean | null;
|
||||
/** 解析错误说明(MP `err_msg`,如"未检测到已登陆") */
|
||||
errMsg: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
@@ -93,4 +98,10 @@ export interface SiteUserParserOptions {
|
||||
/** 共享站点限流器(与 list/search 共用) */
|
||||
limiter?: { acquire(signal?: AbortSignal): Promise<void> };
|
||||
signal?: AbortSignal;
|
||||
/** 做种分页上限(含第一页);缺省 50 */
|
||||
maxSeedingPages?: number;
|
||||
/** VIP 判定:首页选择器(定义 umt.profile.vip_css,可带 ::attr / ::text 后缀,判定时去掉) */
|
||||
vipCss?: string | null;
|
||||
/** VIP 判定:等级名列表(定义 umt.vipLevels),不区分大小写包含匹配 */
|
||||
vipLevels?: readonly string[] | null;
|
||||
}
|
||||
@@ -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