import type { SiteRelease } from "@umt/adapters"; import { createMockRssServer, type MockFeedItem, type MockRssServer } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { ingestFeed } from "../../src/modules/sources-identity/service/feeds.ts"; import { upsertSiteRelease } from "../../src/modules/sources-identity/service/snapshot.ts"; import { listSiteReleases } from "../../src/modules/sources-identity/service/workbench.ts"; import { createHarness, type Harness, pgAvailable, seedFeed, seedSite } from "./harness.ts"; // HH-2:憨憨 RSS 条目规范化(`[分类]种子名[副标题][大小]`)、与列表快照合并、旧行补规范化、RSS 请求计数 const up = await pgAvailable(); const BASE = "https://hhanclub.example"; const item = (id: string, title: string, pubDate: string, extra: Partial = {}): MockFeedItem => ({ guid: `hh-${id}`, title, link: `${BASE}/details.php?id=${id}`, enclosureUrl: `${BASE}/download.php?id=${id}&passkey=PK`, size: 731525231, pubDate: new Date(pubDate), categories: [], ...extra, }); const snapshotRelease = (id: string, title: string, uploadedAt: string): SiteRelease => ({ siteId: "hhanclub", siteReleaseId: id, title, subtitle: "快照副标题", category: { id: "402", name: "电视剧" }, sizeBytes: 1024, seeders: 22, leechers: 66, snatched: 5, promotion: { type: "free", downloadFactor: 0, uploadFactor: 1, expiresAt: null }, hr: false, uploadedAt, detailUrl: `${BASE}/details.php?id=${id}`, tags: [], raw: {}, observedAt: new Date().toISOString(), }); describe.skipIf(!up)("站点 RSS 条目规范化(HH-2)", () => { let h: Harness; let siteId: string; const servers: MockRssServer[] = []; beforeAll(async () => { h = await createHarness("rssnorm"); siteId = (await seedSite(h, { key: "hhanclub", baseUrl: `${BASE}/`, cookie: "c=1" })).site.id; }); afterAll(async () => { for (const s of servers) await s.close(); await h?.close(); }); const feed = async (items: MockFeedItem[]) => { const s = await createMockRssServer({ items, passkey: "PK" }); servers.push(s); return { server: s, row: await seedFeed(h, { siteId, url: s.feedUrl, name: "憨憨 RSS" }) }; }; const rel = (id: string) => h.db .selectFrom("source_release") .selectAll() .where("site_id", "=", siteId) .where("site_release_id", "=", id) .executeTakeFirstOrThrow(); it("拆出分类 / 副标题 / 干净标题;大小取 enclosure 精确值;原始标题留在 raw.rss;请求计入站点计数", async () => { const { row } = await feed([ item( "219390", "[音乐]Travis - L.A. Times (2024) - WEB-DL - 24bit ALAC-HHWEB[Travis - L.A. Times | ALAC | 苹果音乐][697.64 MB]", "2026-09-29T08:51:01Z", ), item("219391", "[纪录片]Some Doc 2026 2160p WEB-DL-HHWEB[新][某纪录片][1.5 GB]", "2026-09-29T08:10:00Z", { size: 0, }), ]); expect((await ingestFeed(h.rt, row.id)).status).toBe("ok"); const r = await rel("219390"); expect(r).toMatchObject({ title: "Travis - L.A. Times (2024) - WEB-DL - 24bit ALAC-HHWEB", subtitle: "Travis - L.A. Times | ALAC | 苹果音乐", category_raw: "410", }); expect(Number(r.size_bytes)).toBe(731525231); expect((r.raw as { rss: Record }).rss).toMatchObject({ categoryName: "音乐", mediaType: "music", rawTitle: expect.stringContaining("[697.64 MB]"), v: 1, }); const doc = await rel("219391"); expect(doc).toMatchObject({ title: "Some Doc 2026 2160p WEB-DL-HHWEB", subtitle: "某纪录片", category_raw: "404" }); expect(Number(doc.size_bytes)).toBe(Math.round(1.5 * 1024 ** 3)); // 计数是异步写的,等一下 let n = 0; for (let i = 0; i < 50 && n === 0; i++) { n = await h.rt.siteRequestCount(siteId); if (!n) await new Promise((res) => setTimeout(res, 20)); } expect(n).toBe(1); }); it("与列表快照按站点资源 ID 合并:快照补做种 / 促销 / H&R,之后 RSS 更新不再覆盖快照标题", async () => { const { server, row } = await feed([ item("219380", "[电视剧]Show S01 2026 1080p WEB-DL-HHWEB[示例剧 | 第1集][1.2 GB]", "2026-09-29T07:00:00Z"), ]); await ingestFeed(h.rt, row.id); await h.db .transaction() .execute((trx) => upsertSiteRelease( h.rt, trx, { id: siteId }, snapshotRelease("219380", "Show S01 2026 1080p WEB-DL-HHWEB", "2026-09-29T07:00:00Z"), ), ); let r = await rel("219380"); expect(r).toMatchObject({ seeders: 22, leechers: 66, subtitle: "快照副标题", category_raw: "402" }); expect((r.raw as { _site: { hr: boolean } })._site.hr).toBe(false); expect((r.raw as { rss: { categoryName: string } }).rss.categoryName).toBe("电视剧"); server.updateItem("hh-219380", { title: "[电视剧]Show S01 2026 1080p WEB-DL-HHWEB[示例剧 | 第1-2集][2.4 GB]" }); await ingestFeed(h.rt, row.id, { manual: true }); r = await rel("219380"); expect(r.title).toBe("Show S01 2026 1080p WEB-DL-HHWEB"); expect(r.seeders).toBe(22); }); it("规范化前写进来的旧 RSS 行:下次抓取时按原标题补规范化并重新触发识别", async () => { const dirty = "[音乐]Old Album (2020) - WEB-DL - 16bit ALAC-HHWEB[Old Album | ALAC][120.5 MB]"; const old = await h.db .insertInto("source_release") .values({ site_id: siteId, site_release_id: "219001", title: dirty, size_bytes: 126353408, raw: JSON.stringify({ rss: { link: `${BASE}/details.php?id=219001`, categories: [] } }), // 旧行:固定首见时间,别让"现在"影响下一个用例的发布时间排序(没有 source_published_at 时按首见时间排) first_seen_at: new Date("2026-09-28T00:00:00Z"), }) .returning(["id"]) .executeTakeFirstOrThrow(); const { row } = await feed([ item("219002", "[电影]New Movie 2026 1080p-HHWEB[新片][4 GB]", "2026-09-29T09:00:00Z"), ]); await ingestFeed(h.rt, row.id); const r = await rel("219001"); expect(r).toMatchObject({ title: "Old Album (2020) - WEB-DL - 16bit ALAC-HHWEB", subtitle: "Old Album | ALAC", category_raw: "410", }); expect((r.raw as { rss: { rawTitle: string } }).rss.rawTitle).toBe(dirty); const events = (await h.outbox("release.observed")).filter((e) => e.payload.sourceReleaseId === old.id); expect(events.map((e) => e.payload.isNew)).toEqual([false]); }); it("资源列表按发布时间排序:缺做种 / H&R 的 RSS 条目不会因此排到前面", async () => { await h.db .transaction() .execute((trx) => upsertSiteRelease( h.rt, trx, { id: siteId }, snapshotRelease("219500", "Newest Snapshot 2026-HHWEB", "2026-09-29T10:00:00Z"), ), ); const list = await listSiteReleases(h.rt, siteId, { limit: 50, live: "false" }); const ids = list.items.map((i) => i.siteReleaseId); expect(ids[0]).toBe("219500"); expect(ids.indexOf("219390")).toBeLessThan(ids.indexOf("219391")); const times = list.items.map((i) => Date.parse(i.sourcePublishedAt ?? i.firstSeenAt)); expect([...times].sort((a, b) => b - a)).toEqual(times); }); });