fix(sites): HHClub parsing, resource list layout and request restraint (re-landed)

Re-lands 901b01d, which was reverted wholesale by 28974d1 (BR-2 committed
from the shared index). The agent UI files 901b01d had rolled back are
left at their AGUI-2 versions; contracts, routes, db types and the runbook
part already reached main through later commits, so this carries the rest.

HHClub (憨憨) was checked read-only against a real account (one detail
request; list, user data and health came from the product's own fetch).

Parsing fixes:
- list titles carried the site's new-torrent marker "[新]" (58 of 100
  rows; the detail page title has none); stripped with a replace filter
- refreshing a detail overwrote seeders / leechers / snatched / category
  name / H&R with null because the HHClub div detail page lacks them;
  detail values now fall back to the list snapshot (mergeDetailOverList),
  and an unknown detail promotion keeps the list promotion
- level "贵宾" is VIP (umt.vipLevels)
- sanitized minimal fixture HHANCLUB_LIST_PAGE_2026 + tests; README marks
  HHClub as verified 2026-09-29
Size, counts, category ids/names, +08:00 time and subtitle were correct.

Request restraint:
- site definitions may carry umt.defaults; merged with settings.sites
  (longer intervals, fewer pages): new settings defaultSnapshotMaxPages 1,
  defaultHealthCheckIntervalSec 6 h, defaultDetailCacheSec 24 h; default
  burst 2. HHClub: snapshot 60 min page 1 only, health 6 h, search
  concurrency 1, detail cache 24 h, 1 token / 3 s burst 2
- periodic / manual snapshots are capped to that page count; scheduled
  health checks skip sites checked within the interval; detail refresh is
  served from cache inside the window; live searches on one site queue
- every rate-limiter token is counted per site per hour
  (site_request_count, migration 0046); GET /sites/{id}/request-stats;
  site overview and resource list show requests in the last 24 hours

Resource list: two-layer rows. Main line: title + spec tags (episodes,
resolution, source, codec, HDR, group, 国语/中字, site tags) + promotion
with time left + H&R. Second line: subtitle (one line, ellipsis) and
fixed-width category / size / seed-leech-snatched / upload time
(relative, absolute on hover) columns, stale badge, inventory overlay;
folds into a card on phones.

Also aligns identity_candidate.normalized_title order in db types with
the migrations (schema drift test).

Takes effect after the stack is rebuilt (migration 0046).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 16:00:34 +08:00
1 parent 99e335d13c
commit 6e9e8efec2
22 files changed
+753 -76

No files matched your search

@@ -14,7 +14,7 @@ import {
type TmdbSharedCache, type TmdbSharedCache,
} from "@umt/adapters"; } from "@umt/adapters";
import { DEFAULT_SETTINGS, type SitesSettings } from "@umt/contracts"; import { DEFAULT_SETTINGS, type SitesSettings } from "@umt/contracts";
import { pgTokenBucketFor, type SiteRow } from "@umt/db"; import { pgTokenBucketFor, type SiteRow, sql } from "@umt/db";
import { CACHE_REGIONS, sharedCache, type TmdbCachePolicy, tmdbCachePolicy } from "../../shared-cache.ts"; import { CACHE_REGIONS, sharedCache, type TmdbCachePolicy, tmdbCachePolicy } from "../../shared-cache.ts";
import type { ServerContext } from "../types.ts"; import type { ServerContext } from "../types.ts";
@@ -170,7 +170,7 @@ export class SourcesRuntime {
if (hit && hit.sig === sig) return hit.adapter; if (hit && hit.sig === sig) return hit.adapter;
const lo = limiterOptions(def, site.rate_limit, defaults.defaultRateLimit); const lo = limiterOptions(def, site.rate_limit, defaults.defaultRateLimit);
// 跨进程:api / worker / worker:agent 对同一站点共用 PG 令牌桶(key = site:<id>);进程内先串行排队 // 跨进程:api / worker / worker:agent 对同一站点共用 PG 令牌桶(key = site:<id>);进程内先串行排队
const limiter = const inner =
this.overrides.sharedRateLimit === false this.overrides.sharedRateLimit === false
? createSiteRateLimiter(lo) ? createSiteRateLimiter(lo)
: createSharedSiteRateLimiter({ : createSharedSiteRateLimiter({
@@ -183,6 +183,7 @@ export class SourcesRuntime {
"站点共享限流不可用,本次退回进程内限流", "站点共享限流不可用,本次退回进程内限流",
), ),
}); });
const limiter = countingLimiter(inner, () => this.countSiteRequest(site.id));
const cfg = (site.config ?? {}) as { apiBaseUrl?: string }; const cfg = (site.config ?? {}) as { apiBaseUrl?: string };
const baseUrl = const baseUrl =
def.umt.kind === "mteam" ? cfg.apiBaseUrl : sameUrl(site.base_url, def.domain) ? undefined : site.base_url; def.umt.kind === "mteam" ? cfg.apiBaseUrl : sameUrl(site.base_url, def.domain) ? undefined : site.base_url;
@@ -197,6 +198,41 @@ export class SourcesRuntime {
return adapter; return adapter;
} }
/**
* 站点请求计数(site_request_count,按小时聚合):每拿到一个令牌记一次。写失败只记日志,不影响请求;
* 顺手删 7 天前的行(每进程每小时最多一次)。
*/
private lastCountPrune = 0;
countSiteRequest(siteId: string): void {
const hour = new Date(Math.floor(this.now().getTime() / 3_600_000) * 3_600_000);
const db = this.ctx.db;
void sql`insert into site_request_count (site_id, hour, count) values (${siteId}, ${hour}, 1)
on conflict (site_id, hour) do update set count = site_request_count.count + 1`
.execute(db)
.then(async () => {
if (Date.now() - this.lastCountPrune < 3_600_000) return;
this.lastCountPrune = Date.now();
await sql`delete from site_request_count where hour < ${new Date(hour.getTime() - 7 * 86_400_000)}`.execute(db);
})
.catch((err) =>
this.ctx.logger.warn({ siteId, err: { message: (err as Error).message } }, "站点请求计数写入失败"),
);
}
/** 最近 hours 小时(含当前小时)该站点的请求次数 */
async siteRequestCount(siteId: string, hours = 24): Promise<number> {
const hour = Math.floor(this.now().getTime() / 3_600_000) * 3_600_000;
const since = new Date(hour - (hours - 1) * 3_600_000);
const r = await sql<{ n: number }>`select coalesce(sum(count), 0)::int as n from site_request_count
where site_id = ${siteId} and hour >= ${since}`.execute(this.ctx.db);
return Number(r.rows[0]?.n ?? 0);
}
/** 站点访问默认:站点定义 umt.defaults 与 settings.sites 取更保守的一方 */
async siteAccess(siteKey: string): Promise<SiteAccess> {
return siteAccessFor(getSiteDefinition(siteKey) ?? null, await this.siteDefaults());
}
/** 已建立的站点限流器(测试与诊断用) */ /** 已建立的站点限流器(测试与诊断用) */
limiterOf(siteId: string): SiteRateLimiter | null { limiterOf(siteId: string): SiteRateLimiter | null {
return this.adapters.get(siteId)?.limiter ?? null; return this.adapters.get(siteId)?.limiter ?? null;
@@ -260,6 +296,63 @@ function sameUrl(a: string, b: string): boolean {
return a.replace(/\/+$/, "") === b.replace(/\/+$/, ""); return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
} }
/** 站点访问默认(已合并定义与设置) */
export interface SiteAccess {
/** 站点行没设快照周期时用 */
snapshotIntervalSec: number;
snapshotMaxPages: number;
healthCheckIntervalSec: number;
detailCacheSec: number;
searchConcurrency: number;
}
type DefinitionDefaults = {
snapshotIntervalSec?: number;
snapshotMaxPages?: number;
healthCheckIntervalSec?: number;
detailCacheSec?: number;
searchConcurrency?: number;
};
/** 周期 / 缓存取较长,页数 / 并发取较小(定义值是按站点实测给的保守下限) */
export function siteAccessFor(def: SiteDefinition | null, s: SitesSettings): SiteAccess {
const d = ((def?.umt as { defaults?: DefinitionDefaults } | undefined)?.defaults ?? {}) as DefinitionDefaults;
const max = (a: number | undefined, b: number) => (a === undefined ? b : Math.max(a, b));
const min = (a: number | undefined, b: number) => (a === undefined ? b : Math.min(a, b));
return {
snapshotIntervalSec: max(d.snapshotIntervalSec, s.defaultSnapshotIntervalSec),
snapshotMaxPages: min(d.snapshotMaxPages, s.defaultSnapshotMaxPages),
healthCheckIntervalSec: max(d.healthCheckIntervalSec, s.defaultHealthCheckIntervalSec),
detailCacheSec: max(d.detailCacheSec, s.defaultDetailCacheSec),
searchConcurrency: d.searchConcurrency ?? 1,
};
}
/** 同站实时搜索并发(只看站点定义,同步取值;缺省 1) */
export function siteSearchConcurrency(siteKey: string): number {
const def = getSiteDefinition(siteKey);
return ((def?.umt as { defaults?: DefinitionDefaults } | undefined)?.defaults?.searchConcurrency ?? 1) as number;
}
/** 包一层限流器:每发放一个令牌回调一次(计数) */
function countingLimiter(inner: SiteRateLimiter, onGrant: () => void): SiteRateLimiter {
return {
get stats() {
return inner.stats;
},
async acquire(signal) {
await inner.acquire(signal);
onGrant();
},
tryAcquire() {
const ok = inner.tryAcquire();
if (ok) onGrant();
return ok;
},
penalize: (ms) => inner.penalize(ms),
};
}
type RateLimitLike = { requestsPerMinute?: number; minIntervalMs?: number; concurrency?: number }; type RateLimitLike = { requestsPerMinute?: number; minIntervalMs?: number; concurrency?: number };
/** /**
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { getSiteDefinition } from "@umt/adapters";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import type { SourcesRuntime } from "./runtime.ts"; import { type SourcesRuntime, siteAccessFor } from "./runtime.ts";
import { archiveStalePool } from "./service/identity-decisions.ts"; import { archiveStalePool } from "./service/identity-decisions.ts";
// worker 后台调度:按 Feed 轮询策略发 feed.due(outbox → ingestFeed)、按站点快照周期入队 snapshotSite、 // worker 后台调度:按 Feed 轮询策略发 feed.due(outbox → ingestFeed)、按站点快照周期入队 snapshotSite、
@@ -67,24 +68,32 @@ export async function tickFeeds(rt: SourcesRuntime): Promise<number> {
export async function tickSnapshots(rt: SourcesRuntime): Promise<number> { export async function tickSnapshots(rt: SourcesRuntime): Promise<number> {
const { ctx } = rt; const { ctx } = rt;
const now = rt.now(); const now = rt.now();
// 站点未设周期时用全局默认(settings.sites.defaultSnapshotIntervalSec) // 站点未设周期时用站点定义与全局默认里更长的一个(settings.sites.defaultSnapshotIntervalSec / umt.defaults)
const fallback = (await rt.siteDefaults()).defaultSnapshotIntervalSec; const defaults = await rt.siteDefaults();
const sites = await sql<{ id: string; snapshot_interval_sec: number }>` const rows = await ctx.db
select id, coalesce(snapshot_interval_sec, ${fallback}::int) as snapshot_interval_sec from site .selectFrom("site")
where enabled and snapshot_enabled and deleted_at is null .select(["id", "key", "snapshot_interval_sec", "last_snapshot_at"])
and (last_snapshot_at is null .where("enabled", "=", true)
or last_snapshot_at + make_interval(secs => coalesce(snapshot_interval_sec, ${fallback}::int)) <= ${now})`.execute( .where("snapshot_enabled", "=", true)
ctx.db, .where("deleted_at", "is", null)
); .execute();
for (const s of sites.rows) { const due: Array<{ id: string; interval: number }> = [];
const slot = Math.floor(now.getTime() / 1000 / s.snapshot_interval_sec); for (const r of rows) {
const interval =
r.snapshot_interval_sec ?? siteAccessFor(getSiteDefinition(r.key) ?? null, defaults).snapshotIntervalSec;
if (!r.last_snapshot_at || r.last_snapshot_at.getTime() + interval * 1000 <= now.getTime()) {
due.push({ id: r.id, interval });
}
}
for (const s of due) {
const slot = Math.floor(now.getTime() / 1000 / s.interval);
await ctx.engine.enqueue( await ctx.engine.enqueue(
"snapshotSite", "snapshotSite",
{ siteId: s.id, kind: "periodic" }, { siteId: s.id, kind: "periodic" },
{ idempotencyKey: `snapshotSite:${s.id}:${slot}`, traceId: randomUUID() }, { idempotencyKey: `snapshotSite:${s.id}:${slot}`, traceId: randomUUID() },
); );
} }
return sites.rows.length; return due.length;
} }
/** /**
@@ -901,7 +901,7 @@ export async function tickSiteUserData(rt: SourcesRuntime, slot: string): Promis
return rows.length; return rows.length;
} }
/** sites.healthCheck:启用站点逐个检测(同步,走站点限流) */ /** sites.healthCheck:启用站点逐个检测(同步,走站点限流);按站点访问默认的间隔跳过刚检测过的站点 */
export async function tickSiteHealth(rt: SourcesRuntime): Promise<number> { export async function tickSiteHealth(rt: SourcesRuntime): Promise<number> {
const sites = await rt.ctx.db const sites = await rt.ctx.db
.selectFrom("site") .selectFrom("site")
@@ -911,7 +911,11 @@ export async function tickSiteHealth(rt: SourcesRuntime): Promise<number> {
.where("adapter", "!=", "generic_rss") .where("adapter", "!=", "generic_rss")
.execute(); .execute();
let n = 0; let n = 0;
const now = rt.now().getTime();
for (const s of sites) { for (const s of sites) {
// 站点访问默认的最短间隔内检测过(手动或定时)就跳过;留 2 分钟余量,免得和定时任务周期错开一轮
const { healthCheckIntervalSec } = await rt.siteAccess(s.key);
if (s.last_health_at && now - s.last_health_at.getTime() < healthCheckIntervalSec * 1000 - 120_000) continue;
try { try {
await checkSiteHealth(rt, s); await checkSiteHealth(rt, s);
n++; n++;
@@ -119,7 +119,11 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr
return { status: "skipped", reason: (e as Error).message, ...none }; return { status: "skipped", reason: (e as Error).message, ...none };
} }
const cfgPages = Number((site.config as { snapshotPages?: number })?.snapshotPages) || 1; const cfgPages = Number((site.config as { snapshotPages?: number })?.snapshotPages) || 1;
const maxPages = Math.min(20, Math.max(1, input.maxPages ?? cfgPages)); let maxPages = Math.min(20, Math.max(1, input.maxPages ?? cfgPages));
// 定时 / 手动快照受站点访问默认的页数上限约束(补漏有自己的页数预算,实时补查只抓 1 页)
if (input.kind === "periodic" || input.kind === "manual") {
maxPages = Math.min(maxPages, (await rt.siteAccess(site.key)).snapshotMaxPages);
}
const snap = await db const snap = await db
.insertInto("site_snapshot") .insertInto("site_snapshot")
.values({ .values({
@@ -211,9 +215,14 @@ export async function refreshReleaseDetail(rt: SourcesRuntime, srId: string): Pr
if (!rel) return "not_found"; if (!rel) return "not_found";
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst(); const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
if (!site?.enabled) return "site_disabled"; if (!site?.enabled) return "site_disabled";
// 详情缓存:站点访问默认的缓存期内抓过就不再请求站点(列表快照会持续更新做种 / 促销)
const { detailCacheSec } = await rt.siteAccess(site.key);
if (rel.detail_fetched_at && rt.now().getTime() - rel.detail_fetched_at.getTime() < detailCacheSec * 1000) {
return null;
}
let detail: SiteRelease; let detail: SiteRelease;
try { try {
detail = await rt.siteAdapter(site).detail(rel.site_release_id); detail = mergeDetailOverList(await rt.siteAdapter(site).detail(rel.site_release_id), rel);
} catch (e) { } catch (e) {
if (e instanceof SiteError) { if (e instanceof SiteError) {
if (e.isAuthError) await db.transaction().execute((trx) => onAuthError(rt, trx, site, e)); if (e.isAuthError) await db.transaction().execute((trx) => onAuthError(rt, trx, site, e));
@@ -226,7 +235,47 @@ export async function refreshReleaseDetail(rt: SourcesRuntime, srId: string): Pr
} }
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
await upsertSiteRelease(rt, trx, site, detail); await upsertSiteRelease(rt, trx, site, detail);
await trx.updateTable("source_release").set({ detail_fetched_at: rt.now() }).where("id", "=", srId).execute(); const keepPromotion = detail.promotion.type === "unknown";
await trx
.updateTable("source_release")
.set({
detail_fetched_at: rt.now(),
// 详情页没解析出促销时保留列表快照的促销,不用"未知"覆盖
...(keepPromotion
? { promotion: jsonb(rel.promotion ?? {}), promotion_expires_at: rel.promotion_expires_at }
: {}),
})
.where("id", "=", srId)
.execute();
}); });
return null; return null;
} }
/**
* 详情页缺的字段沿用列表快照已有的值:有些站点(如 HHClub 的 div 详情页)详情里没有做种 / 完成数 / 分类名,
* upsert 时 seeders / leechers 与 raw._site 会被整体覆盖,不能用 null 冲掉列表数据。
*/
export function mergeDetailOverList(
detail: SiteRelease,
rel: {
seeders: number | null;
leechers: number | null;
category_raw: string | null;
tags_raw: string[] | null;
raw: unknown;
},
): SiteRelease {
const prev = ((rel.raw ?? {}) as { _site?: Record<string, unknown> })._site ?? {};
return {
...detail,
seeders: detail.seeders ?? rel.seeders,
leechers: detail.leechers ?? rel.leechers,
snatched: detail.snatched ?? (typeof prev.snatched === "number" ? prev.snatched : null),
category: {
id: detail.category.id ?? rel.category_raw,
name: detail.category.name ?? (typeof prev.categoryName === "string" ? prev.categoryName : null),
},
hr: detail.hr === "unknown" && typeof prev.hr === "boolean" ? prev.hr : detail.hr,
tags: detail.tags.length ? detail.tags : (rel.tags_raw ?? []),
};
}
@@ -9,7 +9,7 @@ import { type SiteRow, sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain"; import { normalizeTitle } from "@umt/domain";
import { ApiException } from "../../../http/errors.ts"; import { ApiException } from "../../../http/errors.ts";
import { decodeCursor, page, toApi } from "../rows.ts"; import { decodeCursor, page, toApi } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts"; import { type SourcesRuntime, siteSearchConcurrency } from "../runtime.ts";
import { refreshReleaseDetail, snapshotSite } from "./snapshot.ts"; import { refreshReleaseDetail, snapshotSite } from "./snapshot.ts";
// 站点工作台(整理视图):本地快照优先;本地无结果 / 快照过期 / 用户要求时对已开启站点实时补查, // 站点工作台(整理视图):本地快照优先;本地无结果 / 快照过期 / 用户要求时对已开启站点实时补查,
@@ -20,13 +20,15 @@ const LIVE_FAIL_BACKOFF_MS = 60_000;
interface LiveState { interface LiveState {
inflight: Map<string, Promise<{ error: string | null }>>; inflight: Map<string, Promise<{ error: string | null }>>;
failedUntil: Map<string, { until: number; error: string }>; failedUntil: Map<string, { until: number; error: string }>;
/** 按站点串行的实时搜索(站点访问默认 searchConcurrency = 1 时) */
siteChain: Map<string, Promise<unknown>>;
} }
const liveStates = new WeakMap<SourcesRuntime, LiveState>(); const liveStates = new WeakMap<SourcesRuntime, LiveState>();
function liveState(rt: SourcesRuntime): LiveState { function liveState(rt: SourcesRuntime): LiveState {
let s = liveStates.get(rt); let s = liveStates.get(rt);
if (!s) { if (!s) {
s = { inflight: new Map(), failedUntil: new Map() }; s = { inflight: new Map(), failedUntil: new Map(), siteChain: new Map() };
liveStates.set(rt, s); liveStates.set(rt, s);
} }
return s; return s;
@@ -44,14 +46,22 @@ export async function liveSearch(
if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false }; if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false };
const running = st.inflight.get(key); const running = st.inflight.get(key);
if (running) return { ...(await running), coalesced: true }; if (running) return { ...(await running), coalesced: true };
const searchConcurrency = siteSearchConcurrency(site.key);
const prev = searchConcurrency <= 1 ? st.siteChain.get(site.id) : undefined;
const p = (async () => { const p = (async () => {
// 同站不同关键词的实时搜索排队执行(不并发打站点)
if (prev) await prev.catch(() => {});
const r = await snapshotSite(rt, { siteId: site.id, kind: "live_search", query: q, maxPages: 1 }); const r = await snapshotSite(rt, { siteId: site.id, kind: "live_search", query: q, maxPages: 1 });
const error = r.status === "failed" || r.status === "skipped" ? (r.error ?? r.reason ?? "failed") : null; const error = r.status === "failed" || r.status === "skipped" ? (r.error ?? r.reason ?? "failed") : null;
if (error) st.failedUntil.set(key, { until: rt.now().getTime() + LIVE_FAIL_BACKOFF_MS, error }); if (error) st.failedUntil.set(key, { until: rt.now().getTime() + LIVE_FAIL_BACKOFF_MS, error });
else st.failedUntil.delete(key); else st.failedUntil.delete(key);
return { error }; return { error };
})().finally(() => st.inflight.delete(key)); })().finally(() => {
st.inflight.delete(key);
if (st.siteChain.get(site.id) === p) st.siteChain.delete(site.id);
});
st.inflight.set(key, p); st.inflight.set(key, p);
if (searchConcurrency <= 1) st.siteChain.set(site.id, p);
return { ...(await p), coalesced: false }; return { ...(await p), coalesced: false };
} }
@@ -175,6 +175,15 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
.where("key", "=", `site:${siteId}`) .where("key", "=", `site:${siteId}`)
.executeTakeFirst(); .executeTakeFirst();
expect(bucket).toMatchObject({ capacity: 5, refill_per_sec: 200 }); expect(bucket).toMatchObject({ capacity: 5, refill_per_sec: 200 });
// 手动快照要 5 页也只抓访问默认的页数上限(settings.sites.defaultSnapshotMaxPages 缺省 1)
expect((await snapshotSite(h.rt, { siteId, kind: "manual", maxPages: 5 })).pages).toEqual([1]);
// 每拿到一个令牌计一次请求,按小时聚合;站点页读最近 24 小时
await new Promise((r) => setTimeout(r, 50));
const stats = await json(await h.req(`/sites/${siteId}/request-stats`));
expect(stats.status).toBe(200);
expect(stats.body.data.last24h).toBeGreaterThanOrEqual(4);
expect(stats.body.data.hourly).toHaveLength(24);
expect(stats.body.data.access).toMatchObject({ snapshotMaxPages: 1, healthCheckIntervalSec: 21600 });
}); });
it("工作台列表:快照优先,标注来源与更新时间;筛选与分页", async () => { it("工作台列表:快照优先,标注来源与更新时间;筛选与分页", async () => {
@@ -219,6 +228,13 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
expect(d.body.data.refreshError).toBeNull(); expect(d.body.data.refreshError).toBeNull();
expect(d.body.data.volatile.recheckBeforeDownload).toBe(true); expect(d.body.data.volatile.recheckBeforeDownload).toBe(true);
expect(d.body.data.volatile.detailFetchedAt).not.toBeNull(); expect(d.body.data.volatile.detailFetchedAt).not.toBeNull();
// 详情缓存期内(settings.sites.defaultDetailCacheSec 缺省 24 小时)再次刷新不请求站点
const before = mock.requests.length;
const cached = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
expect(cached.body.data.refreshError).toBeNull();
expect(mock.requests.length).toBe(before);
// 缓存过期后才重新抓
await h.db.updateTable("source_release").set({ detail_fetched_at: null }).where("id", "=", rel.id).execute();
mock.setLoggedIn(false); mock.setLoggedIn(false);
const d2 = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`)); const d2 = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
expect(d2.status).toBe(200); expect(d2.status).toBe(200);
@@ -0,0 +1,48 @@
import type { SiteRelease } from "@umt/adapters";
import { describe, expect, it } from "vitest";
import { mergeDetailOverList } from "../../src/modules/sources-identity/service/snapshot.ts";
// HHClub 2026-09-29 实测:div 详情页没有做种 / 完成数 / 分类名,刷新详情后这些列被 null 冲掉。
describe("详情刷新沿用列表字段", () => {
const detail: SiteRelease = {
siteId: "hhanclub",
siteReleaseId: "91001",
title: "示例短剧 1080p WEB-DL H265 AAC-HHWEB",
category: { id: "412", name: null },
sizeBytes: 194385019,
seeders: null,
leechers: null,
snatched: null,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
hr: "unknown",
uploadedAt: "2026-09-29T06:07:03.000Z",
detailUrl: "https://hhanclub.net/details.php?id=91001",
downloadUrl: "https://hhanclub.net/download.php?id=91001",
tags: [],
raw: {},
observedAt: "2026-09-29T06:19:28.000Z",
};
const rel = {
seeders: 1,
leechers: 58,
category_raw: "412",
tags_raw: ["官方"],
raw: { _site: { hr: false, snatched: 0, categoryName: "短剧" } },
};
it("详情缺的做种 / 下载 / 完成 / 分类名 / H&R / 标签取列表快照值", () => {
expect(mergeDetailOverList(detail, rel)).toMatchObject({
seeders: 1,
leechers: 58,
snatched: 0,
category: { id: "412", name: "短剧" },
hr: false,
tags: ["官方"],
});
});
it("详情有值时以详情为准", () => {
const m = mergeDetailOverList({ ...detail, seeders: 9, hr: true, category: { id: "402", name: "电视剧" } }, rel);
expect(m).toMatchObject({ seeders: 9, hr: true, category: { id: "402", name: "电视剧" } });
});
});
+24
View File
@@ -12,6 +12,7 @@ import {
SiteHealthResult, SiteHealthResult,
SiteListItem, SiteListItem,
SiteRefreshLoginAccepted, SiteRefreshLoginAccepted,
SiteRequestStats,
SiteSnapshot, SiteSnapshot,
type SiteUpdate, type SiteUpdate,
SiteUserDataPoint, SiteUserDataPoint,
@@ -33,6 +34,7 @@ export const siteKeys = {
one: (id: string) => ["sites", "one", id] as const, one: (id: string) => ["sites", "one", id] as const,
accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const, accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const,
snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const, snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const,
requestStats: (siteId: string) => ["sites", "request-stats", siteId] as const,
definitions: () => ["sites", "definitions"] as const, definitions: () => ["sites", "definitions"] as const,
userData: (siteId: string, accountId?: string, days = 30) => userData: (siteId: string, accountId?: string, days = 30) =>
["sites", "user-data", siteId, accountId ?? "first", days] as const, ["sites", "user-data", siteId, accountId ?? "first", days] as const,
@@ -88,6 +90,28 @@ export function useSiteSnapshots(siteId: string | undefined, limit = 20) {
}); });
} }
/** 最近 24 小时对该站的请求次数与生效的访问默认(每分钟刷新) */
export function useSiteRequestStats(siteId: string | undefined) {
return useQuery({
queryKey: siteKeys.requestStats(siteId ?? ""),
enabled: !!siteId,
refetchInterval: 60 * TTL_SECOND,
queryFn: ({ signal }) =>
apiParsed(SiteRequestStats, "/api/v1/sites/:id/request-stats", { pathParams: { id: siteId ?? "" }, signal }),
});
}
/** 访问默认的一句话说明:"快照每 60 分钟 1 页 · 健康检测每 6 小时 · 详情缓存 24 小时" */
export function accessSummary(s: SiteRequestStats): string {
const dur = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600} 小时` : `${Math.round(sec / 60)} 分钟`);
const parts = [
`快照每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`,
`健康检测每 ${dur(s.access.healthCheckIntervalSec)}`,
];
if (s.access.detailCacheSec > 0) parts.push(`详情缓存 ${dur(s.access.detailCacheSec)}`);
return parts.join(" · ");
}
/** 启用 / 停用站点 */ /** 启用 / 停用站点 */
export function useToggleSite() { export function useToggleSite() {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -27,6 +27,24 @@ const DEFAULTS_CARD: CardSpec = {
kind: "number", kind: "number",
hint: "站点开了定时快照但没设周期时使用;5–10080", hint: "站点开了定时快照但没设周期时使用;5–10080",
}, },
{
name: "sites.defaultSnapshotMaxPages",
label: "快照页数",
kind: "number",
hint: "定时和手动快照最多抓几页列表;站点定义更少时按站点;1–20",
},
{
name: "sites.defaultHealthCheckIntervalSec",
label: "健康检测间隔(小时)",
kind: "number",
hint: "同一站点两次定时检测至少隔这么久;站点定义更长时按站点",
},
{
name: "sites.defaultDetailCacheSec",
label: "详情缓存(小时)",
kind: "number",
hint: "期内不重复抓同一条资源详情;0 为不缓存",
},
{ {
name: "sites.userDataMaxSeedingPages", name: "sites.userDataMaxSeedingPages",
label: "做种列表页数上限", label: "做种列表页数上限",
@@ -54,6 +72,9 @@ const DEFAULTS_CARD: CardSpec = {
"sites.defaultRateLimit.concurrency": n(s.sites.defaultRateLimit.concurrency), "sites.defaultRateLimit.concurrency": n(s.sites.defaultRateLimit.concurrency),
"sites.defaultSnapshotIntervalSec": n(Math.round(s.sites.defaultSnapshotIntervalSec / 60)), "sites.defaultSnapshotIntervalSec": n(Math.round(s.sites.defaultSnapshotIntervalSec / 60)),
"sites.userDataMaxSeedingPages": n(s.sites.userDataMaxSeedingPages), "sites.userDataMaxSeedingPages": n(s.sites.userDataMaxSeedingPages),
"sites.defaultSnapshotMaxPages": n(s.sites.defaultSnapshotMaxPages),
"sites.defaultHealthCheckIntervalSec": n(s.sites.defaultHealthCheckIntervalSec / 3600),
"sites.defaultDetailCacheSec": n(s.sites.defaultDetailCacheSec / 3600),
"sites.userAgent": s.sites.userAgent, "sites.userAgent": s.sites.userAgent,
"sites.proxyUrl": s.sites.proxyUrl ?? "", "sites.proxyUrl": s.sites.proxyUrl ?? "",
}), }),
@@ -61,6 +82,8 @@ const DEFAULTS_CARD: CardSpec = {
const min = num(v["sites.defaultSnapshotIntervalSec"]); const min = num(v["sites.defaultSnapshotIntervalSec"]);
const rpm = num(v["sites.defaultRateLimit.requestsPerMinute"]); const rpm = num(v["sites.defaultRateLimit.requestsPerMinute"]);
const concurrency = num(v["sites.defaultRateLimit.concurrency"]); const concurrency = num(v["sites.defaultRateLimit.concurrency"]);
const healthH = num(v["sites.defaultHealthCheckIntervalSec"]);
const detailH = num(v["sites.defaultDetailCacheSec"]);
return { return {
sites: { sites: {
defaultRateLimit: { defaultRateLimit: {
@@ -70,6 +93,9 @@ const DEFAULTS_CARD: CardSpec = {
}, },
defaultSnapshotIntervalSec: min === undefined ? undefined : Math.round(min * 60), defaultSnapshotIntervalSec: min === undefined ? undefined : Math.round(min * 60),
userDataMaxSeedingPages: num(v["sites.userDataMaxSeedingPages"]), userDataMaxSeedingPages: num(v["sites.userDataMaxSeedingPages"]),
defaultSnapshotMaxPages: num(v["sites.defaultSnapshotMaxPages"]),
defaultHealthCheckIntervalSec: healthH === undefined ? undefined : Math.round(healthH * 3600),
defaultDetailCacheSec: detailH === undefined ? undefined : Math.round(detailH * 3600),
userAgent: str(v["sites.userAgent"]) || undefined, userAgent: str(v["sites.userAgent"]) || undefined,
proxyUrl: str(v["sites.proxyUrl"]) || null, proxyUrl: str(v["sites.proxyUrl"]) || null,
}, },
@@ -1,5 +1,5 @@
import type { SiteListItem } from "@umt/contracts"; import type { SiteListItem } from "@umt/contracts";
import { useSiteUserData } from "~/api/sites.ts"; import { accessSummary, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx"; import { KvList } from "~/components/console/kv-list.tsx";
@@ -26,6 +26,7 @@ export function SiteOverview({ site }: { site: SiteListItem }) {
const acc = primaryAccount(site.accounts); const acc = primaryAccount(site.accounts);
const u = dAcc?.userData ?? null; const u = dAcc?.userData ?? null;
const history = useSiteUserData(site.id, dAcc?.id, 30); const history = useSiteUserData(site.id, dAcc?.id, 30);
const stats = useSiteRequestStats(site.id);
const st = ACCOUNT_OVERALL[overallStatus(site)]; const st = ACCOUNT_OVERALL[overallStatus(site)];
const h = healthLine(site.lastHealth, site.lastHealthAt); const h = healthLine(site.lastHealth, site.lastHealthAt);
@@ -104,6 +105,17 @@ export function SiteOverview({ site }: { site: SiteListItem }) {
: "未开启", : "未开启",
}, },
{ label: "限流", value: rateLimitText(site.rateLimit) }, { label: "限流", value: rateLimitText(site.rateLimit) },
{
label: "近 24 小时请求",
value: stats.data ? (
<span>
{stats.data.last24h} 次
<span className="block text-meta text-muted-foreground">{accessSummary(stats.data)}</span>
</span>
) : (
"—"
),
},
{ label: "代理", value: site.proxy ? "使用" : "不使用" }, { label: "代理", value: site.proxy ? "使用" : "不使用" },
]} ]}
/> />
@@ -41,6 +41,97 @@ export function promotionLabel(r: Pick<SourceRelease, "promotion" | "promotionEx
export const isFree = (r: Pick<SourceRelease, "promotion">) => export const isFree = (r: Pick<SourceRelease, "promotion">) =>
r.promotion.kind === "free" || r.promotion.kind === "2xfree"; r.promotion.kind === "free" || r.promotion.kind === "2xfree";
/** 完成数(站点 raw._site.snatched);没有时 null */
export function snatchedOf(r: Pick<SourceRelease, "raw">): number | null {
const v = siteExtra(r).snatched;
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
/** 促销剩余时间:"剩 3 小时";没有截止时间或已过期返回 null */
export function remainingLabel(iso: string | null | undefined, now = Date.now()): string | null {
if (!iso) return null;
const left = new Date(iso).getTime() - now;
if (!Number.isFinite(left) || left <= 0) return null;
if (left < 3_600_000) return `剩 ${Math.max(1, Math.round(left / 60_000))} 分钟`;
if (left < 2 * 86_400_000) return `剩 ${Math.round(left / 3_600_000)} 小时`;
return `剩 ${Math.round(left / 86_400_000)} 天`;
}
/** 绝对时间(本地时区,精确到分钟),用在悬停提示 */
export function absTime(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
type ParsedLike = {
resolution?: string;
source?: string;
videoCodec?: string;
hdr?: string[];
releaseGroup?: { name?: string };
subtitles?: string[];
season?: number;
seasonEnd?: number;
seasonInferred?: boolean;
episodes?: { start: number; end: number };
totalEpisodes?: number;
isSeasonPack?: boolean;
};
export type SpecTagKind = "episode" | "resolution" | "source" | "codec" | "hdr" | "group" | "lang" | "site";
export interface SpecTag {
key: string;
label: string;
kind: SpecTagKind;
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/**
* 资源规格标签:季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字,再加站点自带标签(tagsRaw)。
* 都来自标题解析结果与标题、副标题原文,不猜;同名去重。
*/
export function releaseSpecTags(r: Pick<SourceRelease, "parsed" | "title" | "subtitle" | "tagsRaw">): SpecTag[] {
const p = (r.parsed ?? {}) as ParsedLike;
const out: SpecTag[] = [];
const seen = new Set<string>();
const add = (kind: SpecTagKind, label: string | undefined | null) => {
const l = label?.trim();
if (!l || seen.has(l.toLowerCase())) return;
seen.add(l.toLowerCase());
out.push({ key: `${kind}:${l}`, label: l, kind });
};
// 季号是推定的(标题里没写)就不显示季,只显示集
const season =
p.season !== undefined && !p.seasonInferred
? `S${pad2(p.season)}${p.seasonEnd && p.seasonEnd !== p.season ? `-S${pad2(p.seasonEnd)}` : ""}`
: "";
const episode = p.episodes
? `E${pad2(p.episodes.start)}${p.episodes.end !== p.episodes.start ? `-E${pad2(p.episodes.end)}` : ""}`
: "";
const total = !episode && p.totalEpisodes ? `全 ${p.totalEpisodes} 集` : "";
add("episode", [`${season}${episode}`, total].filter(Boolean).join(" "));
add("resolution", p.resolution === "2160p" ? "4K" : p.resolution);
add("source", p.source);
add("codec", p.videoCodec);
for (const h of p.hdr ?? []) add("hdr", h);
add("group", p.releaseGroup?.name);
const text = `${r.title} ${r.subtitle ?? ""}`;
if (/国语|國語|国配|普通话|中配/.test(text)) add("lang", "国语");
if (/粤语|粵語/.test(text)) add("lang", "粤语");
if (
/中字|中文字幕|简中|繁中|简繁|簡繁|双语字幕|雙語字幕|内嵌中|内封中/.test(text) ||
(p.subtitles ?? []).some((l) => l.startsWith("zh"))
) {
add("lang", "中字");
}
for (const t of r.tagsRaw ?? []) add("site", t);
return out;
}
/** 相对时间(中文);未来时间显示"x 后" */ /** 相对时间(中文);未来时间显示"x 后" */
export function relTime(iso: string | null | undefined, now = Date.now()): string { export function relTime(iso: string | null | undefined, now = Date.now()): string {
if (!iso) return "—"; if (!iso) return "—";
@@ -1,21 +1,34 @@
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts"; import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
import { ArrowDown, ArrowUp, Clock } from "lucide-react"; import { ArrowDown, ArrowUp, Check } from "lucide-react";
import { inventoryFlags } from "~/api/inventory.ts"; import { inventoryFlags } from "~/api/inventory.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx";
import { cn, formatBytes } from "~/lib/utils.ts"; import { cn, formatBytes } from "~/lib/utils.ts";
import { categoryLabel, hrOf, ORIGIN_LABEL, promotionLabel, relTime } from "./release-fields.ts"; import {
absTime,
categoryLabel,
hrOf,
ORIGIN_LABEL,
promotionLabel,
releaseSpecTags,
relTime,
remainingLabel,
snatchedOf,
} from "./release-fields.ts";
/** 促销 / H&R 徽章(列表与详情共用) */ /** 促销 / H&R 徽章(列表与详情共用):促销带剩余时间,过期的写明 */
export function ReleaseBadges({ r }: { r: WorkbenchRelease }) { export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
const promo = promotionLabel(r); const promo = promotionLabel(r);
const hr = hrOf(r); const hr = hrOf(r);
const left = promo && !promo.expired ? remainingLabel(r.promotionExpiresAt) : null;
return ( return (
<> <>
{promo ? ( {promo ? (
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "neutral"}> <span title={r.promotionExpiresAt ? `促销截止 ${absTime(r.promotionExpiresAt)}` : undefined}>
{promo.label} <StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "warning"}>
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""} {promo.label}
</StatusBadge> {promo.expired ? "(已过期)" : left ? ` · ${left}` : ""}
</StatusBadge>
</span>
) : null} ) : null}
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null} {hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
{hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null} {hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
@@ -23,6 +36,40 @@ export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
); );
} }
/** 规格标签(季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字 / 站点标签):中性样式,不占状态色 */
export function SpecTags({ r }: { r: Pick<WorkbenchRelease, "parsed" | "title" | "subtitle" | "tagsRaw"> }) {
const tags = releaseSpecTags(r);
if (!tags.length) return null;
return (
<>
{tags.map((t) => (
<span
key={t.key}
className={cn(
"inline-flex items-center rounded-md border px-1.5 text-meta leading-5 whitespace-nowrap",
t.kind === "resolution" || t.kind === "hdr"
? "border-border text-foreground"
: "border-border/70 text-muted-foreground",
)}
>
{t.label}
</span>
))}
</>
);
}
/** 没有关联作品时的说明(明确写出,不猜) */
export function unresolvedLabel(release: Pick<WorkbenchRelease, "resolutionStatus">): string {
return release.resolutionStatus === "ambiguous"
? "识别有歧义"
: release.resolutionStatus === "unidentifiable"
? "无法识别"
: release.resolutionStatus === "rejected"
? "已排除"
: "未识别到本地作品";
}
/** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */ /** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */
export function InventoryOverlay({ export function InventoryOverlay({
release, release,
@@ -33,17 +80,7 @@ export function InventoryOverlay({
entry: InventoryEntry | undefined; entry: InventoryEntry | undefined;
loading: boolean; loading: boolean;
}) { }) {
if (!release.entity) { if (!release.entity) return <span className="text-meta text-muted-foreground">{unresolvedLabel(release)}</span>;
const label =
release.resolutionStatus === "ambiguous"
? "识别有歧义"
: release.resolutionStatus === "unidentifiable"
? "无法识别"
: release.resolutionStatus === "rejected"
? "已排除"
: "未识别到本地作品";
return <span className="text-meta text-muted-foreground">{label}</span>;
}
const flags = inventoryFlags(entry, release.resolutionStatus, release.id); const flags = inventoryFlags(entry, release.resolutionStatus, release.id);
return ( return (
<span className="flex min-w-0 flex-wrap items-center gap-1.5"> <span className="flex min-w-0 flex-wrap items-center gap-1.5">
@@ -68,6 +105,11 @@ export function InventoryOverlay({
); );
} }
/**
* 资源列表行,两层:
* 主行 = 标题 + 规格标签 + 促销 / H&R;副行 = 副标题(单行省略)+ 分类 / 大小 / 做种·下载·完成 / 上传时间 + 库存叠加。
* 宽屏副行的数字列定宽右对齐,上下行对得齐;窄屏整行折成卡片,数字换到副标题下面。
*/
export function ReleaseRow({ export function ReleaseRow({
r, r,
entry, entry,
@@ -86,6 +128,11 @@ export function ReleaseRow({
}) { }) {
const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore; const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore;
const cat = categoryLabel(r); const cat = categoryLabel(r);
const snatched = snatchedOf(r);
const timeTitle = [
r.sourcePublishedAt ? `上传 ${absTime(r.sourcePublishedAt)}` : "上传时间未知",
`${ORIGIN_LABEL[r.origin]}数据 ${relTime(r.dataUpdatedAt)}确认`,
].join("\n");
return ( return (
<li> <li>
<button <button
@@ -93,37 +140,68 @@ export function ReleaseRow({
onClick={onOpen} onClick={onOpen}
aria-current={selected ? "true" : undefined} aria-current={selected ? "true" : undefined}
className={cn( className={cn(
"block w-full min-w-0 cursor-pointer px-4 py-3 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]", "group block w-full min-w-0 cursor-pointer px-4 py-2.5 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
selected && "bg-foreground/[0.05]", selected && "bg-foreground/[0.05]",
)} )}
> >
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1"> <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span> <span
<span className="flex flex-wrap gap-1"> className="min-w-0 text-sm font-medium text-foreground [overflow-wrap:anywhere] group-hover:underline group-hover:underline-offset-2"
<ReleaseBadges r={r} /> title={r.title}
>
{r.title}
</span>
<SpecTags r={r} />
<ReleaseBadges r={r} />
</div>
<div className="mt-1 flex min-w-0 flex-col gap-1 text-meta text-muted-foreground sm:flex-row sm:items-center sm:gap-4">
<span className="min-w-0 truncate sm:flex-1" title={r.subtitle ?? undefined}>
{r.subtitle ?? ""}
</span>
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 tabular-nums sm:shrink-0 sm:flex-nowrap sm:gap-x-4">
<span className="sm:w-14 sm:truncate" title={r.categoryRaw ? `站点分类 ${r.categoryRaw}` : undefined}>
{cat ?? "分类未知"}
</span>
<span className="sm:w-16 sm:text-right">
{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}
</span>
<span className="inline-flex items-center gap-0.5 sm:w-32" title="做种 / 下载 / 完成">
<ArrowUp className="h-3 w-3" aria-label="做种" />
<span className={cn(r.seeders === 0 && "text-warning")}>{r.seeders ?? "?"}</span>
<ArrowDown className="ml-1.5 h-3 w-3" aria-label="下载" />
{r.leechers ?? "?"}
<Check className="ml-1.5 h-3 w-3" aria-label="完成" />
{snatched ?? "?"}
</span>
<span className="sm:w-20 sm:text-right" title={timeTitle}>
{relTime(r.sourcePublishedAt)}
</span>
{!r.entity && r.resolutionStatus !== "ambiguous" ? (
<span className="sm:w-24 sm:truncate">{unresolvedLabel(r)}</span>
) : null}
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
</span> </span>
</div> </div>
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null} <InventoryLine release={r} entry={entry} loading={inventoryLoading} />
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
{cat ? <span>{cat}</span> : null}
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
<ArrowUp className="h-3 w-3" />
{r.seeders ?? "?"}
<ArrowDown className="ml-1 h-3 w-3" />
{r.leechers ?? "?"}
</span>
<span title="站点上传时间">上传 {relTime(r.sourcePublishedAt)}</span>
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}>
<Clock className="h-3 w-3" />
{ORIGIN_LABEL[r.origin]} · {relTime(r.dataUpdatedAt)}
</span>
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
</div>
<div className="mt-1.5">
<InventoryOverlay release={r} entry={entry} loading={inventoryLoading} />
</div>
</button> </button>
</li> </li>
); );
} }
/** 副行末尾的库存叠加:只在识别到作品(或识别有歧义)时占一行,未识别的不重复刷"无法识别" */
function InventoryLine({
release,
entry,
loading,
}: {
release: WorkbenchRelease;
entry: InventoryEntry | undefined;
loading: boolean;
}) {
if (!release.entity && release.resolutionStatus !== "ambiguous") return null;
return (
<div className="mt-1.5 min-w-0">
<InventoryOverlay release={release} entry={entry} loading={loading} />
</div>
);
}
+13 -3
View File
@@ -17,7 +17,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useInventory } from "~/api/inventory.ts"; import { useInventory } from "~/api/inventory.ts";
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts"; import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
import { snapshotStale, useSites, useSnapshotSite } from "~/api/sites.ts"; import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx"; import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { DetailSection } from "~/components/console/detail-sheet.tsx";
@@ -157,6 +157,7 @@ export default function SiteWorkbenchPage() {
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null; const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
const selected = all.find((r) => r.id === openId) ?? null; const selected = all.find((r) => r.id === openId) ?? null;
const freeCount = all.filter(isFree).length; const freeCount = all.filter(isFree).length;
const reqStats = useSiteRequestStats(tab === "resources" ? siteId : undefined);
const reset = () => { const reset = () => {
setQInput(""); setQInput("");
@@ -292,10 +293,14 @@ export default function SiteWorkbenchPage() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!s?.enabled || snap.isPending} disabled={!s?.enabled || snap.isPending}
title="手动快照(受站点限流)" title={
reqStats.data
? `抓取站点列表前 ${reqStats.data.access.snapshotMaxPages} 页(受站点限流)`
: "抓取站点列表(受站点限流)"
}
onClick={() => onClick={() =>
snap.mutate( snap.mutate(
{ id: siteId, maxPages: 2 }, { id: siteId },
{ {
onSuccess: () => toast.success("快照已排队"), onSuccess: () => toast.success("快照已排队"),
onError: (e) => toast.error(errorMessage(e)), onError: (e) => toast.error(errorMessage(e)),
@@ -389,6 +394,11 @@ export default function SiteWorkbenchPage() {
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""} 已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span> </span>
{freeCount ? <span>其中免费 {freeCount}</span> : null} {freeCount ? <span>其中免费 {freeCount}</span> : null}
{reqStats.data ? (
<span className="ml-auto" title={accessSummary(reqStats.data)}>
近 24 小时请求站点 {reqStats.data.last24h} 次
</span>
) : null}
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null} {releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
</div> </div>
<ul className="divide-y divide-border/60"> <ul className="divide-y divide-border/60">
+21 -2
View File
@@ -88,14 +88,33 @@ PT 站点适配器,按 MoviePilot v3(提交 e6820a8)迁移:通用配置驱动 sp
| 状态 | 站点 | | 状态 | 站点 |
| --- | --- | | --- | --- |
| 启用(主力,仍须真实账号核验) | audiences 观众、hhanclub 憨憨、chdbits 彩虹岛、mteam 馒头 | | 启用,**已实测 2026-09-29**(真实账号只读核对) | hhanclub 憨憨 |
| 启用(主力,仍须真实账号核验) | audiences 观众、chdbits 彩虹岛、mteam 馒头 |
| `enabled:false`,**未实测** | agsvpt、hdarea、hddolby、hdfans、hdhome、hdsky、hdtime、hdvideo、keepfrds、nicept、ourbits、piggo、pter、pthome、pttime、soulvoice、ssd、tjupt、ttg | | `enabled:false`,**未实测** | agsvpt、hdarea、hddolby、hdfans、hdhome、hdsky、hdtime、hdvideo、keepfrds、nicept、ourbits、piggo、pter、pthome、pttime、soulvoice、ssd、tjupt、ttg |
未实测站点只经 schema 校验,选择器是 MovieClaw 作者在 2026-08/09 的实测值,未经本项目核验。TTG 需要自定义适配器(中文分类、`/t/<id>` 路径、做种/下载同列),通用 spider 结果不可靠;OurBits 的 `cat[]` 分类参数已由定义表达;hddolby 在 MovieClaw 中按 HTML 站点配置,MP 则用 API spider(`HDDolby`),两条路径待账号确认后二选一。 未实测站点只经 schema 校验,选择器是 MovieClaw 作者在 2026-08/09 的实测值,未经本项目核验。TTG 需要自定义适配器(中文分类、`/t/<id>` 路径、做种/下载同列),通用 spider 结果不可靠;OurBits 的 `cat[]` 分类参数已由定义表达;hddolby 在 MovieClaw 中按 HTML 站点配置,MP 则用 API spider(`HDDolby`),两条路径待账号确认后二选一。
### 待真实站点核验(W1-3c 新增) ### 待真实站点核验(W1-3c 新增)
1. HHClub:`div.torrent-table-sub-info` 卡片结构、`?cat[]=` 分类链接(若 URL 编码为 `cat%5B%5D=` 则 `\d+` 会取错,需改 querystring 过滤)、`promotion-tag-*` 新旧类名、`div.torrent-info-hr`、详情页 Tailwind 选择器、分页 0 起。 1. HHClub(2026-09-29 已实测,见下节):仍未见到的只有促销(`promotion-tag-*`)与 H&R(`div.torrent-info-hr`)命中的实例——当次第 1 页 100 条全部无促销、无 H&R,类名与 Jackett 现行定义一致,暂不改。
### HHClub 实测记录(2026-09-29)
真实账号只读核对,对站点共发 1 次详情请求(列表、用户数据、健康检测用添加站点时产品自己抓的结果;下载令牌只核对列表里的下载链接形态,没有下载)。
| 项 | 结果 |
| --- | --- |
| 列表 `div.torrent-table-sub-info` 卡片、分类 `?cat[]=` 链接、大小 / 做种 / 下载 / 完成 | 正确,全为数字;分类 401–405/412 原始 ID 与名称都在 |
| 标题 | **有误,已修**:标题链接文本末尾带新种标记「[新]」(100 条里 58 条;详情页标题没有),`title` 加 `replace` 过滤 |
| 副标题 | 正确,纯文本,无 HTML 残片 |
| 发布时间 | 正确:`span[title]` 绝对时间按 `+08:00` 转 UTC,相对时间只进 `date_elapsed` |
| 详情页 | 标题 / 副标题取到;没有做种、完成、分类名、基本信息行,**刷新详情会用 null 冲掉列表值,已修**(服务端合并,见 `mergeDetailOverList`) |
| 用户数据(`NexusHhanclub`) | 正确;等级「贵宾」之前 `vip` 为 null,**已加 `umt.vipLevels`** |
| 健康检测 / 登录标志 `a[href='logout.php']` | 正确 |
| 下载链接 | 列表给 `download.php?id=…&passkey=…`,入库时 passkey 脱敏 |
| 标签(官方 / 中字 / 国语等) | 列表没有可用的标签选择器;界面按标题解析与副标题原文生成规格标签 |
访问默认(`umt.defaults`,与 `settings.sites` 取更保守的一方):快照每 60 分钟只抓第 1 页、健康检测 6 小时、用户数据 6 小时、实时搜索并发 1、详情缓存 24 小时;限流 3 秒 1 个令牌、突发 2。fixture:`@umt/testkit` 的 `HHANCLUB_LIST_PAGE_2026`(脱敏最小片段)。
2. CHDBits:域名 `ptchdbits.co`、`font.subtitle`、`td.embedded > span[title]` 促销截止、`a[href='logout.php']`。 2. CHDBits:域名 `ptchdbits.co`、`font.subtitle`、`td.embedded > span[title]` 促销截止、`a[href='logout.php']`。
3. 专用 spider 的 JSON 字段均按 MP 读取的键编写 fixture,真实响应待核;HDDolby `hr` 字段语义、`page_number` 起点;TorrentLeech 时区;Yema `tagList` 类型;直链缺失时的回退地址(`TODO(verify)`)。 3. 专用 spider 的 JSON 字段均按 MP 读取的键编写 fixture,真实响应待核;HDDolby `hr` 字段语义、`page_number` 起点;TorrentLeech 时区;Yema `tagList` 类型;直链缺失时的回退地址(`TODO(verify)`)。
4. 用户信息解析器 fixture 均为最小自编 HTML/JSON;`normalizeDatetime` 不处理相对时间(MP 用 dateparser)。 4. 用户信息解析器 fixture 均为最小自编 HTML/JSON;`normalizeDatetime` 不处理相对时间(MP 用 dateparser)。
@@ -1,4 +1,10 @@
import { CHDBITS_LIST_PAGE, CHDBITS_LOGIN_PAGE, HHANCLUB_DETAIL_PAGE, HHANCLUB_LIST_PAGE } from "@umt/testkit"; import {
CHDBITS_LIST_PAGE,
CHDBITS_LOGIN_PAGE,
HHANCLUB_DETAIL_PAGE,
HHANCLUB_LIST_PAGE,
HHANCLUB_LIST_PAGE_2026,
} from "@umt/testkit";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getSiteDefinition, listSiteDefinitions } from "../registry.ts"; import { getSiteDefinition, listSiteDefinitions } from "../registry.ts";
import { parseDetailPage } from "../spider/detail.ts"; import { parseDetailPage } from "../spider/detail.ts";
@@ -102,6 +108,44 @@ describe("HHClub 定义(div 卡片模板)", () => {
}); });
}); });
it("2026-09-29 实测结构:去掉新种标记「[新]」,短剧 412、完成数 0、无促销、时间按 +08:00、下载链接 passkey 脱敏", () => {
const [a, b] = parse(HHANCLUB_LIST_PAGE_2026);
expect(a).toMatchObject({
siteReleaseId: "91001",
title: "示例短剧 1080p WEB-DL H265 AAC-HHWEB",
subtitle: "示例短剧 | 全30集 | 1080p",
category: { id: "412", name: "短剧" },
sizeBytes: Math.round(185.38 * 1024 ** 2),
seeders: 1,
leechers: 58,
snatched: 0,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
hr: false,
uploadedAt: "2026-09-29T06:07:03.000Z",
detailUrl: "https://hhanclub.net/details.php?id=91001&hit=1",
});
expect(a!.downloadUrl).not.toContain("0000000000000000");
expect(b).toMatchObject({
title: "Example Show S01 2026 2160p WEB-DL H.264 AAC-HHWEB",
category: { id: "402", name: "电视剧" },
snatched: 187,
uploadedAt: "2026-09-29T04:03:28.000Z",
});
});
it("站点访问默认:快照只抓 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1;3 秒 1 个令牌、突发 2", () => {
expect(hhanclubDefinition.umt.defaults).toEqual({
snapshotIntervalSec: 3600,
snapshotMaxPages: 1,
healthCheckIntervalSec: 21600,
userDataIntervalSec: 21600,
searchConcurrency: 1,
detailCacheSec: 86400,
});
expect(hhanclubDefinition.umt.rateLimit).toEqual({ minIntervalMs: 3000, burst: 2 });
expect(hhanclubDefinition.umt.vipLevels).toContain("贵宾");
});
it("浏览从 page=0 起(MovieClaw page_offset -1);mtype=tv 选 402/403/407/412", () => { it("浏览从 page=0 起(MovieClaw page_offset -1);mtype=tv 选 402/403/407/412", () => {
expect(new SiteSpider(hhanclubDefinition, { page: 0 }).getSearchUrl()).toBe( expect(new SiteSpider(hhanclubDefinition, { page: 0 }).getSearchUrl()).toBe(
"https://hhanclub.net/torrents.php?page=0", "https://hhanclub.net/torrents.php?page=0",
@@ -86,7 +86,17 @@
"attribute": "title" "attribute": "title"
}, },
"title": { "title": {
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}" "text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}",
"filters": [
{
"name": "replace",
"args": ["[新]", ""]
},
{
"name": "replace",
"args": ["(新)", ""]
}
]
}, },
"details": { "details": {
"selector": "div.torrent-title a[href*='details.php']", "selector": "div.torrent-title a[href*='details.php']",
@@ -175,13 +185,13 @@
} }
}, },
"umt": { "umt": {
"version": "[email protected]", "version": "[email protected]+umt.1",
"kind": "nexusphp", "kind": "nexusphp",
"displayName": "憨憨", "displayName": "憨憨",
"timezone": "+08:00", "timezone": "+08:00",
"rateLimit": { "rateLimit": {
"minIntervalMs": 3000, "minIntervalMs": 3000,
"burst": 1 "burst": 2
}, },
"capabilities": { "capabilities": {
"list": true, "list": true,
@@ -249,6 +259,15 @@
"douban": "a[href*='douban.com']::attr(href)", "douban": "a[href*='douban.com']::attr(href)",
"fileList": "td.rowfollow > ul > li::text" "fileList": "td.rowfollow > ul > li::text"
} }
},
"vipLevels": ["贵宾", "VIP"],
"defaults": {
"snapshotIntervalSec": 3600,
"snapshotMaxPages": 1,
"healthCheckIntervalSec": 21600,
"userDataIntervalSec": 21600,
"searchConcurrency": 1,
"detailCacheSec": 86400
} }
} }
} }
@@ -74,6 +74,14 @@ const CapabilitiesSchema = z.strictObject({
download: z.boolean(), download: z.boolean(),
rss: z.boolean(), rss: z.boolean(),
}); });
const AccessDefaultsSchema = z.strictObject({
snapshotIntervalSec: z.number().int().min(300).optional(),
snapshotMaxPages: z.number().int().min(1).max(10).optional(),
healthCheckIntervalSec: z.number().int().min(300).optional(),
userDataIntervalSec: z.number().int().min(300).optional(),
searchConcurrency: z.number().int().min(1).max(4).optional(),
detailCacheSec: z.number().int().min(0).optional(),
});
const RateLimitSchema = z.strictObject({ minIntervalMs: z.number().int().min(0), burst: z.number().int().min(1) }); const RateLimitSchema = z.strictObject({ minIntervalMs: z.number().int().min(0), burst: z.number().int().min(1) });
const TimezoneSchema = z.string().regex(/^[+-]\d{2}:\d{2}$/, "时区须为 ±HH:MM 偏移"); const TimezoneSchema = z.string().regex(/^[+-]\d{2}:\d{2}$/, "时区须为 ±HH:MM 偏移");
const PairSchema = z.tuple([z.string(), z.string()]); const PairSchema = z.tuple([z.string(), z.string()]);
@@ -87,6 +95,8 @@ const UmtNexusSchema = z.strictObject({
authSupported: z.array(z.enum(["cookie", "credential", "apikey"])).optional(), authSupported: z.array(z.enum(["cookie", "credential", "apikey"])).optional(),
timezone: TimezoneSchema, timezone: TimezoneSchema,
rateLimit: RateLimitSchema, rateLimit: RateLimitSchema,
/** 站点级访问默认(与 settings.sites 取更保守的一方) */
defaults: AccessDefaultsSchema.optional(),
capabilities: CapabilitiesSchema, capabilities: CapabilitiesSchema,
categoryNames: z.record(z.string(), z.string()).optional(), categoryNames: z.record(z.string(), z.string()).optional(),
loginSuccess: z.string().optional(), loginSuccess: z.string().optional(),
@@ -121,6 +121,21 @@ export interface IndexerDefinition {
umt: UmtSiteExtension; umt: UmtSiteExtension;
} }
export interface SiteAccessDefaults {
/** 定时快照周期(秒) */
snapshotIntervalSec?: number;
/** 快照(定时 / 手动)最多抓几页列表 */
snapshotMaxPages?: number;
/** 健康检测最短间隔(秒) */
healthCheckIntervalSec?: number;
/** 用户数据刷新间隔(秒;定时任务 sites.user_data 的周期,仅作说明与校验) */
userDataIntervalSec?: number;
/** 同站实时搜索并发 */
searchConcurrency?: number;
/** 详情页结果缓存(秒):期内不重复抓详情 */
detailCacheSec?: number;
}
export interface UmtSiteExtension { export interface UmtSiteExtension {
/** 定义版本,入 site_snapshot */ /** 定义版本,入 site_snapshot */
version: string; version: string;
@@ -151,6 +166,11 @@ export interface UmtSiteExtension {
/** 站点时间时区偏移,如 "+08:00";pubdate/freedate 按此转 UTC */ /** 站点时间时区偏移,如 "+08:00";pubdate/freedate 按此转 UTC */
timezone: string; timezone: string;
rateLimit: { minIntervalMs: number; burst: number }; rateLimit: { minIntervalMs: number; burst: number };
/**
* 站点级访问默认(本项目扩展):按站点实测给出的保守下限。运行时与 settings.sites 取更保守的一方
* (周期 / 缓存取较长,页数 / 并发取较小);站点行自己设了快照周期时以站点行为准。
*/
defaults?: SiteAccessDefaults;
capabilities: { list: boolean; search: boolean; detail: boolean; download: boolean; rss: boolean }; capabilities: { list: boolean; search: boolean; detail: boolean; download: boolean; rss: boolean };
/** 原始分类 ID → 站点分类名(列表只有 ID 时补名称;不做投影) */ /** 原始分类 ID → 站点分类名(列表只有 ID 时补名称;不做投影) */
categoryNames?: Record<string, string>; categoryNames?: Record<string, string>;
@@ -0,0 +1,25 @@
import type { Kysely } from "kysely";
import { dropTables, execDdl } from "../src/migration-helpers.ts";
// 站点请求计数:每次从站点限流器拿到令牌(列表 / 搜索 / 详情 / 下载 / 登录 / 用户数据 / 原站视图)记一次,
// 按小时聚合,api / worker 多进程共用。站点页展示"最近 24 小时请求次数";保留 7 天,写入时顺手清理。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
create table site_request_count (
site_id uuid not null references site(id) on delete cascade,
hour timestamptz not null,
count integer not null default 0 check (count >= 0),
primary key (site_id, hour)
);
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["site_request_count"]));
}
+4
View File
@@ -121,6 +121,8 @@ const EXPECTED_TABLES = [
"rate_limit_bucket", "rate_limit_bucket",
// C2-A PG 短期缓存(0042,UNLOGGED) // C2-A PG 短期缓存(0042,UNLOGGED)
"kv_cache", "kv_cache",
// 站点请求计数(0046,按小时聚合)
"site_request_count",
].sort(); ].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */ /** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
@@ -149,6 +151,8 @@ const NON_ENTITY_TABLES = [
"rate_limit_bucket", "rate_limit_bucket",
// 缓存:(region, key) 主键 // 缓存:(region, key) 主键
"kv_cache", "kv_cache",
// 站点请求计数:(site_id, hour) 主键
"site_request_count",
]; ];
describe.skipIf(skip)("schema migrations (real PG)", () => { describe.skipIf(skip)("schema migrations (real PG)", () => {
@@ -45,3 +45,38 @@ export const HHANCLUB_DETAIL_PAGE = `<!DOCTYPE html><html><head><title>HHCLUB ::
<div><a href="download.php?id=90002">下载种子</a></div> <div><a href="download.php?id=90002">下载种子</a></div>
<div id="kdescr"><a href="https://www.imdb.com/title/tt15239678/">IMDb</a> <a href="https://movie.douban.com/subject/35575567/">豆瓣</a> 简介……</div> <div id="kdescr"><a href="https://www.imdb.com/title/tt15239678/">IMDb</a> <a href="https://movie.douban.com/subject/35575567/">豆瓣</a> 简介……</div>
</div></body></html>`; </div></body></html>`;
/**
* 2026-09-29 真实账号核对后补的最小脱敏片段(名称、ID、数值均已替换;不含 passkey 与用户信息)。
* 核对到的真实行为:列表标题链接文本末尾带新种标记「[新]」(详情页标题没有);短剧分类 412;
* 新种完成数为 0;无促销时没有 promotion-tag 元素;上传时间在 span[title] 里,文本是相对时间。
* 下载链接带 passkey(这里用占位值)。
*/
export const HHANCLUB_LIST_PAGE_2026 = `<!DOCTYPE html><html><head><title>HHCLUB :: 种子</title></head><body>
<div id="user-info-panel"><a href="userdetails.php?id=7" class="VIP_Name">bob</a> <a href="logout.php">退出</a></div>
<div class="torrent-table">
<div class="torrent-table-sub-info">
<div class="torrent-cat"><a href="?cat[]=412"><img src="pic/cattrans.gif" alt="短剧"/></a></div>
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=91001&amp;hit=1">示例短剧 1080p WEB-DL H265 AAC-HHWEB[新]</a></div>
<div class="torrent-info-text-small_name">示例短剧 | 全30集 | 1080p</div>
<div class="torrent-manage"><a href="download.php?id=91001&amp;passkey=0000000000000000">下载</a></div>
<div class="torrent-info-text-added"><span title="2026-09-29 14:07:03">5分钟</span></div>
<div class="torrent-info-text-size">185.38 MB</div>
<div class="torrent-info-text-seeders">1</div>
<div class="torrent-info-text-leechers">58</div>
<div class="torrent-info-text-finished">0</div>
</div>
<div class="torrent-table-sub-info">
<div class="torrent-cat"><a href="?cat[]=402"><img src="pic/cattrans.gif" alt="电视剧"/></a></div>
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=91002&amp;hit=1">Example Show S01 2026 2160p WEB-DL H.264 AAC-HHWEB</a></div>
<div class="torrent-info-text-small_name">示例剧集 | 第12集 | 4K | 类型: 奇幻 [内嵌中字]</div>
<div class="torrent-manage"><a href="download.php?id=91002&amp;passkey=0000000000000000">下载</a></div>
<div class="torrent-info-text-added"><span title="2026-09-29 12:03:28">2时8分钟</span></div>
<div class="torrent-info-text-size">1.46 GB</div>
<div class="torrent-info-text-seeders">131</div>
<div class="torrent-info-text-leechers">5</div>
<div class="torrent-info-text-finished">187</div>
</div>
</div>
<div class="pagination"><a href="?page=0">1-100</a><a href="?page=1">101-200</a></div>
</body></html>`;
@@ -1,5 +1,5 @@
{ {
"$comment": "HHClub 分类名取自 MovieClaw hhanclub.yaml 注释(导航栏分类表);REQ §4.5 要求综艺/体育/短剧保留原始分类。", "$comment": "HHClub 分类名取自 MovieClaw hhanclub.yaml 注释(导航栏分类表);REQ §4.5 要求综艺/体育/短剧保留原始分类。2026-09-29 真实账号核对:列表标题链接里带站点的新种标记「[新]」(详情页标题没有),用 replace 过滤掉;等级「贵宾」为 VIP;访问默认按低频原则收紧(快照只抓第 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1)。",
"umt": { "umt": {
"categoryNames": { "categoryNames": {
"401": "电影", "401": "电影",
@@ -12,6 +12,37 @@
"410": "音乐", "410": "音乐",
"411": "MV", "411": "MV",
"412": "短剧" "412": "短剧"
},
"version": "[email protected]+umt.1",
"rateLimit": {
"minIntervalMs": 3000,
"burst": 2
},
"vipLevels": ["贵宾", "VIP"],
"defaults": {
"snapshotIntervalSec": 3600,
"snapshotMaxPages": 1,
"healthCheckIntervalSec": 21600,
"userDataIntervalSec": 21600,
"searchConcurrency": 1,
"detailCacheSec": 86400
}
},
"torrents": {
"fields": {
"title": {
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}",
"filters": [
{
"name": "replace",
"args": ["[新]", ""]
},
{
"name": "replace",
"args": ["(新)", ""]
}
]
}
} }
} }
} }