fix(sites): sync user parsers with MoviePilot v3.2.4
参考:docs/porting/moviepilot-downloader-and-sites.md。按GPLv3公开行为维护TS,补齐UUID身份、专站做种入口与汇总、用户资料回退及刷新边界;不引入上游源码或加密站点数据。
This commit is contained in:
1 parent
c38e7131f1
commit
7e4cdbf5ba
34 files changed
+603
-103
No files matched your search
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/__init__.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/__init__.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* 站点用户数据解析器基类。
|
* 站点用户数据解析器基类。
|
||||||
*
|
*
|
||||||
* 迁移自 MoviePilot e6820a8(GPLv3)`app/modules/indexer/parser/__init__.py` `SiteParserBase`,
|
* 迁移自 MoviePilot 3b156a5(GPLv3)`app/modules/indexer/parser/__init__.py` `SiteParserBase`,
|
||||||
* 函数一一对应(Python 私有 `_x_y` → TS `_xY`):
|
* 函数一一对应(Python 私有 `_x_y` → TS `_xY`):
|
||||||
* - `parse` → `parse`(页面获取顺序:首页/登录判定 → `_parse_site_page` → 基础信息 → 详情 → 未读消息 → 流量 → 做种分页)
|
* - `parse` → `parse`(页面获取顺序:首页/登录判定 → `_parse_site_page` → 基础信息 → 详情 → 未读消息 → 流量 → 做种分页)
|
||||||
* - `_pase_unread_msgs` → `_paseUnreadMsgs`(保留 MP 拼写)、`_parse_seeding_pages` → `_parseSeedingPages`
|
* - `_pase_unread_msgs` → `_paseUnreadMsgs`(保留 MP 拼写)、`_parse_seeding_pages` → `_parseSeedingPages`
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
* 与 MP 的差异(安全与健壮性):
|
* 与 MP 的差异(安全与健壮性):
|
||||||
* 1. 凭据经构造参数注入,只放请求头;`toDict()`/`raw` 不含 cookie/apikey/token。
|
* 1. 凭据经构造参数注入,只放请求头;`toDict()`/`raw` 不含 cookie/apikey/token。
|
||||||
* 2. 做种页与消息页翻页设上限(`MAX_PAGES`=50),防止站点返回自引用"下一页"导致死循环(MP 无上限)。
|
* 2. 做种页与消息页翻页设上限(`MAX_PAGES`=50),防止站点返回自引用"下一页"导致死循环(MP 无上限)。
|
||||||
* 3. 网络异常与非 200/500/403 状态:与 MP 一样返回空串继续,另把脱敏后的原因记在 `httpErrors`(入 raw)。
|
* 3. 非 200 状态记录诊断;网络异常与非 200/500/403 状态返回空串继续,原因脱敏后记在 `httpErrors`(入 raw)。
|
||||||
* 4. 响应解码:按 Content-Type / `<meta charset>` 选 TextDecoder(MP `get_decoded_html_content` 用 chardet 探测)。
|
* 4. 响应解码:按 Content-Type / `<meta charset>` 选 TextDecoder(MP `get_decoded_html_content` 用 chardet 探测)。
|
||||||
*/
|
*/
|
||||||
import { load as loadCheerio } from "cheerio";
|
import { load as loadCheerio } from "cheerio";
|
||||||
@@ -114,6 +114,8 @@ export abstract class SiteParserBase {
|
|||||||
protected _userBasicHeaders: Headers_ | null = null;
|
protected _userBasicHeaders: Headers_ | null = null;
|
||||||
// 用户详情
|
// 用户详情
|
||||||
protected _userDetailPage: string | null = "userdetails.php?id=";
|
protected _userDetailPage: string | null = "userdetails.php?id=";
|
||||||
|
/** 当前会话接口可在还不知道用户 ID 时读取详情。 */
|
||||||
|
protected _userDetailRequiresUserid = true;
|
||||||
protected _userDetailParams: Params | null = null;
|
protected _userDetailParams: Params | null = null;
|
||||||
protected _userDetailHeaders: Headers_ | null = null;
|
protected _userDetailHeaders: Headers_ | null = null;
|
||||||
// 流量
|
// 流量
|
||||||
@@ -192,7 +194,7 @@ export abstract class SiteParserBase {
|
|||||||
} else {
|
} else {
|
||||||
await this._parseUserBaseInfo(this._indexHtml as string);
|
await this._parseUserBaseInfo(this._indexHtml as string);
|
||||||
}
|
}
|
||||||
if (this._userDetailPage) {
|
if (this._userDetailPage && (!this._userDetailRequiresUserid || this._hasValidUserid())) {
|
||||||
await this._parseUserDetailInfo(
|
await this._parseUserDetailInfo(
|
||||||
await this._getPageContent(
|
await this._getPageContent(
|
||||||
urljoin(this._baseUrl, this._userDetailPage),
|
urljoin(this._baseUrl, this._userDetailPage),
|
||||||
@@ -251,7 +253,7 @@ export abstract class SiteParserBase {
|
|||||||
|
|
||||||
/** MP `_parse_seeding_pages` */
|
/** MP `_parse_seeding_pages` */
|
||||||
protected async _parseSeedingPages(): Promise<void> {
|
protected async _parseSeedingPages(): Promise<void> {
|
||||||
if (!this._torrentSeedingPage) return;
|
if (!this._torrentSeedingPage || !this._hasValidUserid()) return;
|
||||||
let nextPage: NextPage = await this._parseUserTorrentSeedingInfo(
|
let nextPage: NextPage = await this._parseUserTorrentSeedingInfo(
|
||||||
await this._getPageContent(
|
await this._getPageContent(
|
||||||
urljoin(this._baseUrl, this._torrentSeedingPage),
|
urljoin(this._baseUrl, this._torrentSeedingPage),
|
||||||
@@ -273,6 +275,12 @@ export abstract class SiteParserBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MP `_has_valid_userid`:空白与占位 ID 0 不触发用户专属请求。 */
|
||||||
|
protected _hasValidUserid(): boolean {
|
||||||
|
const id = this.userid?.trim();
|
||||||
|
return Boolean(id && id !== "0");
|
||||||
|
}
|
||||||
|
|
||||||
/** MP `_prepare_html_text`:去掉 `\d+px` 与 `#\d+` 干扰 */
|
/** MP `_prepare_html_text`:去掉 `\d+px` 与 `#\d+` 干扰 */
|
||||||
static prepareHtmlText(htmlText: string): string {
|
static prepareHtmlText(htmlText: string): string {
|
||||||
return String(htmlText ?? "")
|
return String(htmlText ?? "")
|
||||||
@@ -331,9 +339,9 @@ export abstract class SiteParserBase {
|
|||||||
this.httpErrors.push(redactText(`${init.method} ${url}:${timeout.aborted ? "超时" : (e as Error).message}`));
|
this.httpErrors.push(redactText(`${init.method} ${url}:${timeout.aborted ? "超时" : (e as Error).message}`));
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
if (res.status !== 200) this.httpErrors.push(`${init.method}:HTTP ${res.status}`);
|
||||||
if (![200, 500, 403].includes(res.status)) {
|
if (![200, 500, 403].includes(res.status)) {
|
||||||
await res.body?.cancel().catch(() => {});
|
await res.body?.cancel().catch(() => {});
|
||||||
this.httpErrors.push(redactText(`${init.method} ${url}:HTTP ${res.status}`));
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/bitpt.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/bitpt.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* 极速之星(bitpt.cn)站点用户信息解析器(原作者 ThedoRap)。
|
* 极速之星(bitpt.cn)站点用户信息解析器(原作者 ThedoRap)。
|
||||||
*
|
*
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
* `parse` → `parse`(基类流程后取 uid → `userdetails.php?uid=` → `browse.php?t=myseed&st=2&d=desc&p=n` 逐页累加)。
|
* `parse` → `parse`(基类流程后取 uid → `userdetails.php?uid=` → `browse.php?t=myseed&st=2&d=desc&p=n` 逐页累加)。
|
||||||
* BeautifulSoup 以 cheerio 等价实现。
|
* BeautifulSoup 以 cheerio 等价实现。
|
||||||
*
|
*
|
||||||
* 与 MP 的偏离:末尾不把字段转字符串(同 zhixing.ts);做种翻页上限 50 页。
|
* 数值字段按 MP v3.2.4 保持数值;本项目做种翻页上限 50 页。
|
||||||
*/
|
*/
|
||||||
import { load } from "cheerio";
|
import { load } from "cheerio";
|
||||||
import { type NextPage, SiteParserBase, urljoin } from "./base.ts";
|
import { type NextPage, SiteParserBase, urljoin } from "./base.ts";
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { extractDomain } from "./common.ts";
|
||||||
|
|
||||||
|
describe("站点域名归一", () => {
|
||||||
|
it("Cookie风格前导点不进入站点域名,特殊站点与多级地址保持原规则", () => {
|
||||||
|
expect(extractDomain(".tracker.example.com")).toBe("example.com");
|
||||||
|
expect(extractDomain("...")).toBe("");
|
||||||
|
expect(extractDomain("https://pt.sjtu.edu.cn/")).toBe("pt.sjtu.edu.cn");
|
||||||
|
expect(extractDomain("https://u2.dmhy.org/")).toBe("u2.dmhy.org");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/foundation/text.py @ e6820a8
|
* @mp-source app/foundation/text.py @ 3b156a5
|
||||||
* @mp-source app/foundation/temporal.py @ e6820a8
|
* @mp-source app/foundation/temporal.py @ 3b156a5
|
||||||
* @mp-source app/domain/site.py @ e6820a8
|
* @mp-source app/domain/site.py @ 3b156a5
|
||||||
* @mp-source app/adapters/network/cloudflare.py @ e6820a8
|
* @mp-source app/adapters/network/cloudflare.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* 解析器公共工具:MoviePilot e6820a8(GPLv3)
|
* 解析器公共工具:MoviePilot 3b156a5(GPLv3)
|
||||||
* - `app/foundation/text.py` `parse_int/parse_float/contains_chinese` → `parseIntMp/parseFloatMp/containsChinese`
|
* - `app/foundation/text.py` `parse_int/parse_float/contains_chinese` → `parseIntMp/parseFloatMp/containsChinese`
|
||||||
* - `app/foundation/temporal.py` `normalize_datetime/format_timestamp` → 同名(dateparser 的保守子集)
|
* - `app/foundation/temporal.py` `normalize_datetime/format_timestamp` → 同名(dateparser 的保守子集)
|
||||||
* - `app/domain/site.py` `SiteUtils.is_logged_in` → `isLoggedIn`
|
* - `app/domain/site.py` `SiteUtils.is_logged_in` → `isLoggedIn`
|
||||||
@@ -192,6 +192,8 @@ export function extractDomain(url: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!netloc) return "";
|
if (!netloc) return "";
|
||||||
|
netloc = netloc.replace(/^\.+/, "");
|
||||||
|
if (!netloc) return "";
|
||||||
const labels = netloc.split(".");
|
const labels = netloc.split(".");
|
||||||
if (labels.length > 3) return netloc;
|
if (labels.length > 3) return netloc;
|
||||||
return labels.slice(-2).join(".");
|
return labels.slice(-2).join(".");
|
||||||
|
|||||||
@@ -33,4 +33,14 @@ describe("DiscuzUserInfo(MP discuz.py)", () => {
|
|||||||
expect(info.errMsg).toContain("未检测到已登陆");
|
expect(info.errMsg).toContain("未检测到已登陆");
|
||||||
expect(info.userId).toBeNull();
|
expect(info.userId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["<em>分享率</em> (1,234.5)", "分享率:<b>8.25</b>"])("分享率读取整条文本且不覆盖积分: %s", async (ratio) => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": { body: DISCUZ_USER_INDEX },
|
||||||
|
"/home.php": { body: `<html><body><ul><li>${ratio}</li><li><em>积分</em>700</li></ul></body></html>` },
|
||||||
|
});
|
||||||
|
const info = await runParser(DiscuzUserInfo, { fetch: m.fetch });
|
||||||
|
expect(info.ratio).toBe(ratio.includes("1,234") ? 1234.5 : 8.25);
|
||||||
|
expect(info.bonus).toBe(700);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/discuz.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/discuz.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* Discuz!X 站点用户信息解析器。
|
* Discuz!X 站点用户信息解析器。
|
||||||
*
|
*
|
||||||
* 函数对照(`DiscuzUserInfo`):`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page` → 空、
|
* 函数对照(`DiscuzUserInfo`):`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page` → 空、
|
||||||
* `_parse_user_detail_info` → `_parseUserDetailInfo`、`_parse_user_torrent_seeding_info` →
|
* `_parse_user_detail_info` → `_parseUserDetailInfo`、`_parse_user_torrent_seeding_info` →
|
||||||
* `_parseUserTorrentSeedingInfo`、`_parse_user_traffic_info` / `_parse_message_unread_links` / `_parse_message_content` → 空。
|
* `_parseUserTorrentSeedingInfo`、`_parse_user_traffic_info` / `_parse_message_unread_links` / `_parse_message_content` → 空。
|
||||||
* XPath 与正则逐字保留(含 MP 把"分享率"括号值写入 bonus 的原行为,随后被"积分"覆盖)。
|
* 分享率从整条列表项读取,独立于积分字段。
|
||||||
*/
|
*/
|
||||||
import { type NextPage, SiteParserBase } from "./base.ts";
|
import { type NextPage, SiteParserBase } from "./base.ts";
|
||||||
import { normalizeDatetime, parseFloatMp, parseIntMp, reSearch } from "./common.ts";
|
import { normalizeDatetime, parseFloatMp, parseIntMp, reSearch } from "./common.ts";
|
||||||
@@ -45,10 +45,12 @@ export class DiscuzUserInfo extends SiteParserBase {
|
|||||||
if (levels.length) this.userLevel = levels[levels.length - 1]!.trim();
|
if (levels.length) this.userLevel = levels[levels.length - 1]!.trim();
|
||||||
const joinAt = html.strings('//li[em[text()="注册时间"]]/text()');
|
const joinAt = html.strings('//li[em[text()="注册时间"]]/text()');
|
||||||
if (joinAt.length) this.joinAt = normalizeDatetime(joinAt[0]!.trim()) ?? null;
|
if (joinAt.length) this.joinAt = normalizeDatetime(joinAt[0]!.trim()) ?? null;
|
||||||
const ratioText = html.strings('//li[contains(.//text(), "分享率")]//text()');
|
for (const item of html.nodes('//li[contains(., "分享率")]')) {
|
||||||
if (ratioText.length) {
|
const m = /分享率\s*[::]?\s*[((]?\s*([\d,.]+)/.exec(item.text());
|
||||||
const m = reSearch(/\(([\d,.]+)\)/, ratioText[0]);
|
if (m) {
|
||||||
if (m?.[1]?.trim()) this.bonus = parseFloatMp(m[1]);
|
this.ratio = parseFloatMp(m[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const bonusText = html.strings('//li[em[text()="积分"]]/text()');
|
const bonusText = html.strings('//li[em[text()="积分"]]/text()');
|
||||||
if (bonusText.length) this.bonus = parseFloatMp(bonusText[0]!.trim());
|
if (bonusText.length) this.bonus = parseFloatMp(bonusText[0]!.trim());
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/foundation/dom.py @ e6820a8
|
* @mp-source app/foundation/dom.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* lxml `etree.HTML(...).xpath(...)` 的 TS 等价层(站点用户信息解析器专用)。
|
* lxml `etree.HTML(...).xpath(...)` 的 TS 等价层(站点用户信息解析器专用)。
|
||||||
*
|
*
|
||||||
* MP 解析器(app/modules/indexer/parser/*.py,提交 e6820a8,GPLv3)全部用 lxml XPath 1.0 取值;
|
* MP 解析器(app/modules/indexer/parser/*.py,提交 3b156a5,GPLv3)全部用 lxml XPath 1.0 取值;
|
||||||
* 为了让 XPath 表达式能逐字迁移,本层:
|
* 为了让 XPath 表达式能逐字迁移,本层:
|
||||||
* 1. 用 htmlparser2(经 cheerio,HTML 模式)解析——与 libxml2 HTML 解析器一样**不插入 <tbody>**,
|
* 1. 用 htmlparser2(经 cheerio,HTML 模式)解析——与 libxml2 HTML 解析器一样**不插入 <tbody>**,
|
||||||
* 标签/属性名小写,实体解码(` ` → U+00A0);
|
* 标签/属性名小写,实体解码(` ` → U+00A0);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/file_list.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/file_list.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* FileList 站点用户数据解析器(MP `FileListSiteUserInfo`)。
|
* FileList 站点用户数据解析器(MP `FileListSiteUserInfo`)。
|
||||||
* 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_user_base_info` → `_parseUserBaseInfo`、
|
* 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_user_base_info` → `_parseUserBaseInfo`、
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/gazelle.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/gazelle.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* Gazelle 站点用户数据解析器(MP `GazelleSiteUserInfo`)。
|
* Gazelle 站点用户数据解析器(MP `GazelleSiteUserInfo`)。
|
||||||
* 函数对照:`_parse_logged_in` → `_parseLoggedIn`、`_parse_user_base_info` → `_parseUserBaseInfo`、
|
* 函数对照:`_parse_logged_in` → `_parseLoggedIn`、`_parse_user_base_info` → `_parseUserBaseInfo`、
|
||||||
|
|||||||
@@ -50,6 +50,21 @@ describe("HDDolbySiteUserInfo(MP hddolby.py)", () => {
|
|||||||
fetchMessages: true,
|
fetchMessages: true,
|
||||||
});
|
});
|
||||||
expect(info.userId).toBeNull();
|
expect(info.userId).toBeNull();
|
||||||
expect(m.requests.map((r) => r.url.pathname)).toEqual(["/api/v1/user/data", "/api/v1/user/peers"]);
|
expect(m.requests.map((r) => r.url.pathname)).toEqual(["/api/v1/user/data"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("独立API地址保留路径前缀", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"https://api.test.invalid/custom/v2/user/data": { body: HDDOLBY_USER_DATA },
|
||||||
|
"https://api.test.invalid/custom/v2/user/peers": { body: HDDOLBY_USER_PEERS },
|
||||||
|
});
|
||||||
|
const info = await runParser(HDDolbySiteUserInfo, {
|
||||||
|
fetch: m.fetch,
|
||||||
|
url: "https://www.hddolby.com/",
|
||||||
|
apiUrl: "https://api.test.invalid/custom/v2/",
|
||||||
|
apikey: "key",
|
||||||
|
});
|
||||||
|
expect(info).toMatchObject({ userId: "1", seeding: 2, seedingSize: 1500 });
|
||||||
|
expect(m.requests.map((r) => r.url.pathname)).toEqual(["/custom/v2/user/data", "/custom/v2/user/peers"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/hddolby.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/hddolby.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* HDDolby 用户数据解析器(MP `HDDolbySiteUserInfo`,apikey 模式)。
|
* HDDolby 用户数据解析器(MP `HDDolbySiteUserInfo`,apikey 模式)。
|
||||||
* 函数对照:`HDDolby_sysRoleList` → `HDDOLBY_SYS_ROLE_LIST`、`_parse_site_page` → `_parseSitePage`(api.<域>)、
|
* 函数对照:`HDDolby_sysRoleList` → `HDDOLBY_SYS_ROLE_LIST`、`_parse_site_page` → `_parseSitePage`(api.<域>)、
|
||||||
@@ -49,16 +49,17 @@ export class HDDolbySiteUserInfo extends SiteParserBase {
|
|||||||
override requestMode = "apikey" as const;
|
override requestMode = "apikey" as const;
|
||||||
|
|
||||||
protected _parseSitePage(_htmlText: string): void {
|
protected _parseSitePage(_htmlText: string): void {
|
||||||
this._baseUrl = `https://api.${extractDomain(this._baseUrl)}`;
|
const apiUrl = this._apiUrl || `https://api.${extractDomain(this._baseUrl)}/api/v1`;
|
||||||
|
this._baseUrl = `${apiUrl.replace(/\/+$/, "")}/`;
|
||||||
this._userTrafficPage = null;
|
this._userTrafficPage = null;
|
||||||
this._userDetailPage = null;
|
this._userDetailPage = null;
|
||||||
this._userBasicPage = "api/v1/user/data";
|
this._userBasicPage = "user/data";
|
||||||
this._userBasicParams = {};
|
this._userBasicParams = {};
|
||||||
this._userBasicHeaders = { "Content-Type": "application/json", Accept: "application/json, text/plain, */*" };
|
this._userBasicHeaders = { "Content-Type": "application/json", Accept: "application/json, text/plain, */*" };
|
||||||
this._sysMailUnreadPage = null;
|
this._sysMailUnreadPage = null;
|
||||||
this._userMailUnreadPage = null;
|
this._userMailUnreadPage = null;
|
||||||
this._mailUnreadParams = {};
|
this._mailUnreadParams = {};
|
||||||
this._torrentSeedingPage = "api/v1/user/peers";
|
this._torrentSeedingPage = "user/peers";
|
||||||
this._torrentSeedingParams = {};
|
this._torrentSeedingParams = {};
|
||||||
this._torrentSeedingHeaders = { "Content-Type": "application/json", Accept: "application/json, text/plain, */*" };
|
this._torrentSeedingHeaders = { "Content-Type": "application/json", Accept: "application/json, text/plain, */*" };
|
||||||
this._additionHeaders = { "x-api-key": this.apikey ?? "" };
|
this._additionHeaders = { "x-api-key": this.apikey ?? "" };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/ipt.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/ipt.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* IPTorrents 站点用户数据解析器(MP `IptSiteUserInfo`)。
|
* IPTorrents 站点用户数据解析器(MP `IptSiteUserInfo`)。
|
||||||
* 函数对照:`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page`(空)、
|
* 函数对照:`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page`(空)、
|
||||||
|
|||||||
@@ -88,4 +88,17 @@ describe("MTorrentSiteUserInfo(MP mtorrent.py)", () => {
|
|||||||
expect(m2.requests).toHaveLength(0);
|
expect(m2.requests).toHaveLength(0);
|
||||||
expect(info2.errMsg).toBeTruthy();
|
expect(info2.errMsg).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("roleName覆盖旧角色编号,空做种页直接停止,不反复查询过时的总数", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
[`POST ${API}/api/member/profile`]: {
|
||||||
|
body: { code: "0", data: { id: 91, username: "reader", role: "2", roleName: "Special Member" } },
|
||||||
|
},
|
||||||
|
[`POST ${API}/api/member/getUserTorrentList`]: { body: { code: "0", data: { data: [] } } },
|
||||||
|
[`POST ${API}/api/tracker/myPeerStatus`]: { body: { code: "0", data: { seeder: 100 } } },
|
||||||
|
});
|
||||||
|
const info = await runParser(MTorrentSiteUserInfo, { fetch: m.fetch, url: "https://kp.m-team.cc/", apikey: "key" });
|
||||||
|
expect(info).toMatchObject({ userLevel: "Special Member", seeding: 0 });
|
||||||
|
expect(m.requests.map((r) => r.url.pathname)).toEqual(["/api/member/profile", "/api/member/getUserTorrentList"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/mtorrent.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/mtorrent.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* M-Team(mTorrent API)用户数据解析器(MP `MTorrentSiteUserInfo`,apikey 模式)。
|
* M-Team(mTorrent API)用户数据解析器(MP `MTorrentSiteUserInfo`,apikey 模式)。
|
||||||
* 函数对照:`MTeam_sysRoleList` → `MTEAM_SYS_ROLE_LIST`、`_parse_site_page` → `_parseSitePage`(改用 api.<域> 接口)、
|
* 函数对照:`MTeam_sysRoleList` → `MTEAM_SYS_ROLE_LIST`、`_parse_site_page` → `_parseSitePage`(改用 api.<域> 接口)、
|
||||||
@@ -79,7 +79,7 @@ export class MTorrentSiteUserInfo extends SiteParserBase {
|
|||||||
const memberCount = (u.memberCount ?? {}) as Json;
|
const memberCount = (u.memberCount ?? {}) as Json;
|
||||||
this.userid = u.id == null ? null : String(u.id);
|
this.userid = u.id == null ? null : String(u.id);
|
||||||
this.username = (u.username as string) ?? null;
|
this.username = (u.username as string) ?? null;
|
||||||
this.userLevel = MTEAM_SYS_ROLE_LIST[String(u.role || "1")] ?? null;
|
this.userLevel = (u.roleName as string) || MTEAM_SYS_ROLE_LIST[String(u.role || "1")] || null;
|
||||||
this.joinAt = ((u.memberStatus ?? {}) as Json).createdDate as string | null;
|
this.joinAt = ((u.memberStatus ?? {}) as Json).createdDate as string | null;
|
||||||
this.upload = pyInt(memberCount.uploaded || "0");
|
this.upload = pyInt(memberCount.uploaded || "0");
|
||||||
this.download = pyInt(memberCount.downloaded || "0");
|
this.download = pyInt(memberCount.downloaded || "0");
|
||||||
@@ -117,6 +117,7 @@ export class MTorrentSiteUserInfo extends SiteParserBase {
|
|||||||
this.seeding += pageSeeding;
|
this.seeding += pageSeeding;
|
||||||
this.seedingSize += pageSeedingSize;
|
this.seedingSize += pageSeedingSize;
|
||||||
this.seedingInfo.push(...pageSeedingInfo);
|
this.seedingInfo.push(...pageSeedingInfo);
|
||||||
|
if (!pageSeeding) return null;
|
||||||
|
|
||||||
// 查询总做种数
|
// 查询总做种数
|
||||||
let seederCount = 0;
|
let seederCount = 0;
|
||||||
|
|||||||
@@ -72,4 +72,30 @@ describe("NexusAudiencesSiteUserInfo(MP nexus_audiences.py)", () => {
|
|||||||
expect(NexusAudiencesSiteUserInfo.extractInboxUnreadBadge(" 5 ")).toBe(5);
|
expect(NexusAudiencesSiteUserInfo.extractInboxUnreadBadge(" 5 ")).toBe(5);
|
||||||
expect(NexusAudiencesSiteUserInfo.extractInboxUnreadBadge("新")).toBeNull();
|
expect(NexusAudiencesSiteUserInfo.extractInboxUnreadBadge("新")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("优先顶部用户栏,忽略列表上传者卡片的身份、等级与流量", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: `<html><body><a href="logout.php">退出</a>
|
||||||
|
<header class="site-userbar"><a href="userdetails.php?id=11" data-uploader-url="userdetails.php?id=11" data-uploader-label="self" data-uploader-badge="(站点别名) Power User" data-uploader-stats='[{"tone":"uploaded","value":"8 GB"}]'>self</a><span class="site-userbar__compact-metric--ratio"><span>4</span></span></header>
|
||||||
|
<article><a data-uploader-url="userdetails.php?id=22" data-uploader-label="uploader" data-uploader-badge="Moderator" data-uploader-stats='[{"tone":"uploaded","value":"999 TB"}]'>other</a><span class="site-userbar__compact-metric--ratio"><span>999</span></span></article>
|
||||||
|
</body></html>`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const info = await runParser(NexusAudiencesSiteUserInfo, { fetch: m.fetch });
|
||||||
|
expect(info).toMatchObject({ userId: "11", username: "self", userLevel: "Power User", upload: 8 * GiB, ratio: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("没有顶部用户栏时只读取当前ID的卡片,不读取其他用户", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: `<html><body><a href="logout.php">退出</a><a href="userdetails.php?id=11">self</a>
|
||||||
|
<div data-uploader-url="userdetails.php?id=11" data-uploader-label="self" data-uploader-stats='[{"tone":"uploaded","value":"8 GB"}]'></div>
|
||||||
|
<div data-uploader-url="userdetails.php?id=22" data-uploader-label="other" data-uploader-stats='[{"tone":"uploaded","value":"999 TB"}]'></div>
|
||||||
|
</body></html>`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const info = await runParser(NexusAudiencesSiteUserInfo, { fetch: m.fetch });
|
||||||
|
expect(info).toMatchObject({ userId: "11", username: "self", upload: 8 * GiB });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/nexus_audiences.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/nexus_audiences.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* 观众(Audiences)站点用户信息解析器,继承 NexusPHP 通用解析。
|
* 观众(Audiences)站点用户信息解析器,继承 NexusPHP 通用解析。
|
||||||
*
|
*
|
||||||
@@ -273,16 +273,27 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo {
|
|||||||
private parseUserbarInfo(htmlText: string): void {
|
private parseUserbarInfo(htmlText: string): void {
|
||||||
const html = parseHtml(htmlText);
|
const html = parseHtml(htmlText);
|
||||||
if (!hasChildElements(html)) return;
|
if (!hasChildElements(html)) return;
|
||||||
for (const node of html.nodes("//*[@data-uploader-url or @data-uploader-stats]")) {
|
const userbar = html.nodes('//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar ")]')[0];
|
||||||
|
const scope = userbar ?? html;
|
||||||
|
let nodes = scope.nodes(".//*[@data-uploader-url or @data-uploader-stats]");
|
||||||
|
if (!userbar) {
|
||||||
|
const matching = nodes.filter((node) => {
|
||||||
|
const url = node.attr("data-uploader-url");
|
||||||
|
if (!url || !this.userid) return false;
|
||||||
|
return /userdetails\.php\?id=([^&]+)/.exec(url)?.[1] === this.userid;
|
||||||
|
});
|
||||||
|
nodes = matching.length ? matching : nodes.length === 1 ? nodes : [];
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
this.parseUserIdentity(node);
|
this.parseUserIdentity(node);
|
||||||
this.parseUploaderStats(node.attr("data-uploader-stats"));
|
this.parseUploaderStats(node.attr("data-uploader-stats"));
|
||||||
}
|
}
|
||||||
// data-uploader-stats 不包含分享率,需从 compact metric 的 class 中读取
|
// data-uploader-stats 不包含分享率,需从 compact metric 的 class 中读取
|
||||||
this.parseCompactMetric(html, "ratio", "ratio");
|
this.parseCompactMetric(scope, "ratio", "ratio");
|
||||||
this.parseCompactMetric(html, "uploaded", "upload");
|
this.parseCompactMetric(scope, "uploaded", "upload");
|
||||||
this.parseCompactMetric(html, "downloaded", "download");
|
this.parseCompactMetric(scope, "downloaded", "download");
|
||||||
this.parseCompactMetric(html, "bonus", "bonus");
|
this.parseCompactMetric(scope, "bonus", "bonus");
|
||||||
this.parseCompactMetric(html, "active", "active");
|
this.parseCompactMetric(scope, "active", "active");
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseUserIdentity(node: XNode): void {
|
private parseUserIdentity(node: XNode): void {
|
||||||
@@ -292,7 +303,11 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo {
|
|||||||
const username = node.attr("data-uploader-label");
|
const username = node.attr("data-uploader-label");
|
||||||
if (username?.trim()) this.username = username.trim();
|
if (username?.trim()) this.username = username.trim();
|
||||||
const level = node.attr("data-uploader-badge");
|
const level = node.attr("data-uploader-badge");
|
||||||
if (level?.trim()) this.userLevel = level.trim();
|
const commonLevel = level
|
||||||
|
?.trim()
|
||||||
|
.replace(/^\([^)]*\)\s*/, "")
|
||||||
|
.trim();
|
||||||
|
if (commonLevel) this.userLevel = commonLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseUploaderStats(statsText: string | null): void {
|
private parseUploaderStats(statsText: string | null): void {
|
||||||
@@ -316,12 +331,12 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo {
|
|||||||
|
|
||||||
private parseCompactMetric(html: XNode, metric: string, field: string): void {
|
private parseCompactMetric(html: XNode, metric: string, field: string): void {
|
||||||
let values = html.strings(
|
let values = html.strings(
|
||||||
`//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar__compact-metric--${metric} ")]` +
|
`.//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar__compact-metric--${metric} ")]` +
|
||||||
"//span[normalize-space()][last()]/text()",
|
"//span[normalize-space()][last()]/text()",
|
||||||
);
|
);
|
||||||
if (!values.length) {
|
if (!values.length) {
|
||||||
values = html.strings(
|
values = html.strings(
|
||||||
`//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar__compact-metric--${metric} ")]` +
|
`.//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar__compact-metric--${metric} ")]` +
|
||||||
"/text()",
|
"/text()",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -381,7 +396,7 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo {
|
|||||||
|
|
||||||
/** 做种页只读 Total 汇总行 */
|
/** 做种页只读 Total 汇总行 */
|
||||||
protected override async _parseSeedingPages(): Promise<void> {
|
protected override async _parseSeedingPages(): Promise<void> {
|
||||||
if (!this._torrentSeedingPage) return;
|
if (!this._torrentSeedingPage || !this._hasValidUserid()) return;
|
||||||
this._torrentSeedingHeaders = { Referer: urljoin(this._baseUrl, this._userDetailPage ?? "") };
|
this._torrentSeedingHeaders = { Referer: urljoin(this._baseUrl, this._userDetailPage ?? "") };
|
||||||
const htmlText = await this._getPageContent(
|
const htmlText = await this._getPageContent(
|
||||||
urljoin(this._baseUrl, this._torrentSeedingPage),
|
urljoin(this._baseUrl, this._torrentSeedingPage),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/nexus_hhanclub.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/nexus_hhanclub.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* 憨憨(HHClub)站点用户信息解析器,继承 NexusPHP 通用解析。
|
* 憨憨(HHClub)站点用户信息解析器,继承 NexusPHP 通用解析。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe("NexusPhpSiteUserInfo(MP nexus_php.py)", () => {
|
|||||||
const m = createMockFetch({ "/": { body: NEXUS_PHP_CF_CHALLENGE, status: 403 } });
|
const m = createMockFetch({ "/": { body: NEXUS_PHP_CF_CHALLENGE, status: 403 } });
|
||||||
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
|
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
|
||||||
expect(info.errMsg).toContain("未检测到已登陆");
|
expect(info.errMsg).toContain("未检测到已登陆");
|
||||||
expect(info.raw.http_errors).toEqual([expect.stringContaining("Cloudflare")]);
|
expect(info.raw.http_errors).toEqual(["GET:HTTP 403", expect.stringContaining("Cloudflare")]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("_fixup_next_page_url:补 userid/type,已有 userid 原样,无 userid 停止", () => {
|
it("_fixup_next_page_url:补 userid/type,已有 userid 原样,无 userid 停止", () => {
|
||||||
@@ -68,4 +68,151 @@ describe("NexusPhpSiteUserInfo(MP nexus_php.py)", () => {
|
|||||||
expect(NexusPhpSiteUserInfo.fixupNextPageUrl("x.php?userid=3&page=2", "7")).toBe("x.php?userid=3&page=2");
|
expect(NexusPhpSiteUserInfo.fixupNextPageUrl("x.php?userid=3&page=2", "7")).toBe("x.php?userid=3&page=2");
|
||||||
expect(NexusPhpSiteUserInfo.fixupNextPageUrl("x.php?page=2", null)).toBeNull();
|
expect(NexusPhpSiteUserInfo.fixupNextPageUrl("x.php?page=2", null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([null, "0"])("无有效用户ID %s 时不请求详情与做种", async (id) => {
|
||||||
|
const link = id === null ? "" : `<a href="userdetails.php?id=${id}">用户</a>`;
|
||||||
|
const m = createMockFetch({ "/": { body: `<html><body><a href="logout.php">退出</a>${link}</body></html>` } });
|
||||||
|
await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
|
||||||
|
expect(m.requests.map((r) => r.url.pathname)).toEqual(["/"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("UUID 身份在详情与分页请求中保持一致", async () => {
|
||||||
|
const id = "12345678-abcd-4abc-8def-123456789abc";
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: `<html><body><a href="logout.php">退出</a><a href="userdetails.php?uuid=${id}"><b>uuid-user</b></a></body></html>`,
|
||||||
|
},
|
||||||
|
"/userdetails.php": { body: "<html><body>资料</body></html>" },
|
||||||
|
"/getusertorrentlistajax.php": (req) => ({
|
||||||
|
body: `<table class="torrents"><tr><td>名称</td><td>备注</td><td>大小</td><td>在做种</td></tr><tr><td>测试</td><td></td><td>2 GB</td><td>3</td></tr></table>${req.url.searchParams.has("page") ? "" : '<a href="getusertorrentlistajax.php?page=1">下一页</a>'}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
|
||||||
|
expect(info).toMatchObject({ userId: id, username: "uuid-user", seeding: 2, seedingSize: 4 * 1024 ** 3 });
|
||||||
|
const requests = m.requests.filter((r) => r.url.pathname === "/getusertorrentlistajax.php");
|
||||||
|
expect(requests).toHaveLength(2);
|
||||||
|
for (const req of requests) {
|
||||||
|
expect(req.url.searchParams.get("useruuid")).toBe(id);
|
||||||
|
expect(req.url.searchParams.has("userid")).toBe(false);
|
||||||
|
}
|
||||||
|
expect(NexusPhpSiteUserInfo.fixupNextPageUrl("x.php?useruuid=existing&page=2", id)).toBe(
|
||||||
|
"x.php?useruuid=existing&page=2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("详情表格补齐首页缺失的流量和分享率,排除总上传等无关字段", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: '<html><body><a href="logout.php">退出</a><a href="userdetails.php?id=21">reader</a></body></html>',
|
||||||
|
},
|
||||||
|
"/userdetails.php": {
|
||||||
|
body: "<table><tr><td>总上传</td><td>999 TB</td></tr><tr><td><b>上传量:</b></td><td>24 GiB (已优惠)</td></tr><tr><td>下载量: 3 GiB</td></tr><tr><td>分享率</td><td>8.125</td></tr></table>",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
|
||||||
|
expect(info).toMatchObject({ upload: 24 * 1024 ** 3, download: 3 * 1024 ** 3, ratio: 8.125 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("详情流量只回填零字段,并在没有显示分享率时用最终流量计算", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: '<html><body><a href="logout.php">退出</a><a href="userdetails.php?id=21">reader</a><span>上传: 12 GB</span></body></html>',
|
||||||
|
},
|
||||||
|
"/userdetails.php": {
|
||||||
|
body: "<table><tr><td>上传</td><td>999 TB</td></tr><tr><td>下载</td><td>3 GB</td></tr></table>",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
|
||||||
|
expect(info).toMatchObject({ upload: 12 * 1024 ** 3, download: 3 * 1024 ** 3, ratio: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['<a href="mybonus.php"><span>1,234.5</span></a>', 1234.5],
|
||||||
|
['<a href="mybonus.php"><b>魔力</b></a>: 2,345.6', 2345.6],
|
||||||
|
['<div>魔力值 <a href="mybonus.php"><i>说明</i></a><span>:3,456.7</span></div>', 3456.7],
|
||||||
|
["<script>let ordinary = 123456;</script><span>无魔力值数据</span>", 0],
|
||||||
|
])("魔力值DOM布局 %s", async (markup, bonus) => {
|
||||||
|
const m = createMockFetch({ "/": { body: `<html><body><a href="logout.php">退出</a>${markup}</body></html>` } });
|
||||||
|
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
|
||||||
|
expect(info.bonus).toBe(bonus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("等级标记取当前UID的通用等级,不拿站点组名当等级", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: '<html><body><a href="logout.php">退出</a><a href="userdetails.php?id=21">reader</a><span>[UID=99][(other) Wrong]</span><span>[UID=21][(本站组名) Elite User]</span></body></html>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch });
|
||||||
|
expect(info.userLevel).toBe("Elite User");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Pter完整做种列表替换详情兜底,不重复累计,并发送导航请求头", async () => {
|
||||||
|
const row =
|
||||||
|
'<tr><td>图标</td><td><a href="details.php?id=1">种子</a></td><td>时间</td><td>2 GB</td><td>6</td></tr>';
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: '<html><body><a href="logout.php">退出</a><a href="userdetails.php?id=31">reader</a></body></html>',
|
||||||
|
},
|
||||||
|
"/userdetails.php": {
|
||||||
|
body: `<table><tr><td><b>当前做种</b></td><td><table><tbody>${row}</tbody></table></td></tr></table>`,
|
||||||
|
},
|
||||||
|
"/getusertorrentlist.php": { body: `<div id="outer"><table><tbody>${row}${row}</tbody></table></div>` },
|
||||||
|
});
|
||||||
|
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch, url: "https://pterclub.net/" });
|
||||||
|
expect(info).toMatchObject({
|
||||||
|
seeding: 2,
|
||||||
|
seedingSize: 4 * 1024 ** 3,
|
||||||
|
seedingInfo: [
|
||||||
|
[6, 2 * 1024 ** 3],
|
||||||
|
[6, 2 * 1024 ** 3],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
for (const req of m.requests.slice(1)) {
|
||||||
|
expect(req.headers["sec-fetch-mode"]).toBe("navigate");
|
||||||
|
expect(req.headers.accept).toContain("text/html");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("做种接口失败时保留详情页列表,汇总数量和体积优先于部分分页", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: '<html><body><a href="logout.php">退出</a><a href="userdetails.php?id=31">reader</a></body></html>',
|
||||||
|
},
|
||||||
|
"/userdetails.php": {
|
||||||
|
body: "<p>当前做种:10 做种大小:25 GB</p><table><tr><td>当前上传</td><td><table><tr><td></td><td></td><td></td><td>2 GB</td><td>4</td></tr></table></td></tr></table>",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch, url: "https://pterclub.com/" });
|
||||||
|
expect(info).toMatchObject({ seeding: 10, seedingSize: 25 * 1024 ** 3, seedingInfo: [[4, 2 * 1024 ** 3]] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Keepfrds使用专用入口和第五/六列,保留详情汇总", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: '<html><body><a href="logout.php">退出</a><a href="userdetails.php?id=17">reader</a></body></html>',
|
||||||
|
},
|
||||||
|
"/userdetails.php": { body: "<div>当前做种:9 做种大小:30 GB</div>" },
|
||||||
|
"/torrents.php": {
|
||||||
|
body: '<table class="torrents"><tr><td></td><td></td><td></td><td></td><td></td><td></td></tr><tr><td></td><td></td><td></td><td></td><td>3 GB</td><td>7</td></tr></table>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch, url: "https://keepfrds.com/" });
|
||||||
|
expect(info).toMatchObject({ seeding: 9, seedingSize: 30 * 1024 ** 3, seedingInfo: [[7, 3 * 1024 ** 3]] });
|
||||||
|
expect(m.requests.at(-1)!.url.searchParams.get("option-torrents")).toBe("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("葡萄只读做种汇总,不把全站种子链接当个人做种列表", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: '<html><body><a href="logout.php">退出</a><a href="userdetails.php?id=15">reader</a></body></html>',
|
||||||
|
},
|
||||||
|
"/viewtorrentsdetail.php": {
|
||||||
|
body: '<div>当前做种:12 做种大小:40 GiB</div><a href="torrents.php?type=seeding">全站种子</a><a href="torrents.php?page=2">下一页</a>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch, url: "https://pt.sjtu.edu.cn/" });
|
||||||
|
expect(info).toMatchObject({ seeding: 12, seedingSize: 40 * 1024 ** 3, seedingInfo: [] });
|
||||||
|
expect(m.requests.some((r) => r.url.pathname === "/torrents.php")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/nexus_php.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/nexus_php.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* NexusPHP 站点用户信息解析器。
|
* NexusPHP 站点用户信息解析器。
|
||||||
*
|
*
|
||||||
* 迁移自 MoviePilot e6820a8(GPLv3)`app/modules/indexer/parser/nexus_php.py` `NexusPhpSiteUserInfo`,
|
* 迁移自 MoviePilot 3b156a5(GPLv3)`app/modules/indexer/parser/nexus_php.py` `NexusPhpSiteUserInfo`,
|
||||||
* 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_message_unread` → `_parseMessageUnread`、
|
* 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_message_unread` → `_parseMessageUnread`、
|
||||||
* `_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_user_traffic_info` → `_parseUserTrafficInfo`、
|
* `_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_user_traffic_info` → `_parseUserTrafficInfo`、
|
||||||
* `_parse_ucoin` → `parseUcoin`、`_parse_user_torrent_seeding_info` → `_parseUserTorrentSeedingInfo`、
|
* `_parse_ucoin` → `parseUcoin`、`_parse_user_torrent_seeding_info` → `_parseUserTorrentSeedingInfo`、
|
||||||
* `_fixup_next_page_url` → `fixupNextPageUrl`、`_parse_user_detail_info` → `_parseUserDetailInfo`、
|
* `_fixup_next_page_url` → `fixupNextPageUrl`、`_parse_user_detail_info` → `_parseUserDetailInfo`、
|
||||||
* `_fixup_torrent_seeding_page` → `_fixupTorrentSeedingPage`、`_get_user_level` → `_getUserLevel`、
|
* `_fixup_torrent_seeding_page` → `_fixupTorrentSeedingPage`、`_get_user_level` → `_getUserLevel`、
|
||||||
* `_parse_message_unread_links` → `_parseMessageUnreadLinks`、`_parse_message_content` → `_parseMessageContent`、
|
* `_parse_message_unread_links` → `_parseMessageUnreadLinks`、`_parse_message_content` → `_parseMessageContent`、
|
||||||
* `_fixup_traffic_info` → `_fixupTrafficInfo`。XPath 与正则逐字保留。
|
* `_fixup_traffic_info` → `_fixupTrafficInfo`;UUID、站点专属做种入口与汇总按 v3.2.4 行为更新。
|
||||||
*
|
*
|
||||||
* 差异:做种表"大小列/做种人数列"条数不一致时 MP 抛 IndexError 中断整站解析,这里缺失的做种人数记 0。
|
* 差异:做种表"大小列/做种人数列"条数不一致时 MP 抛 IndexError 中断整站解析,这里缺失的做种人数记 0。
|
||||||
*/
|
*/
|
||||||
@@ -28,14 +28,44 @@ export function round3(x: number): number {
|
|||||||
|
|
||||||
export class NexusPhpSiteUserInfo extends SiteParserBase {
|
export class NexusPhpSiteUserInfo extends SiteParserBase {
|
||||||
static override schema: import("./types.ts").SiteSchemaValue | null = SiteSchema.NexusPhp;
|
static override schema: import("./types.ts").SiteSchemaValue | null = SiteSchema.NexusPhp;
|
||||||
|
private summarySeeding: number | null = null;
|
||||||
|
private summarySeedingSize: number | null = null;
|
||||||
|
|
||||||
|
private get seedingDomain(): string {
|
||||||
|
return this._siteDomain.toLowerCase().replace(/^www\./, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isUuid(id: string | null): boolean {
|
||||||
|
return /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id?.trim() ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不同 NexusPHP 站点的做种入口;UUID 站点使用独立的参数名。 */
|
||||||
|
protected _buildTorrentSeedingPage(): string {
|
||||||
|
if (!this._hasValidUserid()) return "";
|
||||||
|
if (["pterclub.net", "pterclub.com"].includes(this.seedingDomain)) {
|
||||||
|
this._torrentSeedingHeaders = {
|
||||||
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
};
|
||||||
|
this._userDetailHeaders = { ...this._torrentSeedingHeaders };
|
||||||
|
return `getusertorrentlist.php?userid=${this.userid}&type=seeding`;
|
||||||
|
}
|
||||||
|
if (this.seedingDomain === "keepfrds.com") return `torrents.php?option-torrents=3&userid=${this.userid}`;
|
||||||
|
if (this.seedingDomain === "pt.sjtu.edu.cn") return `viewtorrentsdetail.php?id=${this.userid}`;
|
||||||
|
const key = NexusPhpSiteUserInfo.isUuid(this.userid) ? "useruuid" : "userid";
|
||||||
|
return `getusertorrentlistajax.php?${key}=${this.userid}&type=seeding`;
|
||||||
|
}
|
||||||
|
|
||||||
protected _parseSitePage(htmlText: string): void {
|
protected _parseSitePage(htmlText: string): void {
|
||||||
const text = this._prepareHtmlText(htmlText);
|
const text = this._prepareHtmlText(htmlText);
|
||||||
const userDetail = reSearch(/userdetails.php\?id=(\d+)/, text);
|
const userDetail = reSearch(/userdetails\.php\?(?:id|uuid)=([0-9A-Za-z_-]+)/, text);
|
||||||
if (userDetail?.[0].trim()) {
|
if (userDetail?.[0].trim()) {
|
||||||
this._userDetailPage = userDetail[0].trim().replace(/^\/+/, "");
|
this._userDetailPage = userDetail[0].trim().replace(/^\/+/, "");
|
||||||
this.userid = userDetail[1]!;
|
this.userid = userDetail[1]!;
|
||||||
this._torrentSeedingPage = `getusertorrentlistajax.php?userid=${this.userid}&type=seeding`;
|
this._torrentSeedingPage = this._buildTorrentSeedingPage();
|
||||||
} else {
|
} else {
|
||||||
const m = reSearch(/(userdetails)/, text);
|
const m = reSearch(/(userdetails)/, text);
|
||||||
if (m?.[0].trim()) {
|
if (m?.[0].trim()) {
|
||||||
@@ -71,6 +101,8 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
|
|||||||
|
|
||||||
const html = parseHtml(htmlText);
|
const html = parseHtml(htmlText);
|
||||||
if (!hasChildElements(html)) return;
|
if (!hasChildElements(html)) return;
|
||||||
|
const level = this.parseUserLevelMarker(html.text());
|
||||||
|
if (level) this.userLevel = level;
|
||||||
let ret = html.strings(`//a[contains(@href, "userdetails") and contains(@href, "${this.userid}")]//b//text()`);
|
let ret = html.strings(`//a[contains(@href, "userdetails") and contains(@href, "${this.userid}")]//b//text()`);
|
||||||
if (ret.length) {
|
if (ret.length) {
|
||||||
this.username = String(ret[0]);
|
this.username = String(ret[0]);
|
||||||
@@ -98,26 +130,80 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
|
|||||||
const [hasUcoin, bonus] = NexusPhpSiteUserInfo.parseUcoin(html);
|
const [hasUcoin, bonus] = NexusPhpSiteUserInfo.parseUcoin(html);
|
||||||
this.bonus = bonus;
|
this.bonus = bonus;
|
||||||
if (hasUcoin) return;
|
if (hasUcoin) return;
|
||||||
const tmps = html ? html.strings('//a[contains(@href,"mybonus")]/text()') : [];
|
const bonusLinks = html?.nodes('//a[contains(@href,"mybonus")]') ?? [];
|
||||||
if (tmps.length) {
|
for (const link of bonusLinks) {
|
||||||
const bonusText = String(tmps[0]).trim();
|
for (const value of link.strings("text()")) {
|
||||||
const m = reSearch(/([\d,.]+)/, bonusText);
|
const match = /([\d,.]+)/.exec(value.trim());
|
||||||
if (m && m[1]!.trim()) {
|
if (match) {
|
||||||
this.bonus = parseFloatMp(m[1]);
|
this.bonus = parseFloatMp(match[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nested = /^[\s[((【]*([\d][\d,.]*)[\s\]))】]*$/.exec(link.text().trim());
|
||||||
|
const tail = /[::]\s*([\d,.]+)/.exec(link.strings("following-sibling::text()[1]")[0] ?? "");
|
||||||
|
if (nested || tail) {
|
||||||
|
this.bonus = parseFloatMp((nested ?? tail)![1]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const link of bonusLinks) {
|
||||||
|
for (const container of link.nodes('ancestor::*[position() <= 4][contains(., "魔力值")]')) {
|
||||||
|
const value = container.text();
|
||||||
|
if (value.length > 200) continue;
|
||||||
|
const match = /魔力值.*?[::]\s*([\d,.]+)/s.exec(value);
|
||||||
|
if (match) {
|
||||||
|
this.bonus = parseFloatMp(match[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let bonusMatch = reSearch(/mybonus.[[\]::<>/a-zA-Z_\-="'\s#;.(使用魔力值豆]+\s*([\d,.]+)[<()&\s]/, text);
|
let bonusMatch = reSearch(/mybonus.[[\]::<>/a-zA-Z_\-="'\s#;.(使用魔力值豆]+\s*([\d,.]+)[<()&\s]/, text);
|
||||||
if (bonusMatch && bonusMatch[1]!.trim()) {
|
if (bonusMatch && bonusMatch[1]!.trim()) {
|
||||||
this.bonus = parseFloatMp(bonusMatch[1]);
|
this.bonus = parseFloatMp(bonusMatch[1]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bonusMatch = reSearch(/[魔力值|\]][[\]::<>/a-zA-Z_\-="'\s#;]+\s*([\d,.]+|"[\d,.]+")[<>()&\s]/s, text);
|
bonusMatch = reSearch(/(?:魔力值|\])[[\]::<>/a-zA-Z_\-="'\s#;]+\s*([\d,.]+|"[\d,.]+")[<>()&\s]/s, text);
|
||||||
if (bonusMatch && bonusMatch[1]!.trim()) {
|
if (bonusMatch && bonusMatch[1]!.trim()) {
|
||||||
this.bonus = parseFloatMp(bonusMatch[1]!.replace(/^"+|"+$/g, ""));
|
this.bonus = parseFloatMp(bonusMatch[1]!.replace(/^"+|"+$/g, ""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 完整列表替换详情页的兜底列表;缺页时保留详情事实,站点汇总优先。 */
|
||||||
|
protected override async _parseSeedingPages(): Promise<void> {
|
||||||
|
const previous = { count: this.seeding, size: this.seedingSize, info: [...this.seedingInfo] };
|
||||||
|
this.seeding = 0;
|
||||||
|
this.seedingSize = 0;
|
||||||
|
this.seedingInfo = [];
|
||||||
|
try {
|
||||||
|
await super._parseSeedingPages();
|
||||||
|
} catch (error) {
|
||||||
|
this.seeding = previous.count;
|
||||||
|
this.seedingSize = previous.size;
|
||||||
|
this.seedingInfo = previous.info;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if ((!this.seeding || !this.seedingSize) && (previous.count || previous.size)) {
|
||||||
|
this.seeding = previous.count;
|
||||||
|
this.seedingSize = previous.size;
|
||||||
|
if (previous.info.length) this.seedingInfo = previous.info;
|
||||||
|
}
|
||||||
|
if (this.summarySeeding !== null) this.seeding = this.summarySeeding;
|
||||||
|
if (this.summarySeedingSize !== null) this.seedingSize = this.summarySeedingSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSeedingSummary(html: XNode): void {
|
||||||
|
if (!["pterclub.net", "pterclub.com", "keepfrds.com", "pt.sjtu.edu.cn"].includes(this.seedingDomain)) return;
|
||||||
|
const text = html.strings("//text()[not(ancestor::script) and not(ancestor::style)]").join(" ");
|
||||||
|
const count =
|
||||||
|
/当前做种\s*[::]?\s*([\d,]+)/.exec(text) ??
|
||||||
|
(["pterclub.net", "pterclub.com"].includes(this.seedingDomain)
|
||||||
|
? /正在做种的种子\s*[((]\s*共\s*([\d,]+)\s*个/.exec(text)
|
||||||
|
: null);
|
||||||
|
const size = /做种大小\s*[::]?\s*([\d,.]+\s*[KMGTPI]i?B)/i.exec(text);
|
||||||
|
if (count) this.seeding = this.summarySeeding = parseIntMp(count[1]);
|
||||||
|
if (size) this.seedingSize = this.summarySeedingSize = this.numFilesize(size[1]);
|
||||||
|
}
|
||||||
|
|
||||||
/** MP `_parse_ucoin`:金/银/铜币统一换算为铜币 */
|
/** MP `_parse_ucoin`:金/银/铜币统一换算为铜币 */
|
||||||
static parseUcoin(html: XNode | null): [boolean, number] {
|
static parseUcoin(html: XNode | null): [boolean, number] {
|
||||||
if (hasChildElements(html)) {
|
if (hasChildElements(html)) {
|
||||||
@@ -140,6 +226,9 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
|
|||||||
protected _parseUserTorrentSeedingInfo(htmlText: string, multiPage = false): NextPage {
|
protected _parseUserTorrentSeedingInfo(htmlText: string, multiPage = false): NextPage {
|
||||||
const html = parseHtml(String(htmlText ?? "").replaceAll("\\/", "/"));
|
const html = parseHtml(String(htmlText ?? "").replaceAll("\\/", "/"));
|
||||||
if (!hasChildElements(html)) return null;
|
if (!hasChildElements(html)) return null;
|
||||||
|
this.parseSeedingSummary(html);
|
||||||
|
// 葡萄只返回汇总,不跟页面上的全站种子入口。
|
||||||
|
if (this.seedingDomain === "pt.sjtu.edu.cn") return null;
|
||||||
|
|
||||||
// 首页存在扩展链接,使用扩展链接
|
// 首页存在扩展链接,使用扩展链接
|
||||||
const seedingUrlText = html.strings('//a[contains(@href,"torrents.php") and contains(@href,"seeding")]/@href');
|
const seedingUrlText = html.strings('//a[contains(@href,"torrents.php") and contains(@href,"seeding")]/@href');
|
||||||
@@ -160,22 +249,42 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
|
|||||||
if (html.nodes(seedersColXpath).length) {
|
if (html.nodes(seedersColXpath).length) {
|
||||||
seedersCol = html.nodes(`${seedersColXpath}/preceding-sibling::td`).length + 1;
|
seedersCol = html.nodes(`${seedersColXpath}/preceding-sibling::td`).length + 1;
|
||||||
}
|
}
|
||||||
|
if (this.seedingDomain === "keepfrds.com") {
|
||||||
|
sizeCol = 5;
|
||||||
|
seedersCol = 6;
|
||||||
|
}
|
||||||
|
|
||||||
let pageSeeding = 0;
|
let pageSeeding = 0;
|
||||||
let pageSeedingSize = 0;
|
let pageSeedingSize = 0;
|
||||||
const pageSeedingInfo: Array<[number, number]> = [];
|
const pageSeedingInfo: Array<[number, number]> = [];
|
||||||
const tableClass = html.nodes('//table[@class="torrents"]').length ? '//table[@class="torrents"]' : "";
|
if (["pterclub.net", "pterclub.com"].includes(this.seedingDomain)) {
|
||||||
const seedingSizes = html.nodes(`${tableClass}//tr[position()>1]/td[${sizeCol}]`);
|
const rows = html.nodes(
|
||||||
let seedingSeeders = html.strings(`${tableClass}//tr[position()>1]/td[${seedersCol}]/b/a/text()`);
|
'//*[@id="outer"]/table/tbody/tr | //*[@id="outer"]/table/tr' +
|
||||||
if (!seedingSeeders.length)
|
'|//table[contains(concat(" ", normalize-space(@class), " "), " torrents ")]/tbody/tr' +
|
||||||
seedingSeeders = html.strings(`${tableClass}//tr[position()>1]/td[${seedersCol}]//text()`);
|
'|//table[contains(concat(" ", normalize-space(@class), " "), " torrents ")]/tr',
|
||||||
if (seedingSizes.length && seedingSeeders.length) {
|
);
|
||||||
pageSeeding = seedingSizes.length;
|
for (const row of rows) {
|
||||||
for (let i = 0; i < seedingSizes.length; i++) {
|
const cells = row.nodes("./td | ./th");
|
||||||
const size = this.numFilesize(seedingSizes[i]!.text().trim());
|
if (cells.length < 5 || !cells[1]!.nodes(".//a").length) continue;
|
||||||
const seeders = parseIntMp(seedingSeeders[i]);
|
const size = this.numFilesize(cells[3]!.text().trim());
|
||||||
|
if (!size) continue;
|
||||||
|
pageSeeding += 1;
|
||||||
pageSeedingSize += size;
|
pageSeedingSize += size;
|
||||||
pageSeedingInfo.push([seeders, size]);
|
pageSeedingInfo.push([parseIntMp(cells[4]!.text().trim()), size]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const tableClass = html.nodes('//table[@class="torrents"]').length ? '//table[@class="torrents"]' : "";
|
||||||
|
const seedingSizes = html.nodes(`${tableClass}//tr[position()>1]/td[${sizeCol}]`);
|
||||||
|
let seedingSeeders = html.strings(`${tableClass}//tr[position()>1]/td[${seedersCol}]/b/a/text()`);
|
||||||
|
if (!seedingSeeders.length)
|
||||||
|
seedingSeeders = html.strings(`${tableClass}//tr[position()>1]/td[${seedersCol}]//text()`);
|
||||||
|
if (seedingSizes.length && seedingSeeders.length) {
|
||||||
|
pageSeeding = seedingSizes.length;
|
||||||
|
for (let i = 0; i < seedingSizes.length; i++) {
|
||||||
|
const size = this.numFilesize(seedingSizes[i]!.text().trim());
|
||||||
|
pageSeedingSize += size;
|
||||||
|
pageSeedingInfo.push([parseIntMp(seedingSeeders[i]), size]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.seeding += pageSeeding;
|
this.seeding += pageSeeding;
|
||||||
@@ -208,9 +317,9 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
|
|||||||
// parse_qsl → dict:同名键后者覆盖,保留插入顺序
|
// parse_qsl → dict:同名键后者覆盖,保留插入顺序
|
||||||
const params = new Map<string, string>();
|
const params = new Map<string, string>();
|
||||||
for (const [k, v] of new URLSearchParams(query)) params.set(k, v);
|
for (const [k, v] of new URLSearchParams(query)) params.set(k, v);
|
||||||
if (params.get("userid")) return nextPage;
|
if (params.get("userid") || params.get("useruuid")) return nextPage;
|
||||||
if (!userid) return null;
|
if (!userid) return null;
|
||||||
params.set("userid", userid);
|
params.set(NexusPhpSiteUserInfo.isUuid(userid) ? "useruuid" : "userid", userid);
|
||||||
if (!params.has("type")) params.set("type", "seeding");
|
if (!params.has("type")) params.set("type", "seeding");
|
||||||
const qs = new URLSearchParams([...params.entries()]).toString();
|
const qs = new URLSearchParams([...params.entries()]).toString();
|
||||||
return `${path}${qs ? `?${qs}` : ""}${fragment}`;
|
return `${path}${qs ? `?${qs}` : ""}${fragment}`;
|
||||||
@@ -219,6 +328,7 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
|
|||||||
protected _parseUserDetailInfo(htmlText: string): void {
|
protected _parseUserDetailInfo(htmlText: string): void {
|
||||||
const html = parseHtml(htmlText);
|
const html = parseHtml(htmlText);
|
||||||
if (!hasChildElements(html)) return;
|
if (!hasChildElements(html)) return;
|
||||||
|
this.parseSeedingSummary(html);
|
||||||
this._getUserLevel(html);
|
this._getUserLevel(html);
|
||||||
this._fixupTrafficInfo(html);
|
this._fixupTrafficInfo(html);
|
||||||
|
|
||||||
@@ -233,20 +343,22 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 做种体积 & 做种数(seeding 页面获取不到的话,此处再获取一次)
|
// 做种体积 & 做种数(seeding 页面获取不到的话,此处再获取一次)
|
||||||
const seedingSizes = html.nodes(
|
const seedingTables = html.nodes(
|
||||||
'//tr/td[text()="当前上传"]/following-sibling::td[1]//table[tr[1][td[4 and text()="尺寸"]]]//tr[position()>1]/td[4]',
|
'//tr/td[normalize-space(.)="当前上传" or normalize-space(.)="当前做种"]/following-sibling::td[1]//table',
|
||||||
);
|
);
|
||||||
const seedingSeeders = html.strings(
|
let tmpSeeding = 0;
|
||||||
'//tr/td[text()="当前上传"]/following-sibling::td[1]//table[tr[1][td[5 and text()="做种者"]]]//tr[position()>1]/td[5]//text()',
|
|
||||||
);
|
|
||||||
let tmpSeeding = seedingSizes.length;
|
|
||||||
let tmpSeedingSize = 0;
|
let tmpSeedingSize = 0;
|
||||||
const tmpSeedingInfo: Array<[number, number]> = [];
|
const tmpSeedingInfo: Array<[number, number]> = [];
|
||||||
for (let i = 0; i < seedingSizes.length; i++) {
|
for (const table of seedingTables) {
|
||||||
const size = this.numFilesize(seedingSizes[i]!.text().trim());
|
for (const row of table.nodes("./tr | ./tbody/tr")) {
|
||||||
const seeders = parseIntMp(seedingSeeders[i]);
|
const cells = row.nodes("./td | ./th");
|
||||||
tmpSeedingSize += size;
|
if (cells.length < 5) continue;
|
||||||
tmpSeedingInfo.push([seeders, size]);
|
const size = this.numFilesize(cells[3]!.text().trim());
|
||||||
|
if (!size) continue;
|
||||||
|
tmpSeeding += 1;
|
||||||
|
tmpSeedingSize += size;
|
||||||
|
tmpSeedingInfo.push([parseIntMp(cells[4]!.text().trim()), size]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!this.seedingSize) this.seedingSize = tmpSeedingSize;
|
if (!this.seedingSize) this.seedingSize = tmpSeedingSize;
|
||||||
if (!this.seeding) this.seeding = tmpSeeding;
|
if (!this.seeding) this.seeding = tmpSeeding;
|
||||||
@@ -277,10 +389,10 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
|
|||||||
);
|
);
|
||||||
const csrfText = html.strings('//meta[@name="x-csrf"]/@content');
|
const csrfText = html.strings('//meta[@name="x-csrf"]/@content');
|
||||||
if (!this._torrentSeedingPage && seedingUrlText.length) {
|
if (!this._torrentSeedingPage && seedingUrlText.length) {
|
||||||
const userJs = reSearch(/javascript: getusertorrentlistajax\(\s*'(\d+)/, seedingUrlText[0]);
|
const userJs = reSearch(/javascript:\s*getusertorrentlistajax\(\s*'([^']+)'/, seedingUrlText[0]);
|
||||||
if (userJs?.[1]?.trim()) {
|
if (userJs?.[1]?.trim()) {
|
||||||
this.userid = userJs[1].trim();
|
this.userid = userJs[1].trim();
|
||||||
this._torrentSeedingPage = `getusertorrentlistajax.php?userid=${this.userid}&type=seeding`;
|
this._torrentSeedingPage = this._buildTorrentSeedingPage();
|
||||||
}
|
}
|
||||||
} else if (seedingUrlText.length && csrfText.length) {
|
} else if (seedingUrlText.length && csrfText.length) {
|
||||||
if (csrfText[0]!.trim()) {
|
if (csrfText[0]!.trim()) {
|
||||||
@@ -322,6 +434,14 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const level = this.parseUserLevelMarker(html.text());
|
||||||
|
if (level) this.userLevel = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUserLevelMarker(text: string): string | null {
|
||||||
|
const id = this.userid ? this.userid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : "\\d+";
|
||||||
|
const marker = new RegExp(`\\[\\s*UID\\s*=\\s*${id}\\s*\\]\\s*\\[\\s*\\([^\\)\\]]+\\)\\s*([^\\]]+?)\\s*\\]`, "i");
|
||||||
|
return marker.exec(text)?.[1]?.trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _parseMessageUnreadLinks(htmlText: string, msgLinks: string[]): NextPage {
|
protected _parseMessageUnreadLinks(htmlText: string, msgLinks: string[]): NextPage {
|
||||||
@@ -353,9 +473,34 @@ export class NexusPhpSiteUserInfo extends SiteParserBase {
|
|||||||
|
|
||||||
/** MP `_fixup_traffic_info` */
|
/** MP `_fixup_traffic_info` */
|
||||||
protected _fixupTrafficInfo(html: XNode): void {
|
protected _fixupTrafficInfo(html: XNode): void {
|
||||||
|
if (!this.upload) {
|
||||||
|
const value = this.trafficTableValue(html, /上[传傳]量?/);
|
||||||
|
if (value !== null) this.upload = this.numFilesize(value);
|
||||||
|
}
|
||||||
|
if (!this.download) {
|
||||||
|
const value = this.trafficTableValue(html, /下[载載]量?/);
|
||||||
|
if (value !== null) this.download = this.numFilesize(value);
|
||||||
|
}
|
||||||
|
if (!this.ratio) {
|
||||||
|
const value = this.trafficTableValue(html, /分享率/);
|
||||||
|
this.ratio = value !== null ? parseFloatMp(value) : this.download ? round3(this.upload / this.download) : 0;
|
||||||
|
}
|
||||||
if (!this.bonus) {
|
if (!this.bonus) {
|
||||||
const bonusText = html.strings('//tr/td[text()="魔力值" or text()="猫粮"]/following-sibling::td[1]/text()');
|
const bonusText = html.strings('//tr/td[text()="魔力值" or text()="猫粮"]/following-sibling::td[1]/text()');
|
||||||
if (bonusText.length) this.bonus = parseFloatMp(bonusText[0]!.trim());
|
if (bonusText.length) this.bonus = parseFloatMp(bonusText[0]!.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private trafficTableValue(html: XNode, field: RegExp): string | null {
|
||||||
|
const label = new RegExp(`^${field.source}\\s*[::]?\\s*(.*)$`);
|
||||||
|
for (const cell of html.nodes("//tr/td[not(.//td)]")) {
|
||||||
|
const match = label.exec(cell.text().replace(/\s+/g, " ").trim());
|
||||||
|
if (!match) continue;
|
||||||
|
const value =
|
||||||
|
match[1]!.trim() || cell.nodes("following-sibling::td[1]")[0]?.text().replace(/\s+/g, " ").trim() || "";
|
||||||
|
const parsed = /^([\d,.]+(?:\s*[KMGTPI]i?B)?)(?:\s*[((].*)?$/i.exec(value);
|
||||||
|
if (parsed) return parsed[1]!;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/nexus_project.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/nexus_project.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* NexusProject 站点用户信息解析器,继承 NexusPHP 通用解析。
|
* NexusProject 站点用户信息解析器,继承 NexusPHP 通用解析。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/nexus_rabbit.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/nexus_rabbit.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* NexusRabbit(兔子/layui-hares 模板)站点用户信息解析器。
|
* NexusRabbit(兔子/layui-hares 模板)站点用户信息解析器。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -47,4 +47,57 @@ describe("用户信息解析器注册表(MP refresh_userdata)", () => {
|
|||||||
expect(info).toMatchObject({ schema: "NexusPhp", userId: null });
|
expect(info).toMatchObject({ schema: "NexusPhp", userId: null });
|
||||||
expect(info!.errMsg).toContain("未检测到已登陆");
|
expect(info!.errMsg).toContain("未检测到已登陆");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("回退只尝试通用网页模型,不探测专站API", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": { body: '<html><body><a href="logout.php">退出</a><div>没有用户资料</div></body></html>' },
|
||||||
|
});
|
||||||
|
const info = await refreshSiteUserData({
|
||||||
|
name: "demo",
|
||||||
|
url: "https://pt.example.com/",
|
||||||
|
schema: SiteSchema.NexusPhp,
|
||||||
|
apikey: "key",
|
||||||
|
fetch: m.fetch,
|
||||||
|
});
|
||||||
|
expect(info).toMatchObject({ userId: null, errMsg: "未获取到站点用户信息,请检查 Cookie 是否有效" });
|
||||||
|
expect(m.requests.map((r) => r.url.pathname)).toEqual(["/", "/", "/index.php", "/", "/index.php"]);
|
||||||
|
expect(m.requests.every((r) => r.url.host === "pt.example.com")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["/account/current", "https://pt.example.com/base/account/current"],
|
||||||
|
["https://account.example.net/current", "https://account.example.net/current"],
|
||||||
|
])("用户资料入口 %s 保留原站域名并使用默认UA", async (userdataPath, expected) => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
[expected]: { body: NEXUS_PHP_INDEX },
|
||||||
|
"/userdetails.php": { body: NEXUS_PHP_USERDETAILS },
|
||||||
|
});
|
||||||
|
const info = await refreshSiteUserData({
|
||||||
|
name: "demo",
|
||||||
|
url: "https://pt.example.com/base/",
|
||||||
|
schema: SiteSchema.NexusPhp,
|
||||||
|
userdataPath,
|
||||||
|
defaultUa: "Default/1",
|
||||||
|
fetch: m.fetch,
|
||||||
|
});
|
||||||
|
expect(m.requests[0]!.url.href).toBe(expected);
|
||||||
|
expect(m.requests[0]!.headers["user-agent"]).toBe("Default/1");
|
||||||
|
expect(info).toMatchObject({ domain: "example.com", userId: "10086" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("公开站不回退也不增加缺失用户ID错误;站点UA优先", async () => {
|
||||||
|
const m = createMockFetch({ "/": { body: '<html><body><a href="logout.php">退出</a></body></html>' } });
|
||||||
|
const info = await refreshSiteUserData({
|
||||||
|
name: "public",
|
||||||
|
url: "https://pt.example.com/",
|
||||||
|
schema: SiteSchema.NexusPhp,
|
||||||
|
public: true,
|
||||||
|
ua: "Site/2",
|
||||||
|
defaultUa: "Default/1",
|
||||||
|
fetch: m.fetch,
|
||||||
|
});
|
||||||
|
expect(info).toMatchObject({ userId: null, errMsg: null });
|
||||||
|
expect(m.requests).toHaveLength(1);
|
||||||
|
expect(m.requests[0]!.headers["user-agent"]).toBe("Site/2");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
* - `IndexerModule.__init__` 里 `ModuleHelper.load('app.modules.indexer.parser', filter=有 schema)` → `USER_PARSER_CLASSES`
|
* - `IndexerModule.__init__` 里 `ModuleHelper.load('app.modules.indexer.parser', filter=有 schema)` → `USER_PARSER_CLASSES`
|
||||||
* (按模块文件名字母序,与 pkgutil 遍历顺序一致;回退时按此顺序尝试)
|
* (按模块文件名字母序,与 pkgutil 遍历顺序一致;回退时按此顺序尝试)
|
||||||
* - `IndexerModule.refresh_userdata` → `refreshSiteUserData`:按站点声明的 schema 取解析器;解析后 userid 为空且非公开站时,
|
* - `IndexerModule.refresh_userdata` → `refreshSiteUserData`:按站点声明的 schema 取解析器;解析后 userid 为空且非公开站时,
|
||||||
* 依次尝试其他 schema(兼容 schema 标注错误/变种站),其他 schema 的异常只记录不抛出。
|
* 仅尝试 Gazelle/NexusPhp/Unit3d 通用模型,避免探测专站 API;回退异常不抛出。
|
||||||
*
|
*
|
||||||
* 与 MP 一致:声明 schema 解析器的异常原样抛出(由调用方记录),只有回退尝试的异常被吞掉。
|
* 与 MP 一致:声明 schema 解析器的异常原样抛出(由调用方记录),只有回退尝试的异常被吞掉。
|
||||||
* 差异:结果不含凭据;`fetchMessages` 默认 false(MP 由全局 SITE_MESSAGE 控制)。
|
* 差异:结果不含凭据;`fetchMessages` 默认 false(MP 由全局 SITE_MESSAGE 控制)。
|
||||||
@@ -31,7 +31,7 @@ import { SmallHorseSiteUserInfo } from "./small-horse.ts";
|
|||||||
import { SunnyPTSiteUserInfo } from "./sunnypt.ts";
|
import { SunnyPTSiteUserInfo } from "./sunnypt.ts";
|
||||||
import { TNodeSiteUserInfo } from "./tnode.ts";
|
import { TNodeSiteUserInfo } from "./tnode.ts";
|
||||||
import { TorrentLeechSiteUserInfo } from "./torrent-leech.ts";
|
import { TorrentLeechSiteUserInfo } from "./torrent-leech.ts";
|
||||||
import type { SiteSchemaValue, SiteUserInfo, SiteUserParserOptions } from "./types.ts";
|
import { SiteSchema, type SiteSchemaValue, type SiteUserInfo, type SiteUserParserOptions } from "./types.ts";
|
||||||
import { Unit3dSiteUserInfo } from "./unit3d.ts";
|
import { Unit3dSiteUserInfo } from "./unit3d.ts";
|
||||||
import { YemaSiteUserInfo } from "./yema.ts";
|
import { YemaSiteUserInfo } from "./yema.ts";
|
||||||
import { ZhixingSiteUserInfo } from "./zhixing.ts";
|
import { ZhixingSiteUserInfo } from "./zhixing.ts";
|
||||||
@@ -72,15 +72,27 @@ export interface SiteUserDataRequest extends Omit<SiteUserParserOptions, "siteNa
|
|||||||
schema?: string | null;
|
schema?: string | null;
|
||||||
/** 公开站:不报"未找到解析器",也不做 schema 回退 */
|
/** 公开站:不报"未找到解析器",也不做 schema 回退 */
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
|
/** 用户资料入口;相对路径附加在站点URL之后。 */
|
||||||
|
userdataPath?: string | null;
|
||||||
|
/** 全局UA,由调用方从站点设置注入。 */
|
||||||
|
defaultUa?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FALLBACK_SCHEMAS = new Set<SiteSchemaValue>([SiteSchema.Gazelle, SiteSchema.NexusPhp, SiteSchema.Unit3d]);
|
||||||
|
|
||||||
/** MP `refresh_userdata`:未找到解析器返回 null */
|
/** MP `refresh_userdata`:未找到解析器返回 null */
|
||||||
export async function refreshSiteUserData(site: SiteUserDataRequest): Promise<SiteUserInfo | null> {
|
export async function refreshSiteUserData(site: SiteUserDataRequest): Promise<SiteUserInfo | null> {
|
||||||
const make = (schema: string | null | undefined): SiteParserBase | null => {
|
const make = (schema: string | null | undefined): SiteParserBase | null => {
|
||||||
const cls = getUserParserClass(schema);
|
const cls = getUserParserClass(schema);
|
||||||
if (!cls) return null;
|
if (!cls) return null;
|
||||||
const { name, schema: _s, public: _p, ...rest } = site;
|
const { name, schema: _s, public: _p, userdataPath, defaultUa, ...rest } = site;
|
||||||
return new cls({ ...rest, siteName: name });
|
const path = userdataPath?.trim();
|
||||||
|
const url = path
|
||||||
|
? /^https?:\/\//.test(path)
|
||||||
|
? path
|
||||||
|
: new URL(path.replace(/^\/+/, ""), `${site.url.replace(/\/+$/, "")}/`).toString()
|
||||||
|
: site.url;
|
||||||
|
return new cls({ ...rest, siteName: name, url, ua: site.ua || defaultUa });
|
||||||
};
|
};
|
||||||
let obj = make(site.schema);
|
let obj = make(site.schema);
|
||||||
if (!obj) return null;
|
if (!obj) return null;
|
||||||
@@ -90,7 +102,7 @@ export async function refreshSiteUserData(site: SiteUserDataRequest): Promise<Si
|
|||||||
const tried = new Set<string>([String(site.schema)]);
|
const tried = new Set<string>([String(site.schema)]);
|
||||||
for (const cls of USER_PARSER_CLASSES) {
|
for (const cls of USER_PARSER_CLASSES) {
|
||||||
const value = cls.schema as SiteSchemaValue | null;
|
const value = cls.schema as SiteSchemaValue | null;
|
||||||
if (!value || tried.has(value)) continue;
|
if (!value || tried.has(value) || !FALLBACK_SCHEMAS.has(value)) continue;
|
||||||
tried.add(value);
|
tried.add(value);
|
||||||
const alt = make(value);
|
const alt = make(value);
|
||||||
if (!alt) continue;
|
if (!alt) continue;
|
||||||
@@ -105,6 +117,9 @@ export async function refreshSiteUserData(site: SiteUserDataRequest): Promise<Si
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!obj.userid && !site.public && !obj.errMsg) {
|
||||||
|
obj.errMsg = "未获取到站点用户信息,请检查 Cookie 是否有效";
|
||||||
|
}
|
||||||
const info = obj.toUserInfo();
|
const info = obj.toUserInfo();
|
||||||
info.domain = extractDomain(site.url) || null;
|
info.domain = extractDomain(site.url) || null;
|
||||||
return info;
|
return info;
|
||||||
|
|||||||
@@ -32,4 +32,19 @@ describe("RousiSiteUserInfo(MP rousi.py)", () => {
|
|||||||
expect(info.userId).toBeNull();
|
expect(info.userId).toBeNull();
|
||||||
expect(info.errMsg).toBe("API Key 无效");
|
expect(info.errMsg).toBe("API Key 无效");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([null, [], "unexpected"])("消息API返回非对象data %s 时按空消息处理", async (data) => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"GET https://rousi.pro/api/v1/profile": { body: ROUSI_USER_PROFILE },
|
||||||
|
"GET https://rousi.pro/api/messages": { body: { code: 0, data } },
|
||||||
|
});
|
||||||
|
const info = await runParser(RousiSiteUserInfo, {
|
||||||
|
fetch: m.fetch,
|
||||||
|
url: "https://rousi.pro/",
|
||||||
|
apikey: "key",
|
||||||
|
token: "token",
|
||||||
|
fetchMessages: true,
|
||||||
|
});
|
||||||
|
expect(info).toMatchObject({ userId: "1", messageUnread: 0, messageUnreadContents: [] });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/rousi.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/rousi.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* Rousi.pro(PeerGo)用户数据解析器(MP `RousiSiteUserInfo`,apikey 模式)。
|
* Rousi.pro(PeerGo)用户数据解析器(MP `RousiSiteUserInfo`,apikey 模式)。
|
||||||
* 函数对照:`_parse_site_page` → `_parseSitePage`(api/v1/profile,Bearer API Key)、`_parse_logged_in`(恒 True)、
|
* 函数对照:`_parse_site_page` → `_parseSitePage`(api/v1/profile,Bearer API Key)、`_parse_logged_in`(恒 True)、
|
||||||
@@ -128,7 +128,9 @@ export class RousiSiteUserInfo extends SiteParserBase {
|
|||||||
const qs = new URLSearchParams({ page: String(page), page_size: "100", unread_only: "true" });
|
const qs = new URLSearchParams({ page: String(page), page_size: "100", unread_only: "true" });
|
||||||
const res = await this._rawJson("GET", `${urljoin(this._baseUrl, "api/messages")}?${qs}`, headers);
|
const res = await this._rawJson("GET", `${urljoin(this._baseUrl, "api/messages")}?${qs}`, headers);
|
||||||
if (!res || (res.code ?? -1) !== 0) return { messages: [], total_pages: 0 };
|
if (!res || (res.code ?? -1) !== 0) return { messages: [], total_pages: 0 };
|
||||||
return (res.data ?? {}) as Json;
|
return res.data && typeof res.data === "object" && !Array.isArray(res.data)
|
||||||
|
? (res.data as Json)
|
||||||
|
: { messages: [], total_pages: 0 };
|
||||||
};
|
};
|
||||||
let page = 0;
|
let page = 0;
|
||||||
let res = await getMessageList(page);
|
let res = await getMessageList(page);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/small_horse.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/small_horse.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* Small Horse(Gazelle 变种)站点用户信息解析器。
|
* Small Horse(Gazelle 变种)站点用户信息解析器。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/sunnypt.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/sunnypt.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* SunnyPT MoviePilot API 用户数据解析器(MP `SunnyPTSiteUserInfo`,apikey 模式)。
|
* SunnyPT MoviePilot API 用户数据解析器(MP `SunnyPTSiteUserInfo`,apikey 模式)。
|
||||||
* 函数对照:`_parse_site_page` → `_parseSitePage`(api_url,缺省 https://api.sunnypt.top/api/v1/mp)、
|
* 函数对照:`_parse_site_page` → `_parseSitePage`(api_url,缺省 https://api.sunnypt.top/api/v1/mp)、
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/tnode.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/tnode.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* TNode 站点用户数据解析器(MP `TNodeSiteUserInfo`)。
|
* TNode 站点用户数据解析器(MP `TNodeSiteUserInfo`)。
|
||||||
* 函数对照:`_parse_site_page` → `_parseSitePage`(首页取 x-csrf-token,改走 JSON 接口)、
|
* 函数对照:`_parse_site_page` → `_parseSitePage`(首页取 x-csrf-token,改走 JSON 接口)、
|
||||||
@@ -34,6 +34,7 @@ export class TNodeSiteUserInfo extends SiteParserBase {
|
|||||||
if (csrf) {
|
if (csrf) {
|
||||||
this._additionHeaders = { "X-CSRF-TOKEN": csrf[1]! };
|
this._additionHeaders = { "X-CSRF-TOKEN": csrf[1]! };
|
||||||
this._userDetailPage = "api/user/getMainInfo";
|
this._userDetailPage = "api/user/getMainInfo";
|
||||||
|
this._userDetailRequiresUserid = false;
|
||||||
this._torrentSeedingPage = "api/user/listTorrentActivity?id=&type=seeding&page=1&size=20000";
|
this._torrentSeedingPage = "api/user/listTorrentActivity?id=&type=seeding&page=1&size=20000";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/torrent_leech.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/torrent_leech.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* TorrentLeech 站点用户信息解析器(MP `TorrentLeechSiteUserInfo`)。
|
* TorrentLeech 站点用户信息解析器(MP `TorrentLeechSiteUserInfo`)。
|
||||||
* 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_user_base_info` → `_parseUserBaseInfo`、
|
* 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_user_base_info` → `_parseUserBaseInfo`、
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/__init__.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/__init__.py @ 3b156a5
|
||||||
* @mp-source app/schemas/site.py @ e6820a8
|
* @mp-source app/schemas/site.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* 站点用户信息解析类型。
|
* 站点用户信息解析类型。
|
||||||
*
|
*
|
||||||
* 来源:MoviePilot e6820a8(GPLv3)`app/modules/indexer/parser/__init__.py` `SiteSchema`、
|
* 来源:MoviePilot 3b156a5(GPLv3)`app/modules/indexer/parser/__init__.py` `SiteSchema`、
|
||||||
* `app/schemas/site.py` `SiteUserData`、`app/modules/indexer/__init__.py` `refresh_userdata`。
|
* `app/schemas/site.py` `SiteUserData`、`app/modules/indexer/__init__.py` `refresh_userdata`。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe("Unit3dSiteUserInfo(MP unit3d.py)", () => {
|
|||||||
const info = await runParser(Unit3dSiteUserInfo, { fetch: m.fetch });
|
const info = await runParser(Unit3dSiteUserInfo, { fetch: m.fetch });
|
||||||
expect(info).toMatchObject({
|
expect(info).toMatchObject({
|
||||||
schema: "Unit3d",
|
schema: "Unit3d",
|
||||||
|
userId: "carol",
|
||||||
username: "carol",
|
username: "carol",
|
||||||
userLevel: "Elite",
|
userLevel: "Elite",
|
||||||
joinAt: "2020-02-03 04:05:06",
|
joinAt: "2020-02-03 04:05:06",
|
||||||
@@ -42,4 +43,14 @@ describe("Unit3dSiteUserInfo(MP unit3d.py)", () => {
|
|||||||
expect(info.errMsg).toContain("未检测到已登陆");
|
expect(info.errMsg).toContain("未检测到已登陆");
|
||||||
expect(info.raw.http_errors).toEqual([expect.stringContaining("HTTP 404")]);
|
expect(info.raw.http_errors).toEqual([expect.stringContaining("HTTP 404")]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("新式bonus路径只读取当前用户的积分", async () => {
|
||||||
|
const m = createMockFetch({
|
||||||
|
"/": {
|
||||||
|
body: '<html><body><a href="logout">退出</a><a href="/users/carol/settings">设置</a><a href="/users/other/bonus/log">9999</a><a href="/users/carol/bonus/transactions">1,234.5</a></body></html>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const info = await runParser(Unit3dSiteUserInfo, { fetch: m.fetch });
|
||||||
|
expect(info).toMatchObject({ userId: "carol", username: "carol", bonus: 1234.5 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/unit3d.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/unit3d.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* UNIT3D 站点用户数据解析器(MP `Unit3dSiteUserInfo`)。
|
* UNIT3D 站点用户数据解析器(MP `Unit3dSiteUserInfo`)。
|
||||||
* 函数对照:`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page`(空)、
|
* 函数对照:`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page`(空)、
|
||||||
@@ -24,11 +24,18 @@ export class Unit3dSiteUserInfo extends SiteParserBase {
|
|||||||
const m = reSearch(/\/users\/(.+)\/settings/, hrefs[0]);
|
const m = reSearch(/\/users\/(.+)\/settings/, hrefs[0]);
|
||||||
if (m?.[0].trim()) {
|
if (m?.[0].trim()) {
|
||||||
this.username = m[1]!;
|
this.username = m[1]!;
|
||||||
|
this.userid = this.username;
|
||||||
this._torrentSeedingPage = `/users/${this.username}/active?perPage=100&client=&seeding=include`;
|
this._torrentSeedingPage = `/users/${this.username}/active?perPage=100&client=&seeding=include`;
|
||||||
this._userDetailPage = `/users/${this.username}`;
|
this._userDetailPage = `/users/${this.username}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bonus = html.nodes('//a[contains(@href, "bonus/earnings")]');
|
let bonus = html.nodes('//a[contains(@href, "bonus/earnings")]');
|
||||||
|
if (!bonus.length && this.username) {
|
||||||
|
bonus = html.nodes('//a[contains(@href, "/users/") and contains(@href, "/bonus/")]').filter((node) => {
|
||||||
|
const path = new URL(node.attr("href") ?? "", this._baseUrl).pathname;
|
||||||
|
return path.startsWith(`/users/${this.username}/bonus/`);
|
||||||
|
});
|
||||||
|
}
|
||||||
if (bonus.length) {
|
if (bonus.length) {
|
||||||
const m = reSearch(/([\d,.]+)/, bonus[0]!.text());
|
const m = reSearch(/([\d,.]+)/, bonus[0]!.text());
|
||||||
if (m?.[1]?.trim()) this.bonus = parseFloatMp(m[1]);
|
if (m?.[1]?.trim()) this.bonus = parseFloatMp(m[1]);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/yema.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/yema.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* YemaPT 开放 API 用户数据解析器(MP `YemaSiteUserInfo`,apikey 模式)。
|
* YemaPT 开放 API 用户数据解析器(MP `YemaSiteUserInfo`,apikey 模式)。
|
||||||
* 函数对照:`_parse_site_page` → `_parseSitePage`(POST openApi/user/fetchBasicInfo.json,Authorization 头为 AuthKey)、
|
* 函数对照:`_parse_site_page` → `_parseSitePage`(POST openApi/user/fetchBasicInfo.json,Authorization 头为 AuthKey)、
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
* 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。
|
||||||
* @mp-source app/modules/indexer/parser/zhixing.py @ e6820a8
|
* @mp-source app/modules/indexer/parser/zhixing.py @ 3b156a5
|
||||||
*
|
*
|
||||||
* 知行(pt.zhixing.bjtu.edu.cn)站点用户信息解析器(原作者 ThedoRap)。
|
* 知行(pt.zhixing.bjtu.edu.cn)站点用户信息解析器(原作者 ThedoRap)。
|
||||||
*
|
*
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* BeautifulSoup 以 cheerio 等价实现。
|
* BeautifulSoup 以 cheerio 等价实现。
|
||||||
*
|
*
|
||||||
* 与 MP 的偏离:
|
* 与 MP 的偏离:
|
||||||
* 1. MP 末尾把全部字段转成字符串(避免其 UI join 报错);这里保持数值类型(`toUserInfo` 语义相同)。
|
* 1. 数值字段与 MP v3.2.4 一样保持数值,缺失身份字段按本项目合同保留 null。
|
||||||
* 2. 做种翻页设上限 50 页(MP `while True`)。
|
* 2. 做种翻页设上限 50 页(MP `while True`)。
|
||||||
*/
|
*/
|
||||||
import { load } from "cheerio";
|
import { load } from "cheerio";
|
||||||
|
|||||||
Reference in new issue
Block a user