diff --git a/packages/adapters/src/sites/user-parsers/base.ts b/packages/adapters/src/sites/user-parsers/base.ts index 9637bc9b..b9f447eb 100644 --- a/packages/adapters/src/sites/user-parsers/base.ts +++ b/packages/adapters/src/sites/user-parsers/base.ts @@ -1,11 +1,11 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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`): * - `parse` → `parse`(页面获取顺序:首页/登录判定 → `_parse_site_page` → 基础信息 → 详情 → 未读消息 → 流量 → 做种分页) * - `_pase_unread_msgs` → `_paseUnreadMsgs`(保留 MP 拼写)、`_parse_seeding_pages` → `_parseSeedingPages` @@ -18,7 +18,7 @@ * 与 MP 的差异(安全与健壮性): * 1. 凭据经构造参数注入,只放请求头;`toDict()`/`raw` 不含 cookie/apikey/token。 * 2. 做种页与消息页翻页设上限(`MAX_PAGES`=50),防止站点返回自引用"下一页"导致死循环(MP 无上限)。 - * 3. 网络异常与非 200/500/403 状态:与 MP 一样返回空串继续,另把脱敏后的原因记在 `httpErrors`(入 raw)。 + * 3. 非 200 状态记录诊断;网络异常与非 200/500/403 状态返回空串继续,原因脱敏后记在 `httpErrors`(入 raw)。 * 4. 响应解码:按 Content-Type / `` 选 TextDecoder(MP `get_decoded_html_content` 用 chardet 探测)。 */ import { load as loadCheerio } from "cheerio"; @@ -114,6 +114,8 @@ export abstract class SiteParserBase { protected _userBasicHeaders: Headers_ | null = null; // 用户详情 protected _userDetailPage: string | null = "userdetails.php?id="; + /** 当前会话接口可在还不知道用户 ID 时读取详情。 */ + protected _userDetailRequiresUserid = true; protected _userDetailParams: Params | null = null; protected _userDetailHeaders: Headers_ | null = null; // 流量 @@ -192,7 +194,7 @@ export abstract class SiteParserBase { } else { await this._parseUserBaseInfo(this._indexHtml as string); } - if (this._userDetailPage) { + if (this._userDetailPage && (!this._userDetailRequiresUserid || this._hasValidUserid())) { await this._parseUserDetailInfo( await this._getPageContent( urljoin(this._baseUrl, this._userDetailPage), @@ -251,7 +253,7 @@ export abstract class SiteParserBase { /** MP `_parse_seeding_pages` */ protected async _parseSeedingPages(): Promise { - if (!this._torrentSeedingPage) return; + if (!this._torrentSeedingPage || !this._hasValidUserid()) return; let nextPage: NextPage = await this._parseUserTorrentSeedingInfo( await this._getPageContent( 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+` 干扰 */ static prepareHtmlText(htmlText: string): string { return String(htmlText ?? "") @@ -331,9 +339,9 @@ export abstract class SiteParserBase { this.httpErrors.push(redactText(`${init.method} ${url}:${timeout.aborted ? "超时" : (e as Error).message}`)); return ""; } + if (res.status !== 200) this.httpErrors.push(`${init.method}:HTTP ${res.status}`); if (![200, 500, 403].includes(res.status)) { await res.body?.cancel().catch(() => {}); - this.httpErrors.push(redactText(`${init.method} ${url}:HTTP ${res.status}`)); return ""; } const bytes = new Uint8Array(await res.arrayBuffer()); diff --git a/packages/adapters/src/sites/user-parsers/bitpt.ts b/packages/adapters/src/sites/user-parsers/bitpt.ts index 9cd23f3b..c402419c 100644 --- a/packages/adapters/src/sites/user-parsers/bitpt.ts +++ b/packages/adapters/src/sites/user-parsers/bitpt.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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)。 * @@ -11,7 +11,7 @@ * `parse` → `parse`(基类流程后取 uid → `userdetails.php?uid=` → `browse.php?t=myseed&st=2&d=desc&p=n` 逐页累加)。 * BeautifulSoup 以 cheerio 等价实现。 * - * 与 MP 的偏离:末尾不把字段转字符串(同 zhixing.ts);做种翻页上限 50 页。 + * 数值字段按 MP v3.2.4 保持数值;本项目做种翻页上限 50 页。 */ import { load } from "cheerio"; import { type NextPage, SiteParserBase, urljoin } from "./base.ts"; diff --git a/packages/adapters/src/sites/user-parsers/common.test.ts b/packages/adapters/src/sites/user-parsers/common.test.ts new file mode 100644 index 00000000..4b1a825f --- /dev/null +++ b/packages/adapters/src/sites/user-parsers/common.test.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"); + }); +}); diff --git a/packages/adapters/src/sites/user-parsers/common.ts b/packages/adapters/src/sites/user-parsers/common.ts index 37ebba45..37c9ff5e 100644 --- a/packages/adapters/src/sites/user-parsers/common.ts +++ b/packages/adapters/src/sites/user-parsers/common.ts @@ -1,12 +1,12 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。 - * @mp-source app/foundation/text.py @ e6820a8 - * @mp-source app/foundation/temporal.py @ e6820a8 - * @mp-source app/domain/site.py @ e6820a8 - * @mp-source app/adapters/network/cloudflare.py @ e6820a8 + * @mp-source app/foundation/text.py @ 3b156a5 + * @mp-source app/foundation/temporal.py @ 3b156a5 + * @mp-source app/domain/site.py @ 3b156a5 + * @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/temporal.py` `normalize_datetime/format_timestamp` → 同名(dateparser 的保守子集) * - `app/domain/site.py` `SiteUtils.is_logged_in` → `isLoggedIn` @@ -192,6 +192,8 @@ export function extractDomain(url: string | null | undefined): string { } } if (!netloc) return ""; + netloc = netloc.replace(/^\.+/, ""); + if (!netloc) return ""; const labels = netloc.split("."); if (labels.length > 3) return netloc; return labels.slice(-2).join("."); diff --git a/packages/adapters/src/sites/user-parsers/discuz.test.ts b/packages/adapters/src/sites/user-parsers/discuz.test.ts index cc16c253..7e575ee2 100644 --- a/packages/adapters/src/sites/user-parsers/discuz.test.ts +++ b/packages/adapters/src/sites/user-parsers/discuz.test.ts @@ -33,4 +33,14 @@ describe("DiscuzUserInfo(MP discuz.py)", () => { expect(info.errMsg).toContain("未检测到已登陆"); expect(info.userId).toBeNull(); }); + + it.each(["分享率 (1,234.5)", "分享率:8.25"])("分享率读取整条文本且不覆盖积分: %s", async (ratio) => { + const m = createMockFetch({ + "/": { body: DISCUZ_USER_INDEX }, + "/home.php": { body: `` }, + }); + 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); + }); }); diff --git a/packages/adapters/src/sites/user-parsers/discuz.ts b/packages/adapters/src/sites/user-parsers/discuz.ts index c660bc24..83edcc43 100644 --- a/packages/adapters/src/sites/user-parsers/discuz.ts +++ b/packages/adapters/src/sites/user-parsers/discuz.ts @@ -1,14 +1,14 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 站点用户信息解析器。 * * 函数对照(`DiscuzUserInfo`):`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page` → 空、 * `_parse_user_detail_info` → `_parseUserDetailInfo`、`_parse_user_torrent_seeding_info` → * `_parseUserTorrentSeedingInfo`、`_parse_user_traffic_info` / `_parse_message_unread_links` / `_parse_message_content` → 空。 - * XPath 与正则逐字保留(含 MP 把"分享率"括号值写入 bonus 的原行为,随后被"积分"覆盖)。 + * 分享率从整条列表项读取,独立于积分字段。 */ import { type NextPage, SiteParserBase } from "./base.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(); const joinAt = html.strings('//li[em[text()="注册时间"]]/text()'); if (joinAt.length) this.joinAt = normalizeDatetime(joinAt[0]!.trim()) ?? null; - const ratioText = html.strings('//li[contains(.//text(), "分享率")]//text()'); - if (ratioText.length) { - const m = reSearch(/\(([\d,.]+)\)/, ratioText[0]); - if (m?.[1]?.trim()) this.bonus = parseFloatMp(m[1]); + for (const item of html.nodes('//li[contains(., "分享率")]')) { + const m = /分享率\s*[::]?\s*[((]?\s*([\d,.]+)/.exec(item.text()); + if (m) { + this.ratio = parseFloatMp(m[1]); + break; + } } const bonusText = html.strings('//li[em[text()="积分"]]/text()'); if (bonusText.length) this.bonus = parseFloatMp(bonusText[0]!.trim()); diff --git a/packages/adapters/src/sites/user-parsers/dom.ts b/packages/adapters/src/sites/user-parsers/dom.ts index d955405b..a05545aa 100644 --- a/packages/adapters/src/sites/user-parsers/dom.ts +++ b/packages/adapters/src/sites/user-parsers/dom.ts @@ -1,11 +1,11 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 等价层(站点用户信息解析器专用)。 * - * 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 表达式能逐字迁移,本层: * 1. 用 htmlparser2(经 cheerio,HTML 模式)解析——与 libxml2 HTML 解析器一样**不插入 **, * 标签/属性名小写,实体解码(` ` → U+00A0); diff --git a/packages/adapters/src/sites/user-parsers/file-list.ts b/packages/adapters/src/sites/user-parsers/file-list.ts index b269f90f..61263bec 100644 --- a/packages/adapters/src/sites/user-parsers/file-list.ts +++ b/packages/adapters/src/sites/user-parsers/file-list.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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`)。 * 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_user_base_info` → `_parseUserBaseInfo`、 diff --git a/packages/adapters/src/sites/user-parsers/gazelle.ts b/packages/adapters/src/sites/user-parsers/gazelle.ts index fa2be55f..e338466c 100644 --- a/packages/adapters/src/sites/user-parsers/gazelle.ts +++ b/packages/adapters/src/sites/user-parsers/gazelle.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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`)。 * 函数对照:`_parse_logged_in` → `_parseLoggedIn`、`_parse_user_base_info` → `_parseUserBaseInfo`、 diff --git a/packages/adapters/src/sites/user-parsers/hddolby.test.ts b/packages/adapters/src/sites/user-parsers/hddolby.test.ts index 8a74ad9d..15566911 100644 --- a/packages/adapters/src/sites/user-parsers/hddolby.test.ts +++ b/packages/adapters/src/sites/user-parsers/hddolby.test.ts @@ -50,6 +50,21 @@ describe("HDDolbySiteUserInfo(MP hddolby.py)", () => { fetchMessages: true, }); 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"]); }); }); diff --git a/packages/adapters/src/sites/user-parsers/hddolby.ts b/packages/adapters/src/sites/user-parsers/hddolby.ts index 8ba6c277..6c6a3691 100644 --- a/packages/adapters/src/sites/user-parsers/hddolby.ts +++ b/packages/adapters/src/sites/user-parsers/hddolby.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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_sysRoleList` → `HDDOLBY_SYS_ROLE_LIST`、`_parse_site_page` → `_parseSitePage`(api.<域>)、 @@ -49,16 +49,17 @@ export class HDDolbySiteUserInfo extends SiteParserBase { override requestMode = "apikey" as const; 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._userDetailPage = null; - this._userBasicPage = "api/v1/user/data"; + this._userBasicPage = "user/data"; this._userBasicParams = {}; this._userBasicHeaders = { "Content-Type": "application/json", Accept: "application/json, text/plain, */*" }; this._sysMailUnreadPage = null; this._userMailUnreadPage = null; this._mailUnreadParams = {}; - this._torrentSeedingPage = "api/v1/user/peers"; + this._torrentSeedingPage = "user/peers"; this._torrentSeedingParams = {}; this._torrentSeedingHeaders = { "Content-Type": "application/json", Accept: "application/json, text/plain, */*" }; this._additionHeaders = { "x-api-key": this.apikey ?? "" }; diff --git a/packages/adapters/src/sites/user-parsers/ipt.ts b/packages/adapters/src/sites/user-parsers/ipt.ts index 66fb6cba..74b47361 100644 --- a/packages/adapters/src/sites/user-parsers/ipt.ts +++ b/packages/adapters/src/sites/user-parsers/ipt.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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`)。 * 函数对照:`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page`(空)、 diff --git a/packages/adapters/src/sites/user-parsers/mtorrent.test.ts b/packages/adapters/src/sites/user-parsers/mtorrent.test.ts index 12c02a10..2a466e49 100644 --- a/packages/adapters/src/sites/user-parsers/mtorrent.test.ts +++ b/packages/adapters/src/sites/user-parsers/mtorrent.test.ts @@ -88,4 +88,17 @@ describe("MTorrentSiteUserInfo(MP mtorrent.py)", () => { expect(m2.requests).toHaveLength(0); 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"]); + }); }); diff --git a/packages/adapters/src/sites/user-parsers/mtorrent.ts b/packages/adapters/src/sites/user-parsers/mtorrent.ts index 97d0a0e8..827666b7 100644 --- a/packages/adapters/src/sites/user-parsers/mtorrent.ts +++ b/packages/adapters/src/sites/user-parsers/mtorrent.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 模式)。 * 函数对照:`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; this.userid = u.id == null ? null : String(u.id); 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.upload = pyInt(memberCount.uploaded || "0"); this.download = pyInt(memberCount.downloaded || "0"); @@ -117,6 +117,7 @@ export class MTorrentSiteUserInfo extends SiteParserBase { this.seeding += pageSeeding; this.seedingSize += pageSeedingSize; this.seedingInfo.push(...pageSeedingInfo); + if (!pageSeeding) return null; // 查询总做种数 let seederCount = 0; diff --git a/packages/adapters/src/sites/user-parsers/nexus-audiences.test.ts b/packages/adapters/src/sites/user-parsers/nexus-audiences.test.ts index c5d3de27..a0121ba8 100644 --- a/packages/adapters/src/sites/user-parsers/nexus-audiences.test.ts +++ b/packages/adapters/src/sites/user-parsers/nexus-audiences.test.ts @@ -72,4 +72,30 @@ describe("NexusAudiencesSiteUserInfo(MP nexus_audiences.py)", () => { expect(NexusAudiencesSiteUserInfo.extractInboxUnreadBadge(" 5 ")).toBe(5); expect(NexusAudiencesSiteUserInfo.extractInboxUnreadBadge("新")).toBeNull(); }); + + it("优先顶部用户栏,忽略列表上传者卡片的身份、等级与流量", async () => { + const m = createMockFetch({ + "/": { + body: `退出 +
self4
+
other999
+ `, + }, + }); + 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: `退出self +
+
+ `, + }, + }); + const info = await runParser(NexusAudiencesSiteUserInfo, { fetch: m.fetch }); + expect(info).toMatchObject({ userId: "11", username: "self", upload: 8 * GiB }); + }); }); diff --git a/packages/adapters/src/sites/user-parsers/nexus-audiences.ts b/packages/adapters/src/sites/user-parsers/nexus-audiences.ts index 8ab96bf7..97cf49cd 100644 --- a/packages/adapters/src/sites/user-parsers/nexus-audiences.ts +++ b/packages/adapters/src/sites/user-parsers/nexus-audiences.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 通用解析。 * @@ -273,16 +273,27 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo { private parseUserbarInfo(htmlText: string): void { const html = parseHtml(htmlText); 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.parseUploaderStats(node.attr("data-uploader-stats")); } // data-uploader-stats 不包含分享率,需从 compact metric 的 class 中读取 - this.parseCompactMetric(html, "ratio", "ratio"); - this.parseCompactMetric(html, "uploaded", "upload"); - this.parseCompactMetric(html, "downloaded", "download"); - this.parseCompactMetric(html, "bonus", "bonus"); - this.parseCompactMetric(html, "active", "active"); + this.parseCompactMetric(scope, "ratio", "ratio"); + this.parseCompactMetric(scope, "uploaded", "upload"); + this.parseCompactMetric(scope, "downloaded", "download"); + this.parseCompactMetric(scope, "bonus", "bonus"); + this.parseCompactMetric(scope, "active", "active"); } private parseUserIdentity(node: XNode): void { @@ -292,7 +303,11 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo { const username = node.attr("data-uploader-label"); if (username?.trim()) this.username = username.trim(); 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 { @@ -316,12 +331,12 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo { private parseCompactMetric(html: XNode, metric: string, field: string): void { 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()", ); if (!values.length) { values = html.strings( - `//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar__compact-metric--${metric} ")]` + + `.//*[contains(concat(" ", normalize-space(@class), " "), " site-userbar__compact-metric--${metric} ")]` + "/text()", ); } @@ -381,7 +396,7 @@ export class NexusAudiencesSiteUserInfo extends NexusPhpSiteUserInfo { /** 做种页只读 Total 汇总行 */ protected override async _parseSeedingPages(): Promise { - if (!this._torrentSeedingPage) return; + if (!this._torrentSeedingPage || !this._hasValidUserid()) return; this._torrentSeedingHeaders = { Referer: urljoin(this._baseUrl, this._userDetailPage ?? "") }; const htmlText = await this._getPageContent( urljoin(this._baseUrl, this._torrentSeedingPage), diff --git a/packages/adapters/src/sites/user-parsers/nexus-hhanclub.ts b/packages/adapters/src/sites/user-parsers/nexus-hhanclub.ts index 4ff3ecfc..46fabba9 100644 --- a/packages/adapters/src/sites/user-parsers/nexus-hhanclub.ts +++ b/packages/adapters/src/sites/user-parsers/nexus-hhanclub.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 通用解析。 * diff --git a/packages/adapters/src/sites/user-parsers/nexus-php.test.ts b/packages/adapters/src/sites/user-parsers/nexus-php.test.ts index 00806dda..03bac5e8 100644 --- a/packages/adapters/src/sites/user-parsers/nexus-php.test.ts +++ b/packages/adapters/src/sites/user-parsers/nexus-php.test.ts @@ -58,7 +58,7 @@ describe("NexusPhpSiteUserInfo(MP nexus_php.py)", () => { const m = createMockFetch({ "/": { body: NEXUS_PHP_CF_CHALLENGE, status: 403 } }); const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch }); 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 停止", () => { @@ -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?page=2", null)).toBeNull(); }); + + it.each([null, "0"])("无有效用户ID %s 时不请求详情与做种", async (id) => { + const link = id === null ? "" : `用户`; + const m = createMockFetch({ "/": { body: `退出${link}` } }); + 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: `退出uuid-user`, + }, + "/userdetails.php": { body: "资料" }, + "/getusertorrentlistajax.php": (req) => ({ + body: `
名称备注大小在做种
测试2 GB3
${req.url.searchParams.has("page") ? "" : '下一页'}`, + }), + }); + 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: '退出reader', + }, + "/userdetails.php": { + body: "
总上传999 TB
上传量:24 GiB (已优惠)
下载量: 3 GiB
分享率8.125
", + }, + }); + 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: '退出reader上传: 12 GB', + }, + "/userdetails.php": { + body: "
上传999 TB
下载3 GB
", + }, + }); + const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch }); + expect(info).toMatchObject({ upload: 12 * 1024 ** 3, download: 3 * 1024 ** 3, ratio: 4 }); + }); + + it.each([ + ['1,234.5', 1234.5], + ['魔力: 2,345.6', 2345.6], + ['
魔力值 说明:3,456.7
', 3456.7], + ["无魔力值数据", 0], + ])("魔力值DOM布局 %s", async (markup, bonus) => { + const m = createMockFetch({ "/": { body: `退出${markup}` } }); + const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch }); + expect(info.bonus).toBe(bonus); + }); + + it("等级标记取当前UID的通用等级,不拿站点组名当等级", async () => { + const m = createMockFetch({ + "/": { + body: '退出reader[UID=99][(other) Wrong][UID=21][(本站组名) Elite User]', + }, + }); + const info = await runParser(NexusPhpSiteUserInfo, { fetch: m.fetch }); + expect(info.userLevel).toBe("Elite User"); + }); + + it("Pter完整做种列表替换详情兜底,不重复累计,并发送导航请求头", async () => { + const row = + '图标种子时间2 GB6'; + const m = createMockFetch({ + "/": { + body: '退出reader', + }, + "/userdetails.php": { + body: `
当前做种${row}
`, + }, + "/getusertorrentlist.php": { body: `
${row}${row}
` }, + }); + 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: '退出reader', + }, + "/userdetails.php": { + body: "

当前做种:10 做种大小:25 GB

当前上传
2 GB4
", + }, + }); + 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: '退出reader', + }, + "/userdetails.php": { body: "
当前做种:9 做种大小:30 GB
" }, + "/torrents.php": { + body: '
3 GB7
', + }, + }); + 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: '退出reader', + }, + "/viewtorrentsdetail.php": { + body: '
当前做种:12 做种大小:40 GiB
全站种子下一页', + }, + }); + 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); + }); }); diff --git a/packages/adapters/src/sites/user-parsers/nexus-php.ts b/packages/adapters/src/sites/user-parsers/nexus-php.ts index a1437d78..048e4d14 100644 --- a/packages/adapters/src/sites/user-parsers/nexus-php.ts +++ b/packages/adapters/src/sites/user-parsers/nexus-php.ts @@ -1,18 +1,18 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 站点用户信息解析器。 * - * 迁移自 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_user_base_info` → `_parseUserBaseInfo`、`_parse_user_traffic_info` → `_parseUserTrafficInfo`、 * `_parse_ucoin` → `parseUcoin`、`_parse_user_torrent_seeding_info` → `_parseUserTorrentSeedingInfo`、 * `_fixup_next_page_url` → `fixupNextPageUrl`、`_parse_user_detail_info` → `_parseUserDetailInfo`、 * `_fixup_torrent_seeding_page` → `_fixupTorrentSeedingPage`、`_get_user_level` → `_getUserLevel`、 * `_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。 */ @@ -28,14 +28,44 @@ export function round3(x: number): number { export class NexusPhpSiteUserInfo extends SiteParserBase { 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 { 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()) { this._userDetailPage = userDetail[0].trim().replace(/^\/+/, ""); this.userid = userDetail[1]!; - this._torrentSeedingPage = `getusertorrentlistajax.php?userid=${this.userid}&type=seeding`; + this._torrentSeedingPage = this._buildTorrentSeedingPage(); } else { const m = reSearch(/(userdetails)/, text); if (m?.[0].trim()) { @@ -71,6 +101,8 @@ export class NexusPhpSiteUserInfo extends SiteParserBase { const html = parseHtml(htmlText); 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()`); if (ret.length) { this.username = String(ret[0]); @@ -98,26 +130,80 @@ export class NexusPhpSiteUserInfo extends SiteParserBase { const [hasUcoin, bonus] = NexusPhpSiteUserInfo.parseUcoin(html); this.bonus = bonus; if (hasUcoin) return; - const tmps = html ? html.strings('//a[contains(@href,"mybonus")]/text()') : []; - if (tmps.length) { - const bonusText = String(tmps[0]).trim(); - const m = reSearch(/([\d,.]+)/, bonusText); - if (m && m[1]!.trim()) { - this.bonus = parseFloatMp(m[1]); + const bonusLinks = html?.nodes('//a[contains(@href,"mybonus")]') ?? []; + for (const link of bonusLinks) { + for (const value of link.strings("text()")) { + const match = /([\d,.]+)/.exec(value.trim()); + if (match) { + 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; } } + 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); if (bonusMatch && bonusMatch[1]!.trim()) { this.bonus = parseFloatMp(bonusMatch[1]); 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()) { this.bonus = parseFloatMp(bonusMatch[1]!.replace(/^"+|"+$/g, "")); } } + /** 完整列表替换详情页的兜底列表;缺页时保留详情事实,站点汇总优先。 */ + protected override async _parseSeedingPages(): Promise { + 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`:金/银/铜币统一换算为铜币 */ static parseUcoin(html: XNode | null): [boolean, number] { if (hasChildElements(html)) { @@ -140,6 +226,9 @@ export class NexusPhpSiteUserInfo extends SiteParserBase { protected _parseUserTorrentSeedingInfo(htmlText: string, multiPage = false): NextPage { const html = parseHtml(String(htmlText ?? "").replaceAll("\\/", "/")); 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'); @@ -160,22 +249,42 @@ export class NexusPhpSiteUserInfo extends SiteParserBase { if (html.nodes(seedersColXpath).length) { seedersCol = html.nodes(`${seedersColXpath}/preceding-sibling::td`).length + 1; } + if (this.seedingDomain === "keepfrds.com") { + sizeCol = 5; + seedersCol = 6; + } let pageSeeding = 0; let pageSeedingSize = 0; const pageSeedingInfo: Array<[number, number]> = []; - 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()); - const seeders = parseIntMp(seedingSeeders[i]); + if (["pterclub.net", "pterclub.com"].includes(this.seedingDomain)) { + const rows = html.nodes( + '//*[@id="outer"]/table/tbody/tr | //*[@id="outer"]/table/tr' + + '|//table[contains(concat(" ", normalize-space(@class), " "), " torrents ")]/tbody/tr' + + '|//table[contains(concat(" ", normalize-space(@class), " "), " torrents ")]/tr', + ); + for (const row of rows) { + const cells = row.nodes("./td | ./th"); + if (cells.length < 5 || !cells[1]!.nodes(".//a").length) continue; + const size = this.numFilesize(cells[3]!.text().trim()); + if (!size) continue; + pageSeeding += 1; 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; @@ -208,9 +317,9 @@ export class NexusPhpSiteUserInfo extends SiteParserBase { // parse_qsl → dict:同名键后者覆盖,保留插入顺序 const params = new Map(); 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; - params.set("userid", userid); + params.set(NexusPhpSiteUserInfo.isUuid(userid) ? "useruuid" : "userid", userid); if (!params.has("type")) params.set("type", "seeding"); const qs = new URLSearchParams([...params.entries()]).toString(); return `${path}${qs ? `?${qs}` : ""}${fragment}`; @@ -219,6 +328,7 @@ export class NexusPhpSiteUserInfo extends SiteParserBase { protected _parseUserDetailInfo(htmlText: string): void { const html = parseHtml(htmlText); if (!hasChildElements(html)) return; + this.parseSeedingSummary(html); this._getUserLevel(html); this._fixupTrafficInfo(html); @@ -233,20 +343,22 @@ export class NexusPhpSiteUserInfo extends SiteParserBase { } // 做种体积 & 做种数(seeding 页面获取不到的话,此处再获取一次) - const seedingSizes = html.nodes( - '//tr/td[text()="当前上传"]/following-sibling::td[1]//table[tr[1][td[4 and text()="尺寸"]]]//tr[position()>1]/td[4]', + const seedingTables = html.nodes( + '//tr/td[normalize-space(.)="当前上传" or normalize-space(.)="当前做种"]/following-sibling::td[1]//table', ); - const seedingSeeders = html.strings( - '//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 tmpSeeding = 0; let tmpSeedingSize = 0; const tmpSeedingInfo: Array<[number, number]> = []; - for (let i = 0; i < seedingSizes.length; i++) { - const size = this.numFilesize(seedingSizes[i]!.text().trim()); - const seeders = parseIntMp(seedingSeeders[i]); - tmpSeedingSize += size; - tmpSeedingInfo.push([seeders, size]); + for (const table of seedingTables) { + for (const row of table.nodes("./tr | ./tbody/tr")) { + const cells = row.nodes("./td | ./th"); + if (cells.length < 5) continue; + 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.seeding) this.seeding = tmpSeeding; @@ -277,10 +389,10 @@ export class NexusPhpSiteUserInfo extends SiteParserBase { ); const csrfText = html.strings('//meta[@name="x-csrf"]/@content'); 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()) { this.userid = userJs[1].trim(); - this._torrentSeedingPage = `getusertorrentlistajax.php?userid=${this.userid}&type=seeding`; + this._torrentSeedingPage = this._buildTorrentSeedingPage(); } } else if (seedingUrlText.length && csrfText.length) { 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 { @@ -353,9 +473,34 @@ export class NexusPhpSiteUserInfo extends SiteParserBase { /** MP `_fixup_traffic_info` */ 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) { const bonusText = html.strings('//tr/td[text()="魔力值" or text()="猫粮"]/following-sibling::td[1]/text()'); 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; + } } diff --git a/packages/adapters/src/sites/user-parsers/nexus-project.ts b/packages/adapters/src/sites/user-parsers/nexus-project.ts index bb6e83a2..9a5874f3 100644 --- a/packages/adapters/src/sites/user-parsers/nexus-project.ts +++ b/packages/adapters/src/sites/user-parsers/nexus-project.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 通用解析。 * diff --git a/packages/adapters/src/sites/user-parsers/nexus-rabbit.ts b/packages/adapters/src/sites/user-parsers/nexus-rabbit.ts index 56f48db0..07e2f71c 100644 --- a/packages/adapters/src/sites/user-parsers/nexus-rabbit.ts +++ b/packages/adapters/src/sites/user-parsers/nexus-rabbit.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 模板)站点用户信息解析器。 * diff --git a/packages/adapters/src/sites/user-parsers/registry.test.ts b/packages/adapters/src/sites/user-parsers/registry.test.ts index a345c620..a632c6f9 100644 --- a/packages/adapters/src/sites/user-parsers/registry.test.ts +++ b/packages/adapters/src/sites/user-parsers/registry.test.ts @@ -47,4 +47,57 @@ describe("用户信息解析器注册表(MP refresh_userdata)", () => { expect(info).toMatchObject({ schema: "NexusPhp", userId: null }); expect(info!.errMsg).toContain("未检测到已登陆"); }); + + it("回退只尝试通用网页模型,不探测专站API", async () => { + const m = createMockFetch({ + "/": { body: '退出
没有用户资料
' }, + }); + 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: '退出' } }); + 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"); + }); }); diff --git a/packages/adapters/src/sites/user-parsers/registry.ts b/packages/adapters/src/sites/user-parsers/registry.ts index 6915dd05..e8558f36 100644 --- a/packages/adapters/src/sites/user-parsers/registry.ts +++ b/packages/adapters/src/sites/user-parsers/registry.ts @@ -7,7 +7,7 @@ * - `IndexerModule.__init__` 里 `ModuleHelper.load('app.modules.indexer.parser', filter=有 schema)` → `USER_PARSER_CLASSES` * (按模块文件名字母序,与 pkgutil 遍历顺序一致;回退时按此顺序尝试) * - `IndexerModule.refresh_userdata` → `refreshSiteUserData`:按站点声明的 schema 取解析器;解析后 userid 为空且非公开站时, - * 依次尝试其他 schema(兼容 schema 标注错误/变种站),其他 schema 的异常只记录不抛出。 + * 仅尝试 Gazelle/NexusPhp/Unit3d 通用模型,避免探测专站 API;回退异常不抛出。 * * 与 MP 一致:声明 schema 解析器的异常原样抛出(由调用方记录),只有回退尝试的异常被吞掉。 * 差异:结果不含凭据;`fetchMessages` 默认 false(MP 由全局 SITE_MESSAGE 控制)。 @@ -31,7 +31,7 @@ import { SmallHorseSiteUserInfo } from "./small-horse.ts"; import { SunnyPTSiteUserInfo } from "./sunnypt.ts"; import { TNodeSiteUserInfo } from "./tnode.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 { YemaSiteUserInfo } from "./yema.ts"; import { ZhixingSiteUserInfo } from "./zhixing.ts"; @@ -72,15 +72,27 @@ export interface SiteUserDataRequest extends Omit([SiteSchema.Gazelle, SiteSchema.NexusPhp, SiteSchema.Unit3d]); + /** MP `refresh_userdata`:未找到解析器返回 null */ export async function refreshSiteUserData(site: SiteUserDataRequest): Promise { const make = (schema: string | null | undefined): SiteParserBase | null => { const cls = getUserParserClass(schema); if (!cls) return null; - const { name, schema: _s, public: _p, ...rest } = site; - return new cls({ ...rest, siteName: name }); + const { name, schema: _s, public: _p, userdataPath, defaultUa, ...rest } = site; + 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); if (!obj) return null; @@ -90,7 +102,7 @@ export async function refreshSiteUserData(site: SiteUserDataRequest): Promise([String(site.schema)]); for (const cls of USER_PARSER_CLASSES) { 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); const alt = make(value); if (!alt) continue; @@ -105,6 +117,9 @@ export async function refreshSiteUserData(site: SiteUserDataRequest): Promise { expect(info.userId).toBeNull(); 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: [] }); + }); }); diff --git a/packages/adapters/src/sites/user-parsers/rousi.ts b/packages/adapters/src/sites/user-parsers/rousi.ts index 6fd811e6..d28720f9 100644 --- a/packages/adapters/src/sites/user-parsers/rousi.ts +++ b/packages/adapters/src/sites/user-parsers/rousi.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 模式)。 * 函数对照:`_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 res = await this._rawJson("GET", `${urljoin(this._baseUrl, "api/messages")}?${qs}`, headers); 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 res = await getMessageList(page); diff --git a/packages/adapters/src/sites/user-parsers/small-horse.ts b/packages/adapters/src/sites/user-parsers/small-horse.ts index 3c7daf39..1ae8a9b4 100644 --- a/packages/adapters/src/sites/user-parsers/small-horse.ts +++ b/packages/adapters/src/sites/user-parsers/small-horse.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 变种)站点用户信息解析器。 * diff --git a/packages/adapters/src/sites/user-parsers/sunnypt.ts b/packages/adapters/src/sites/user-parsers/sunnypt.ts index 5eff0d3c..cfa73d81 100644 --- a/packages/adapters/src/sites/user-parsers/sunnypt.ts +++ b/packages/adapters/src/sites/user-parsers/sunnypt.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 模式)。 * 函数对照:`_parse_site_page` → `_parseSitePage`(api_url,缺省 https://api.sunnypt.top/api/v1/mp)、 diff --git a/packages/adapters/src/sites/user-parsers/tnode.ts b/packages/adapters/src/sites/user-parsers/tnode.ts index f8bfbaaa..e4559c0b 100644 --- a/packages/adapters/src/sites/user-parsers/tnode.ts +++ b/packages/adapters/src/sites/user-parsers/tnode.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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`)。 * 函数对照:`_parse_site_page` → `_parseSitePage`(首页取 x-csrf-token,改走 JSON 接口)、 @@ -34,6 +34,7 @@ export class TNodeSiteUserInfo extends SiteParserBase { if (csrf) { this._additionHeaders = { "X-CSRF-TOKEN": csrf[1]! }; this._userDetailPage = "api/user/getMainInfo"; + this._userDetailRequiresUserid = false; this._torrentSeedingPage = "api/user/listTorrentActivity?id=&type=seeding&page=1&size=20000"; } } diff --git a/packages/adapters/src/sites/user-parsers/torrent-leech.ts b/packages/adapters/src/sites/user-parsers/torrent-leech.ts index 3c1a761f..dcce1569 100644 --- a/packages/adapters/src/sites/user-parsers/torrent-leech.ts +++ b/packages/adapters/src/sites/user-parsers/torrent-leech.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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`)。 * 函数对照:`_parse_site_page` → `_parseSitePage`、`_parse_user_base_info` → `_parseUserBaseInfo`、 diff --git a/packages/adapters/src/sites/user-parsers/types.ts b/packages/adapters/src/sites/user-parsers/types.ts index 6e0e55f3..3f53a46f 100644 --- a/packages/adapters/src/sites/user-parsers/types.ts +++ b/packages/adapters/src/sites/user-parsers/types.ts @@ -1,12 +1,12 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。 - * @mp-source app/modules/indexer/parser/__init__.py @ e6820a8 - * @mp-source app/schemas/site.py @ e6820a8 + * @mp-source app/modules/indexer/parser/__init__.py @ 3b156a5 + * @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`。 */ diff --git a/packages/adapters/src/sites/user-parsers/unit3d.test.ts b/packages/adapters/src/sites/user-parsers/unit3d.test.ts index 61392771..d422809b 100644 --- a/packages/adapters/src/sites/user-parsers/unit3d.test.ts +++ b/packages/adapters/src/sites/user-parsers/unit3d.test.ts @@ -16,6 +16,7 @@ describe("Unit3dSiteUserInfo(MP unit3d.py)", () => { const info = await runParser(Unit3dSiteUserInfo, { fetch: m.fetch }); expect(info).toMatchObject({ schema: "Unit3d", + userId: "carol", username: "carol", userLevel: "Elite", joinAt: "2020-02-03 04:05:06", @@ -42,4 +43,14 @@ describe("Unit3dSiteUserInfo(MP unit3d.py)", () => { expect(info.errMsg).toContain("未检测到已登陆"); expect(info.raw.http_errors).toEqual([expect.stringContaining("HTTP 404")]); }); + + it("新式bonus路径只读取当前用户的积分", async () => { + const m = createMockFetch({ + "/": { + body: '退出设置99991,234.5', + }, + }); + const info = await runParser(Unit3dSiteUserInfo, { fetch: m.fetch }); + expect(info).toMatchObject({ userId: "carol", username: "carol", bonus: 1234.5 }); + }); }); diff --git a/packages/adapters/src/sites/user-parsers/unit3d.ts b/packages/adapters/src/sites/user-parsers/unit3d.ts index 8c69d458..731fb706 100644 --- a/packages/adapters/src/sites/user-parsers/unit3d.ts +++ b/packages/adapters/src/sites/user-parsers/unit3d.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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`)。 * 函数对照:`_parse_user_base_info` → `_parseUserBaseInfo`、`_parse_site_page`(空)、 @@ -24,11 +24,18 @@ export class Unit3dSiteUserInfo extends SiteParserBase { const m = reSearch(/\/users\/(.+)\/settings/, hrefs[0]); if (m?.[0].trim()) { this.username = m[1]!; + this.userid = this.username; this._torrentSeedingPage = `/users/${this.username}/active?perPage=100&client=&seeding=include`; 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) { const m = reSearch(/([\d,.]+)/, bonus[0]!.text()); if (m?.[1]?.trim()) this.bonus = parseFloatMp(m[1]); diff --git a/packages/adapters/src/sites/user-parsers/yema.ts b/packages/adapters/src/sites/user-parsers/yema.ts index 257a601f..6d4fe6c0 100644 --- a/packages/adapters/src/sites/user-parsers/yema.ts +++ b/packages/adapters/src/sites/user-parsers/yema.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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 模式)。 * 函数对照:`_parse_site_page` → `_parseSitePage`(POST openApi/user/fetchBasicInfo.json,Authorization 头为 AuthKey)、 diff --git a/packages/adapters/src/sites/user-parsers/zhixing.ts b/packages/adapters/src/sites/user-parsers/zhixing.ts index 24087323..0dd05985 100644 --- a/packages/adapters/src/sites/user-parsers/zhixing.ts +++ b/packages/adapters/src/sites/user-parsers/zhixing.ts @@ -1,7 +1,7 @@ /** * SPDX-License-Identifier: GPL-3.0-only * 移植自 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)。 * @@ -12,7 +12,7 @@ * BeautifulSoup 以 cheerio 等价实现。 * * 与 MP 的偏离: - * 1. MP 末尾把全部字段转成字符串(避免其 UI join 报错);这里保持数值类型(`toUserInfo` 语义相同)。 + * 1. 数值字段与 MP v3.2.4 一样保持数值,缺失身份字段按本项目合同保留 null。 * 2. 做种翻页设上限 50 页(MP `while True`)。 */ import { load } from "cheerio";