diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d69113..df39bdc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 修复(站点适配,2026-10-02 真实账号列表页核对) +- OurBits:标题列嵌套表格让大小、发布时间取错(大小全为 0),改为只匹配行的直接子单元格;副标题原先取成了标签,改为取标签之后的正文;新增标签与列表页 IMDb / 豆瓣 ID。 +- 彩虹岛:副标题前混入的标签与末尾徽标去掉,标签单独取。 +- 憨憨:新增列表标签。 +- 站点给出纯数字 IMDb ID 时补成 `tt` + 7 位。 + ## [0.3.15] - 2026-10-02 ### 修复 diff --git a/packages/adapters/src/sites/definitions/chdbits.json b/packages/adapters/src/sites/definitions/chdbits.json index 8ae76e69..e62703d4 100644 --- a/packages/adapters/src/sites/definitions/chdbits.json +++ b/packages/adapters/src/sites/definitions/chdbits.json @@ -149,7 +149,8 @@ } }, "description": { - "selector": "font.subtitle" + "selector": "font.subtitle", + "remove": "div" }, "category": { "selector": "a[href^='?cat=']", @@ -170,6 +171,9 @@ "args": ["\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}", 0] } ] + }, + "labels": { + "selector": "font.subtitle div.tag" } } }, diff --git a/packages/adapters/src/sites/definitions/definitions.test.ts b/packages/adapters/src/sites/definitions/definitions.test.ts index 0bd34206..16fc0149 100644 --- a/packages/adapters/src/sites/definitions/definitions.test.ts +++ b/packages/adapters/src/sites/definitions/definitions.test.ts @@ -1,14 +1,17 @@ import { CHDBITS_LIST_PAGE, + CHDBITS_LIST_PAGE_2026, CHDBITS_LOGIN_PAGE, HHANCLUB_DETAIL_PAGE, HHANCLUB_LIST_PAGE, HHANCLUB_LIST_PAGE_2026, + HHANCLUB_LIST_PAGE_TAGS, + OURBITS_LIST_PAGE, } from "@umt/testkit"; import { describe, expect, it } from "vitest"; import { getSiteDefinition, listSiteDefinitions } from "../registry.ts"; import { parseDetailPage } from "../spider/detail.ts"; -import { spiderTorrentToRelease } from "../spider/map.ts"; +import { imdbIdOf, spiderTorrentToRelease } from "../spider/map.ts"; import type { IndexerDefinition } from "../spider/schema.ts"; import { SiteSpider } from "../spider/spider.ts"; import { @@ -198,3 +201,44 @@ describe("CHDBits 定义(标准 NexusPHP)", () => { expect(r.torrents).toEqual([]); }); }); + +describe("2026-10 真实列表页结构(脱敏样例)", () => { + const parse = (key: string, html: string) => { + const def = BUILTIN_SITE_DEFINITIONS[key] as IndexerDefinition; + return new SiteSpider(def, {}).parse(html).torrents.map((t) => spiderTorrentToRelease(t, def, OBSERVED)!); + }; + + it("OurBits:嵌套 torrentname 不干扰列号;副标题在标签之后;标签与列表页 IMDb / 豆瓣 ID", () => { + const [a] = parse("ourbits", OURBITS_LIST_PAGE); + expect(a).toMatchObject({ + siteReleaseId: "900001", + sizeBytes: Math.round(38.44 * 1024 ** 3), + seeders: 174, + leechers: 17, + snatched: 334, + subtitle: "示例电影[简繁双语字幕]", + tags: ["官方", "中字"], + imdbId: "tt0299977", + doubanId: "1306123", + }); + }); + + it("CHDBits:副标题去掉前面的标签块和末尾徽标,标签单独取", () => { + const [a] = parse("chdbits", CHDBITS_LIST_PAGE_2026); + expect(a!.subtitle).toBe("示例劫案 [DIY UHD 原盘]"); + expect(a!.tags).toEqual(["官方", "DIY", "中字"]); + }); + + it("HHClub:标签取 span.tag,不混入促销", () => { + const [a] = parse("hhanclub", HHANCLUB_LIST_PAGE_TAGS); + expect(a!.tags).toEqual(["官方", "中字"]); + }); + + it("IMDb ID 规范化:纯数字补 tt + 7 位,0 与非法值丢弃", () => { + expect(imdbIdOf("299977")).toBe("tt0299977"); + expect(imdbIdOf("35522483")).toBe("tt35522483"); + expect(imdbIdOf("tt0120907")).toBe("tt0120907"); + expect(imdbIdOf("0")).toBeUndefined(); + expect(imdbIdOf("N/A")).toBeUndefined(); + }); +}); diff --git a/packages/adapters/src/sites/definitions/hhanclub.json b/packages/adapters/src/sites/definitions/hhanclub.json index 4c46f915..d9e6e326 100644 --- a/packages/adapters/src/sites/definitions/hhanclub.json +++ b/packages/adapters/src/sites/definitions/hhanclub.json @@ -181,6 +181,9 @@ }, "hr": { "selector": "div.torrent-info-hr" + }, + "labels": { + "selector": "div.torrent-title span.tag" } } }, diff --git a/packages/adapters/src/sites/definitions/ourbits.json b/packages/adapters/src/sites/definitions/ourbits.json index 7e24fc26..ad9d2c5e 100644 --- a/packages/adapters/src/sites/definitions/ourbits.json +++ b/packages/adapters/src/sites/definitions/ourbits.json @@ -8,7 +8,8 @@ "ref": "v0.27.0", "commit": "c79bb1b0", "license": "MIT", - "file": "src/movieclaw_tracker/sites/configs/ourbits.yaml" + "file": "src/movieclaw_tracker/sites/configs/ourbits.yaml", + "overrides": "scripts/mp-sync/movieclaw-overrides/ourbits.json" }, "domain": "https://ourbits.club/", "encoding": "UTF-8", @@ -105,20 +106,20 @@ "attribute": "href" }, "imdbid": { - "selector": "a[href*=\"imdb.com/title/tt\"]", - "attribute": "href", + "selector": "label.imdb_rate", + "attribute": "data-imdbid", "filters": [ { "name": "re_search", - "args": ["tt\\d+", 0] + "args": ["[1-9]\\d*", 0] } ] }, "date_elapsed": { - "selector": "td:nth-child(4)" + "selector": "> td:nth-child(4)" }, "date_added": { - "selector": "td:nth-child(4) > span", + "selector": "> td:nth-child(4) > span", "attribute": "title" }, "date": { @@ -131,16 +132,16 @@ ] }, "size": { - "selector": "td:nth-child(5)" + "selector": "> td:nth-child(5)" }, "seeders": { - "selector": "td:nth-child(6)" + "selector": "> td:nth-child(6)" }, "leechers": { - "selector": "td:nth-child(7)" + "selector": "> td:nth-child(7)" }, "grabs": { - "selector": "td:nth-child(8)" + "selector": "> td:nth-child(8)" }, "downloadvolumefactor": { "case": { @@ -161,7 +162,8 @@ } }, "description": { - "selector": "td.embedded > div" + "selector": "table.torrentname td.embedded", + "remove": "a, b, img, div, br, font" }, "category": { "selector": "a[href^='?cat=']", @@ -175,6 +177,19 @@ }, "hr": { "selector": "img.hitandrun" + }, + "labels": { + "selector": "span.tag" + }, + "doubanid": { + "selector": "label.douban_rate", + "attribute": "data-doubanid", + "filters": [ + { + "name": "re_search", + "args": ["[1-9]\\d*", 0] + } + ] } } }, diff --git a/packages/adapters/src/sites/spider/map.ts b/packages/adapters/src/sites/spider/map.ts index 0fab73cc..98a06289 100644 --- a/packages/adapters/src/sites/spider/map.ts +++ b/packages/adapters/src/sites/spider/map.ts @@ -45,6 +45,14 @@ export function categoryName(def: IndexerDefinition, id: string | undefined): st } /** SpiderTorrent(MP TorrentInfo 字段)→ SiteRelease。raw 保存完整 MP 字段(enclosure 脱敏)。 */ +/** 站点给的 IMDb ID:`tt0299977` 原样;纯数字(OurBits 列表页 data-imdbid)补成 `tt` + 至少 7 位;其他丢弃 */ +export function imdbIdOf(v: string | undefined): string | undefined { + const s = v?.trim(); + if (!s) return undefined; + if (/^tt\d{7,}$/.test(s)) return s; + return /^\d+$/.test(s) && Number(s) > 0 ? `tt${s.padStart(7, "0")}` : undefined; +} + export function spiderTorrentToRelease( t: SpiderTorrent, def: IndexerDefinition, @@ -77,7 +85,8 @@ export function spiderTorrentToRelease( observedAt, }; if (t.description) release.subtitle = t.description; - if (t.imdbid) release.imdbId = t.imdbid; + const imdb = imdbIdOf(t.imdbid); + if (imdb) release.imdbId = imdb; if (t.doubanid) release.doubanId = t.doubanid; return release; } diff --git a/packages/adapters/test/differential/indexer.json b/packages/adapters/test/differential/indexer.json index a074fff8..8b289336 100644 --- a/packages/adapters/test/differential/indexer.json +++ b/packages/adapters/test/differential/indexer.json @@ -7333,6 +7333,98 @@ "tjupt@tk-chdbits-list#1.pubdate": { "kind": "mp_bug", "note": "tjupt 定义 date_added 取单元格文本(\"2天\"/\"26天\"相对时间),MP normalize_datetime 用 dateparser 以当前时刻换算成绝对时间(生成时冻结 now=2026-09-27T12:00),结果随抓取时刻漂移;本项目 normalizeDatetime 只接受绝对时间,相对时间不写 pubdate(与 MP __is_relative_pubdate_text 的意图一致)。" + }, + "chdbits@tk-audiences-list#0.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "chdbits@tk-audiences-list#1.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "chdbits@tk-audiences-list#2.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "chdbits@tk-audiences-list#3.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "chdbits@tk-audiences-list#4.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "chdbits@tk-chdbits-list#0.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "chdbits@tk-chdbits-list#1.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "hhanclub@tk-hhanclub-list#0.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "hhanclub@tk-hhanclub-list#1.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "ourbits@tk-audiences-list#0.description": { + "kind": "intentional", + "note": "2026-10-02 OurBits 覆盖层改了副标题选择器(原定义 td.embedded > div 取到的是标签块,真实页面副标题在标签之后);MP 参照仍用原定义,在通用样例上为 null,本项目取到正文。" + }, + "ourbits@tk-audiences-list#0.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "ourbits@tk-audiences-list#1.description": { + "kind": "intentional", + "note": "2026-10-02 OurBits 覆盖层改了副标题选择器(原定义 td.embedded > div 取到的是标签块,真实页面副标题在标签之后);MP 参照仍用原定义,在通用样例上为 null,本项目取到正文。" + }, + "ourbits@tk-audiences-list#1.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "ourbits@tk-audiences-list#2.description": { + "kind": "intentional", + "note": "2026-10-02 OurBits 覆盖层改了副标题选择器(原定义 td.embedded > div 取到的是标签块,真实页面副标题在标签之后);MP 参照仍用原定义,在通用样例上为 null,本项目取到正文。" + }, + "ourbits@tk-audiences-list#2.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "ourbits@tk-audiences-list#3.description": { + "kind": "intentional", + "note": "2026-10-02 OurBits 覆盖层改了副标题选择器(原定义 td.embedded > div 取到的是标签块,真实页面副标题在标签之后);MP 参照仍用原定义,在通用样例上为 null,本项目取到正文。" + }, + "ourbits@tk-audiences-list#3.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "ourbits@tk-audiences-list#4.description": { + "kind": "intentional", + "note": "2026-10-02 OurBits 覆盖层改了副标题选择器(原定义 td.embedded > div 取到的是标签块,真实页面副标题在标签之后);MP 参照仍用原定义,在通用样例上为 null,本项目取到正文。" + }, + "ourbits@tk-audiences-list#4.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "ourbits@tk-chdbits-list#0.description": { + "kind": "intentional", + "note": "2026-10-02 OurBits 覆盖层改了副标题选择器(原定义 td.embedded > div 取到的是标签块,真实页面副标题在标签之后);MP 参照仍用原定义,在通用样例上为 null,本项目取到正文。" + }, + "ourbits@tk-chdbits-list#0.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" + }, + "ourbits@tk-chdbits-list#1.description": { + "kind": "intentional", + "note": "2026-10-02 OurBits 覆盖层改了副标题选择器(原定义 td.embedded > div 取到的是标签块,真实页面副标题在标签之后);MP 参照仍用原定义,在通用样例上为 null,本项目取到正文。" + }, + "ourbits@tk-chdbits-list#1.labels": { + "kind": "intentional", + "note": "2026-10-02 按真实列表页给 OurBits / 彩虹岛 / 憨憨 覆盖层新增 labels 字段(MovieClaw 原定义没有,MP 参照因此为 undefined);样例里没有标签时本项目输出空数组。" } } } diff --git a/packages/testkit/src/sites/fixtures/definitions/index.ts b/packages/testkit/src/sites/fixtures/definitions/index.ts index 46bf0642..844b5200 100644 --- a/packages/testkit/src/sites/fixtures/definitions/index.ts +++ b/packages/testkit/src/sites/fixtures/definitions/index.ts @@ -1,3 +1,4 @@ export * from "./chdbits.ts"; export * from "./hhanclub.ts"; +export * from "./sites-2026.ts"; export * from "./subtitles.ts"; diff --git a/packages/testkit/src/sites/fixtures/definitions/sites-2026.ts b/packages/testkit/src/sites/fixtures/definitions/sites-2026.ts new file mode 100644 index 00000000..70a29369 --- /dev/null +++ b/packages/testkit/src/sites/fixtures/definitions/sites-2026.ts @@ -0,0 +1,70 @@ +/** + * 2026-10-02 按真实账号列表页(OurBits / CHDBits / HHClub 各 1 次只读抓取)的 DOM 结构手写的脱敏样例: + * 标题、id、ID 号与数值均为虚构,只保留会影响解析的结构(嵌套 table.torrentname、标签块、外部 ID 属性)。 + */ + +/** OurBits:标题列嵌套的 table.torrentname 有 5 个 td;副标题在标签 div 之后;IMDb 为纯数字 data 属性 */ +export const OURBITS_LIST_PAGE = `OurBits :: 种子 +退出 + + + + + + + + + + + + + + +
类型标题进度发布者
Movies + + + + + +
 Sample Film 2002 1080p BluRay AVC DTS-HD MA 5.1-GRP@OurBits [免费]
示例电影[简繁双语字幕]
29时37分38.44GB17417334-匿名
`; + +/** CHDBits:font.subtitle 里先是 div.tag 标签,再是副标题,末尾 div.circle 徽标 */ +export const CHDBITS_LIST_PAGE_2026 = `CHDBits :: 种子 +退出 + + + + + + + + + + + + +
类型标题
Movie + + +
Sample Heist 2026 2160p BluRay HEVC DV-GRP@CHDBits Free
 
官方
DIY
中字
 示例劫案 [DIY UHD 原盘] 
h5
05时57分88.67GB12340
`; + +/** HHClub 2026 卡片布局:标签在 torrent-title 里的 span.tag(促销是 promotion-tag) */ +export const HHANCLUB_LIST_PAGE_TAGS = `HHCLUB :: 种子 +退出 +
+
类型
+
+
Sample Show S02 2025 2160p WEB-DL H.265 DDP5.1-HHWEB +
示例剧集 第二季 | 全12集
+
官方中字免费
+
+
+
41.05 GB
+
9时18分钟
+ + + +
+
下载
+
+`; diff --git a/scripts/mp-sync/movieclaw-overrides/chdbits.json b/scripts/mp-sync/movieclaw-overrides/chdbits.json index bc9cd5a6..d580c58c 100644 --- a/scripts/mp-sync/movieclaw-overrides/chdbits.json +++ b/scripts/mp-sync/movieclaw-overrides/chdbits.json @@ -1,5 +1,5 @@ { - "$comment": "CHDBits 分类名取自 MovieClaw chdbits.yaml 注释(TV Series / TV Shows / Sports / Music Videos / HQ Audio)。", + "$comment": "CHDBits 分类名取自 MovieClaw chdbits.yaml 注释(TV Series / TV Shows / Sports / Music Videos / HQ Audio)。 2026-10-02 真实列表页核对:font.subtitle 里先是 div.tag 标签再是副标题,末尾 div.circle 徽标;副标题去掉 div,标签取 div.tag。", "umt": { "categoryNames": { "401": "电影", @@ -12,5 +12,16 @@ "408": "HQ Audio", "409": "其他" } + }, + "torrents": { + "fields": { + "description": { + "selector": "font.subtitle", + "remove": "div" + }, + "labels": { + "selector": "font.subtitle div.tag" + } + } } } diff --git a/scripts/mp-sync/movieclaw-overrides/hhanclub.json b/scripts/mp-sync/movieclaw-overrides/hhanclub.json index fde51b8b..c78d18b6 100644 --- a/scripts/mp-sync/movieclaw-overrides/hhanclub.json +++ b/scripts/mp-sync/movieclaw-overrides/hhanclub.json @@ -1,5 +1,5 @@ { - "$comment": "HHClub 分类名取自 MovieClaw hhanclub.yaml 注释(导航栏分类表);REQ §4.5 要求综艺/体育/短剧保留原始分类。2026-09-29 真实账号核对:列表标题链接里带站点的新种标记「[新]」(详情页标题没有),用 replace 过滤掉;等级「贵宾」为 VIP;访问默认按低频原则收紧(快照只抓第 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1)。", + "$comment": "HHClub 分类名取自 MovieClaw hhanclub.yaml 注释(导航栏分类表);REQ §4.5 要求综艺/体育/短剧保留原始分类。2026-09-29 真实账号核对:列表标题链接里带站点的新种标记「[新]」(详情页标题没有),用 replace 过滤掉;等级「贵宾」为 VIP;访问默认按低频原则收紧(快照只抓第 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1)。 2026-10-02 真实列表页核对:标签在 torrent-title 里的 span.tag(促销是 promotion-tag,不混入)。", "umt": { "categoryNames": { "401": "电影", @@ -48,6 +48,9 @@ "args": ["(新)", ""] } ] + }, + "labels": { + "selector": "div.torrent-title span.tag" } } } diff --git a/scripts/mp-sync/movieclaw-overrides/ourbits.json b/scripts/mp-sync/movieclaw-overrides/ourbits.json new file mode 100644 index 00000000..190d97fe --- /dev/null +++ b/scripts/mp-sync/movieclaw-overrides/ourbits.json @@ -0,0 +1,53 @@ +{ + "$comment": "2026-10-02 真实账号核对列表页 1 次(.local/site-samples/ourbits-torrents.html):标题列嵌套的 table.torrentname 有 5 个 td,MovieClaw 的 td:nth-child(n) 会先命中嵌套格子(大小 0、时间 N/A),改为只匹配行的直接子单元格;副标题在 td.embedded 里标签 div 之后,原定义取到的是标签;标签在 span.tag;列表页有 IMDb / 豆瓣 ID(label.imdb_rate / label.douban_rate 的 data 属性,IMDb 为纯数字,映射时补 tt + 7 位)。", + "torrents": { + "fields": { + "date_elapsed": { + "selector": "> td:nth-child(4)" + }, + "date_added": { + "selector": "> td:nth-child(4) > span", + "attribute": "title" + }, + "size": { + "selector": "> td:nth-child(5)" + }, + "seeders": { + "selector": "> td:nth-child(6)" + }, + "leechers": { + "selector": "> td:nth-child(7)" + }, + "grabs": { + "selector": "> td:nth-child(8)" + }, + "description": { + "selector": "table.torrentname td.embedded", + "remove": "a, b, img, div, br, font" + }, + "labels": { + "selector": "span.tag" + }, + "imdbid": { + "selector": "label.imdb_rate", + "attribute": "data-imdbid", + "filters": [ + { + "name": "re_search", + "args": ["[1-9]\\d*", 0] + } + ] + }, + "doubanid": { + "selector": "label.douban_rate", + "attribute": "data-doubanid", + "filters": [ + { + "name": "re_search", + "args": ["[1-9]\\d*", 0] + } + ] + } + } + } +}