io worker 进程内按任务声明的 lane 分文件 / 网络 / 维护三条道,各自并发(缺省 2/4/1)、 各自排队;维护道在文件道或网络道有排队/运行时让路(连续 10 分钟放行一个),调度器内联的 清理/回填/保留期裁剪也走维护道。并发在 设置 → 自动化 → 高级 热生效,env 覆盖;旧 WORKER_CONCURRENCY 映射到网络道。运行状态页按道显示运行中/并发/排队。归属表写进 docs/runbook/dev.md,ci 与注册表逐项核对。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
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<SubscriptionSearchResult> {
|
|
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<string>();
|
|
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<string, unknown>;
|
|
|
|
export const sourcesOutboxRoutes: Record<string, OutboxRouteFn> = {
|
|
"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 } };
|
|
},
|
|
};
|