import { defineTask, type OutboxRouteFn, type TaskRegistry } from "@umt/tasks"; import { z } from "zod"; import type { SourcesRuntime } from "./runtime.ts"; import { refreshSiteAccount } from "./service/account.ts"; import { AUTO_ENRICH_TASK, runAutoEnrich } from "./service/enrich.ts"; import { ingestFeed } from "./service/feeds.ts"; import { refreshMetadata } from "./service/metadata.ts"; import { resolveRelease } from "./service/resolve.ts"; import { snapshotSite } from "./service/snapshot.ts"; import { liveSearch } from "./service/workbench.ts"; // 持久任务:输入只放业务 ID,处理器读业务表。外部请求失败的退避/熔断由适配器状态与业务表承担, // 凭据失效返回正常结果(不重试);只有意外错误(DB、TMDB 网络)抛出交给引擎重试。 const NETWORK_RETRY = { maximumAttempts: 5, initialInterval: "1s", backoffCoefficient: 2, maximumInterval: "60s", } as const; const id = z.string().uuid(); /** Feed 疑似缺口时的补漏页数预算 */ export const BACKFILL_MAX_PAGES = 3; export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRuntime): void { registry.register( defineTask({ name: "ingestFeed", version: 1, lane: "network", inputSchema: z.object({ feedSourceId: id, manual: z.boolean().optional() }), retryPolicy: NETWORK_RETRY, handler: (t) => t.step.run("ingest", () => ingestFeed(rt(), t.input.feedSourceId, { manual: !!t.input.manual })), }), defineTask({ name: "resolveObservation", version: 1, lane: "network", inputSchema: z .object({ observationId: id.nullish(), sourceReleaseId: id.nullish(), force: z.boolean().optional(), // 惰性识别(DEC-1):manual / search 总是查 TMDB;缺省按 force 视为 review / observed trigger: z.enum(["observed", "review", "manual", "search"]).optional(), }) .refine((v) => !!v.observationId || !!v.sourceReleaseId, { message: "需要 observationId 或 sourceReleaseId" }), retryPolicy: NETWORK_RETRY, handler: (t) => t.step.run("resolve", () => resolveRelease(rt(), { observationId: t.input.observationId ?? undefined, sourceReleaseId: t.input.sourceReleaseId ?? undefined, force: t.input.force, trigger: t.input.trigger, }), ), }), // META-1 自动元数据补全(每个池条目一次,只出建议):经能力注册中心调用 metadata.enrich defineTask({ name: AUTO_ENRICH_TASK, version: 1, lane: "network", inputSchema: z.object({ poolEntryId: id }), retryPolicy: { maximumAttempts: 1 }, handler: (t) => t.step.run("enrich", () => runAutoEnrich(rt().ctx, t.input.poolEntryId, t.runId, t.abortSignal)), }), defineTask({ name: "refreshMetadata", version: 1, lane: "network", inputSchema: z.object({ mediaEntityId: id, full: z.boolean().optional() }), retryPolicy: NETWORK_RETRY, handler: (t) => t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId, { full: !!t.input.full })), }), defineTask({ name: "searchSubscription", version: 1, lane: "network", inputSchema: z.object({ subscriptionId: id, manual: z.boolean().optional() }), retryPolicy: NETWORK_RETRY, handler: (t) => t.step.run("search", () => searchSubscription(rt(), t.input.subscriptionId, { manual: !!t.input.manual })), }), defineTask({ name: "snapshotSite", version: 1, lane: "network", inputSchema: z.object({ siteId: id, kind: z.enum(["periodic", "backfill", "manual"]).default("periodic"), maxPages: z.number().int().min(1).max(20).optional(), }), retryPolicy: NETWORK_RETRY, handler: (t) => t.step.run("snapshot", () => snapshotSite(rt(), { siteId: t.input.siteId, kind: t.input.kind, maxPages: t.input.maxPages }), ), }), defineTask({ name: "refreshSiteAccount", version: 1, lane: "network", inputSchema: z.object({ accountId: id, mode: z.enum(["login", "userData"]).default("login") }), // 网络失败只记 last_error 不抛错;不自动重试(站点登录频繁重试有封号风险) retryPolicy: { maximumAttempts: 1 }, handler: (t) => t.step.run("refresh", async () => { const exists = await rt() .ctx.db.selectFrom("site_account") .select("id") .where("id", "=", t.input.accountId) .executeTakeFirst(); if (!exists) return { skipped: "not_found" }; return refreshSiteAccount(rt(), t.input.accountId, { mode: t.input.mode }); }), }), ); } // ── 订阅主动搜索(W5-C,对照 MP SubscribeSearchOwner):按作品标题/原名/别名在已开启站点搜索 ── /** 主动搜索周期缺省 24h;subscription.params.searchIntervalHours 覆盖(0 = 关闭周期搜索) */ export const DEFAULT_SEARCH_INTERVAL_HOURS = 24; /** 每次最多用几个检索词(标题、原名、别名),控制站点请求量 */ export const SEARCH_MAX_KEYWORDS = 3; export interface SubscriptionSearchResult { status: "ok" | "skipped"; reason?: string; keywords: string[]; sites: string[]; searches: number; errors: { siteId: string; keyword: string; error: string }[]; nextSearchAt: string | null; } /** * 主动搜索:对订阅站点范围内已开启且健康的站点,按标题/原名/别名经站点适配器 search(共享限流器、实时补查合并与失败退避, * 记 site_snapshot kind=live_search);新结果 upsert 为 source_release 并发 release.observed → 识别 → release.resolved → * evaluateAcquisition(与 RSS 同一路径);已识别的旧结果由本任务末尾入队的订阅重评覆盖。 */ export async function searchSubscription( rt: SourcesRuntime, subscriptionId: string, opts: { manual?: boolean } = {}, ): Promise { const { db } = rt.ctx; const none = { keywords: [], sites: [], searches: 0, errors: [], nextSearchAt: null }; const sub = await db.selectFrom("subscription").selectAll().where("id", "=", subscriptionId).executeTakeFirst(); if (!sub) return { status: "skipped", reason: "not_found", ...none }; if (!opts.manual && sub.status !== "active" && sub.status !== "pending") return { status: "skipped", reason: `subscription_${sub.status}`, ...none }; if (!sub.media_entity_id) return { status: "skipped", reason: "no_entity", ...none }; const automation = await rt.ctx.settings.get("automation"); if (!opts.manual && (!automation.enabled || automation.readOnlyReconcile)) return { status: "skipped", reason: "automation_blocked", ...none }; const entity = await db .selectFrom("media_entity") .select(["id", "title", "original_title"]) .where("id", "=", sub.media_entity_id) .executeTakeFirst(); if (!entity) return { status: "skipped", reason: "entity_not_found", ...none }; const aliases = await db .selectFrom("title_alias") .select(["alias", "kind"]) .where("media_entity_id", "=", entity.id) .where("content_unit_id", "is", null) .orderBy("created_at") .limit(20) .execute(); const seen = new Set(); const keywords: string[] = []; for (const k of [entity.title, entity.original_title, ...aliases.map((a) => a.alias)]) { const v = (k ?? "").trim(); const norm = v.toLowerCase(); if (!v || seen.has(norm)) continue; seen.add(norm); keywords.push(v); if (keywords.length >= SEARCH_MAX_KEYWORDS) break; } const params = (sub.params ?? {}) as { siteIds?: string[]; searchIntervalHours?: number; keywords?: string[] }; // 订阅的搜索关键词(MP keyword)追加在标题/别名之后,不占 SEARCH_MAX_KEYWORDS;结果同样经识别 → 评估 // (订阅私有规则组的包含 / 排除在评估时过滤,与 RSS 来的资源同一口径) for (const k of params.keywords ?? []) { const v = typeof k === "string" ? k.trim() : ""; if (!v || seen.has(v.toLowerCase())) continue; seen.add(v.toLowerCase()); keywords.push(v); } let sq = db.selectFrom("site").selectAll().where("enabled", "=", true).where("health_status", "!=", "failing"); if (params.siteIds?.length) sq = sq.where("id", "in", params.siteIds); const sites = await sq.orderBy("name").execute(); const errors: SubscriptionSearchResult["errors"] = []; let searches = 0; for (const site of sites) { for (const kw of keywords) { searches++; const r = await liveSearch(rt, site, kw).catch((e) => ({ error: (e as Error).message, coalesced: false })); if (r.error) errors.push({ siteId: site.id, keyword: kw, error: r.error }); } } const hours = params.searchIntervalHours ?? DEFAULT_SEARCH_INTERVAL_HOURS; const now = rt.now(); const next = hours > 0 ? new Date(now.getTime() + hours * 3600_000) : null; await db .updateTable("subscription") .set({ last_searched_at: now, next_search_at: next }) .where("id", "=", sub.id) .execute(); // 已识别的旧结果:按订阅重评(新结果走 release.observed → resolved → evaluate) await rt.ctx.engine .enqueue( "evaluateAcquisition", { subscriptionId: sub.id }, { idempotencyKey: `search-eval:${sub.id}:${now.getTime()}`, traceId: sub.id }, ) .catch(() => {}); return { status: "ok", keywords, sites: sites.map((s) => s.id), searches, errors, nextSearchAt: next ? next.toISOString() : null, }; } type P = Record; export const sourcesOutboxRoutes: Record = { "feed.due": (ev) => { const p = ev.payload as P; return { task: "ingestFeed", input: { feedSourceId: p.feedId, manual: p.reason === "manual" } }; }, "release.observed": (ev) => { const p = ev.payload as P; // 标题更新(isNew=false):按新标题重新解析并重新评估,已识别的也不短路(ID-2);触发来源仍是 observed(DEC-1 不变) const retitled = p.isNew === false; return { task: "resolveObservation", input: { sourceReleaseId: p.sourceReleaseId, observationId: p.observationId, ...(retitled ? { force: true, trigger: "observed" } : {}), }, }; }, "feed.gap_suspected": (ev) => { const p = ev.payload as P; if (!p.siteId) return null; return { task: "snapshotSite", input: { siteId: p.siteId, kind: "backfill", maxPages: BACKFILL_MAX_PAGES } }; }, };