fix(sites): HHClub parsing, resource list layout and request restraint (re-landed)
Re-lands901b01d, which was reverted wholesale by28974d1(BR-2 committed from the shared index). The agent UI files901b01dhad 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:
1 parent
99e335d13c
commit
6e9e8efec2
22 files changed
+746
-69
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: "电视剧" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}>
|
||||||
|
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "warning"}>
|
||||||
{promo.label}
|
{promo.label}
|
||||||
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""}
|
{promo.expired ? "(已过期)" : left ? ` · ${left}` : ""}
|
||||||
</StatusBadge>
|
</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"
|
||||||
|
title={r.title}
|
||||||
|
>
|
||||||
|
{r.title}
|
||||||
|
</span>
|
||||||
|
<SpecTags r={r} />
|
||||||
<ReleaseBadges r={r} />
|
<ReleaseBadges r={r} />
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
|
<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">
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
|
<span className="min-w-0 truncate sm:flex-1" title={r.subtitle ?? undefined}>
|
||||||
{cat ? <span>{cat}</span> : null}
|
{r.subtitle ?? ""}
|
||||||
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
|
<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">
|
||||||
<ArrowUp className="h-3 w-3" />
|
<span className="sm:w-14 sm:truncate" title={r.categoryRaw ? `站点分类 ${r.categoryRaw}` : undefined}>
|
||||||
{r.seeders ?? "?"}
|
{cat ?? "分类未知"}
|
||||||
<ArrowDown className="ml-1 h-3 w-3" />
|
</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 ?? "?"}
|
{r.leechers ?? "?"}
|
||||||
|
<Check className="ml-1.5 h-3 w-3" aria-label="完成" />
|
||||||
|
{snatched ?? "?"}
|
||||||
</span>
|
</span>
|
||||||
<span title="站点上传时间">上传 {relTime(r.sourcePublishedAt)}</span>
|
<span className="sm:w-20 sm:text-right" title={timeTitle}>
|
||||||
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}>
|
{relTime(r.sourcePublishedAt)}
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{ORIGIN_LABEL[r.origin]} · {relTime(r.dataUpdatedAt)}
|
|
||||||
</span>
|
</span>
|
||||||
|
{!r.entity && r.resolutionStatus !== "ambiguous" ? (
|
||||||
|
<span className="sm:w-24 sm:truncate">{unresolvedLabel(r)}</span>
|
||||||
|
) : null}
|
||||||
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
|
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5">
|
<InventoryLine release={r} entry={entry} loading={inventoryLoading} />
|
||||||
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"]));
|
||||||
|
}
|
||||||
@@ -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&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&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&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&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": ["(新)", ""]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user