Merge branch 'ia2'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	CHANGELOG.md
#	docs/plan/00-first-version-plan.md
#	packages/agent/src/system-prompt.ts
This commit is contained in:
root committed 2026-09-30 00:30:42 +08:00
commit 7a24d23bbf
46 files changed
+1845 -1503

No files matched your search

+1
View File
@@ -86,6 +86,7 @@ export function buildSystemPrompt({
'参数按工具 schema 的类型填写:ID 类参数一律是字符串(如 externalId:"693134",不是数字 693134);不要传 schema 里没有的字段。',
"业务任务 ID 用 task.inspect,下载任务 ID 或种子 hash 用 download.inspect,两者不要混用。",
"下载器、媒体服务器有没有接入、是否正常,用 downloader.list 查;站点列表里没有下载器不代表没配置。",
"站点分类型:PT 站、公开 BT 站、自定义 RSS 源;RSS 属于站点(在站点详情的 RSS 里管理),没有单独的 RSS 页。能不能搜索、抓列表、登录看 site.list 返回的获取方式,不支持的不要去试。",
"思考、步骤说明和汇报都用中文;引用文件名、原站标题时保持原文。",
"工具结果里出现【预算提醒】时立刻停止探查,直接用中文写小结;被预算或中止打断时,汇报只写中文小结(已完成什么、证据、还缺什么),不要复述中间过程或下一步打算。",
];
@@ -73,3 +73,12 @@ describe("工作方式(AGUI-3)", () => {
expect(interactionRules({ options: true, proposeFirst: true }).join("\n")).toContain("本会话要求先看方案再执行");
});
});
describe("站点类型(IA-2)", () => {
it("RSS 属于站点,没有单独的 RSS 页;按获取方式判断能不能搜索 / 登录", () => {
const p = userPrompt();
expect(p).toContain("RSS 属于站点");
expect(p).toContain("没有单独的 RSS 页");
expect(p).toContain("自定义 RSS 源");
});
});
@@ -25,8 +25,10 @@ import {
SiteAccountUserData,
SiteAdapter,
SiteAuthKind,
SiteCapability,
SiteHealth,
SiteHealthResult,
SiteKind,
SiteLastHealth,
SiteLoginResult,
SiteUserDataPoint,
@@ -461,6 +463,10 @@ export const SiteBrief = z.object({
id: Id,
key: z.string(),
name: z.string(),
/** 站点类型:pt / public_bt / rss_feed / torznab */
kind: SiteKind,
/** 实际可用的获取方式(search / list / rss / detail / login / userinfo) */
capabilities: z.array(SiteCapability),
adapter: SiteAdapter,
baseUrl: z.string(),
enabled: z.boolean(),
+1 -1
View File
@@ -370,7 +370,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"site.list": d(
"site.list",
"列出站点",
"列出已登记站点的健康、登录状态与账号摘要(不含凭证)。排查站点问题或确认站点可用时用",
"列出已登记站点的类型(PT 站 / 公开 BT 站 / 自定义 RSS 源)、可用的获取方式、健康、登录状态与账号摘要(不含凭证)。RSS 属于站点:自定义 RSS 源也是一种站点。排查站点或 RSS 问题、确认站点能不能搜索时用",
"read",
"SiteList",
"read",
+116 -10
View File
@@ -8,6 +8,94 @@ import { QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts";
/** api = MP 专用 API spider(Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema) */
export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "api", "generic_rss"]);
export type SiteAdapter = z.infer<typeof SiteAdapter>;
/**
* 站点类型(IA-2):PT 站之外的下载来源都作为一种站点类型。类型声明它可能支持的获取方式,
* 站点实际可用的方式 = 类型允许的 ∩ 适配器(站点定义)实现的,见 siteCapabilities。
* torznab 只定义结构(Jackett / Prowlarr 的 Torznab 接口),尚未实现:不能添加,也不参与调度。
*/
export const SiteKind = z.enum(["pt", "public_bt", "rss_feed", "torznab"]);
export type SiteKind = z.infer<typeof SiteKind>;
/** 获取方式:搜索 / 列表抓取 / RSS / 详情页 / 登录 / 账号数据(上传量、分享率、等级) */
export const SiteCapability = z.enum(["search", "list", "rss", "detail", "login", "userinfo"]);
export type SiteCapability = z.infer<typeof SiteCapability>;
export interface SiteKindSpec {
/** 界面名 */
label: string;
/** 添加站点第一步的一句说明 */
description: string;
/** 这类站点可能支持的获取方式 */
capabilities: readonly SiteCapability[];
/** false = 只定义了结构,还不能添加 */
implemented: boolean;
/** 需要从站点定义库里选(PT 站);否则只填名称和地址 */
fromDefinition: boolean;
}
export const SITE_KINDS: Record<SiteKind, SiteKindSpec> = {
pt: {
label: "PT 站",
description: "需要账号的私有站。从站点定义里选,支持搜索、列表抓取、RSS、登录和账号数据",
capabilities: ["search", "list", "rss", "detail", "login", "userinfo"],
implemented: true,
fromDefinition: true,
},
public_bt: {
label: "公开 BT 站",
description: "不用登录的公开站。填网址和 RSS 地址,按 RSS 获取资源",
capabilities: ["search", "list", "rss", "detail"],
implemented: true,
fromDefinition: false,
},
rss_feed: {
label: "自定义 RSS 源",
description: "只有一个 RSS 地址的来源,不登录、不搜索",
capabilities: ["rss"],
implemented: true,
fromDefinition: false,
},
torznab: {
label: "Torznab 索引器",
description: "Jackett、Prowlarr 等索引器的 Torznab 接口,还不支持",
capabilities: ["search", "rss"],
implemented: false,
fromDefinition: false,
},
};
/** 站点定义(适配器)声明的能力;没有定义的站点传 null */
export type SiteDefinitionCaps = { search: boolean; list: boolean; detail: boolean } | null;
/**
* 站点实际可用的获取方式:类型允许的 ∩ 适配器实现的。RSS 走通用解析器,不依赖站点定义;
* 搜索 / 列表 / 详情要站点定义声明支持;登录与账号数据要有站点定义(没有定义就没有登录流程)。
*/
export function siteCapabilities(kind: SiteKind, def: SiteDefinitionCaps): SiteCapability[] {
const spec = SITE_KINDS[kind];
if (!spec.implemented) return [];
return spec.capabilities.filter((c) => {
if (c === "rss") return true;
if (!def) return false;
if (c === "search") return def.search;
if (c === "list") return def.list;
if (c === "detail") return def.detail;
return true;
});
}
/**
* Torznab 索引器的站点 config(只定义结构,未实现):API 地址、API Key 的 secret 引用、分类过滤。
* 对照 MP 的 Jackett / Prowlarr 插件:按索引器登记,搜索走 `t=search`,RSS 走不带 q 的同一接口。
*/
export const TorznabSiteConfig = z.object({
apiUrl: z.url(),
apiKeySecretRef: z.uuid().nullable(),
indexerId: z.string().max(200).nullable(),
categories: z.array(z.number().int().nonnegative()).default([]),
});
export type TorznabSiteConfig = z.infer<typeof TorznabSiteConfig>;
export const SiteHealth = z.enum(["unknown", "healthy", "degraded", "failing", "disabled"]);
export type SiteHealth = z.infer<typeof SiteHealth>;
export const CircuitState = z.enum(["closed", "open", "half_open"]);
@@ -34,6 +122,10 @@ export type SiteLastHealth = z.infer<typeof SiteLastHealth>;
export const Site = EntityBase.extend({
key: z.string().regex(/^[a-z][a-z0-9_-]*$/),
name: z.string().min(1),
/** 站点类型(IA-2) */
kind: SiteKind,
/** 派生字段(非表列):实际可用的获取方式,见 siteCapabilities */
capabilities: z.array(SiteCapability),
adapter: SiteAdapter,
baseUrl: z.url(),
enabled: z.boolean(),
@@ -58,6 +150,7 @@ export type Site = z.infer<typeof Site>;
export const SiteCreate = Site.pick({
key: true,
name: true,
kind: true,
adapter: true,
baseUrl: true,
enabled: true,
@@ -67,17 +160,28 @@ export const SiteCreate = Site.pick({
snapshotIntervalSec: true,
proxy: true,
searchDefault: true,
}).partial({
enabled: true,
rateLimit: true,
config: true,
snapshotEnabled: true,
snapshotIntervalSec: true,
proxy: true,
searchDefault: true,
});
})
.partial({
key: true,
kind: true,
enabled: true,
rateLimit: true,
config: true,
snapshotEnabled: true,
snapshotIntervalSec: true,
proxy: true,
searchDefault: true,
})
.extend({
/**
* 非 PT 站(public_bt / rss_feed)添加时一并建的 RSS 地址(只写,存为 secret)。
* key 缺省时按地址主机名生成(rss-<主机>,重名加序号)。
*/
rssUrl: z.url().optional(),
});
export type SiteCreate = z.infer<typeof SiteCreate>;
export const SiteUpdate = SiteCreate.omit({ key: true }).partial();
/** 类型添加后不改(换类型 = 删了重加) */
export const SiteUpdate = SiteCreate.omit({ key: true, kind: true, rssUrl: true }).partial();
export type SiteUpdate = z.infer<typeof SiteUpdate>;
export const SiteAuthKind = z.enum(["cookie", "passkey", "api_key", "password"]);
@@ -553,6 +657,8 @@ export type SiteRequestStats = z.infer<typeof SiteRequestStats>;
export const SiteDefinitionView = z.object({
key: z.string(),
name: z.string(),
/** 定义对应的站点类型(现有定义都是 PT 站) */
kind: SiteKind,
adapter: SiteAdapter,
baseUrl: z.string(),
/** 定义是否随产品启用(未实测站点为 false) */
+3 -2
View File
@@ -77,8 +77,8 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
Secret: { row: C.Secret, dbOnly: ["ciphertext", "nonce", "authTag"] },
ServerMapping: { row: C.ServerMapping },
Settings: { row: C.SettingEntry },
// deleted_at:软删除只在服务端过滤(已删站点不出 API)
Site: { row: C.Site, dbOnly: ["deletedAt"] },
// deleted_at:软删除只在服务端过滤(已删站点不出 API);capabilities 由类型 ∩ 站点定义算出(IA-2)
Site: { row: C.Site, dbOnly: ["deletedAt"], apiOnly: ["capabilities"] },
// secret 引用不外露,只给布尔
SiteUserData: { row: C.SiteUserData },
SiteAccount: {
@@ -248,6 +248,7 @@ const ENUMS: Record<string, z.ZodEnum> = {
SiteAdapter: C.SiteAdapter,
SiteHealthStatus: C.SiteHealth,
SiteCircuitState: C.CircuitState,
SiteKind: C.SiteKind,
SiteAccountAuthKind: C.SiteAuthKind,
SiteAccountStatus: C.SiteAccountStatus,
SourceReleaseResolutionStatus: C.ResolutionStatus,
+100
View File
@@ -0,0 +1,100 @@
import { type Kysely, sql } from "kysely";
import { execDdl, oneOf } from "../src/migration-helpers.ts";
// IA-2 站点类型:site.kind(pt / public_bt / rss_feed / torznab),各类型声明支持的获取方式(contracts SITE_KINDS)。
// - 存量:adapter = generic_rss 的站点(旧"自定义 RSS 站点:只抓 RSS,不登录")→ rss_feed;其余 → pt。
// - RSS 归属站点:不属于任何站点的 feed_source 按地址主机名归入「自定义 RSS 源」站点(同主机一个站点,
// key = rss-<主机>,重名加序号);清掉这些 Feed 的游标窗口与条件请求头,下次抓取把窗口里的条目重新写成资源
// (之前无站点时观察记录被标 ignored/no_site,没有进 source_release)。
// down 只删列;迁移建的站点与 Feed 归属保留(对旧版本就是普通的 generic_rss 站点)。
export const SITE_KINDS = ["pt", "public_bt", "rss_feed", "torznab"] as const;
/** 主机名 → 站点 key 片段:小写,非字母数字换成 -,首尾去 -;空则 feed */
export function hostSlug(host: string): string {
const s = host
.toLowerCase()
.replace(/^www\./, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40)
.replace(/-+$/, "");
return s || "feed";
}
function hostOf(urlDisplay: string): string | null {
try {
return new URL(urlDisplay).host || null;
} catch {
return null;
}
}
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`alter table site add column kind text not null default 'pt' ${oneOf("kind", SITE_KINDS)};
update site set kind = 'rss_feed' where adapter = 'generic_rss';
create index site_kind_idx on site (kind) where deleted_at is null;`,
);
const orphans = await db
.selectFrom("feed_source")
.select(["id", "url_display", "name"])
.where("site_id", "is", null)
.orderBy("created_at")
.execute();
if (!orphans.length) return;
const taken = new Set((await db.selectFrom("site").select("key").execute()).map((r) => r.key as string));
const byHost = new Map<string, { id: string; name: string }[]>();
for (const f of orphans) {
const host = hostOf(f.url_display as string) ?? "unknown";
const list = byHost.get(host) ?? [];
list.push({ id: f.id as string, name: f.name as string });
byHost.set(host, list);
}
for (const [host, feeds] of byHost) {
const base = `rss-${hostSlug(host === "unknown" ? "" : host)}`;
let key = base;
for (let n = 2; taken.has(key); n++) key = `${base}-${n}`;
taken.add(key);
const site = await db
.insertInto("site")
.values({
key,
name: host === "unknown" ? (feeds[0]?.name ?? "自定义 RSS 源") : host,
kind: "rss_feed",
adapter: "generic_rss",
base_url: host === "unknown" ? "https://invalid.local" : `https://${host}`,
snapshot_enabled: false,
search_default: false,
})
.returning("id")
.executeTakeFirstOrThrow();
await db
.updateTable("feed_source")
.set({
site_id: site.id,
etag: null,
last_modified: null,
cursor: sql`cursor - 'window'`,
})
.where(
"id",
"in",
feeds.map((f) => f.id),
)
.execute();
}
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`drop index if exists site_kind_idx;
alter table site drop column if exists kind;`,
);
}
+2
View File
@@ -239,6 +239,7 @@ export type ServerMappingMatchStatus = "matched" | "ambiguous" | "unmatched" | "
export type SiteAdapter = "nexusphp" | "mteam_api" | "api" | "generic_rss";
export type SiteHealthStatus = "unknown" | "healthy" | "degraded" | "failing" | "disabled";
export type SiteCircuitState = "closed" | "open" | "half_open";
export type SiteKind = "pt" | "public_bt" | "rss_feed" | "torznab";
export type SiteAccountAuthKind = "cookie" | "passkey" | "api_key" | "password";
export type SiteAccountStatus = "unknown" | "active" | "invalid" | "expired" | "challenge";
export type SiteSnapshotKind = "periodic" | "backfill" | "live_search" | "manual";
@@ -1384,6 +1385,7 @@ export interface SiteTable {
proxy: Generated<boolean>;
deleted_at: Timestamptz | null;
search_default: Generated<boolean>;
kind: Generated<SiteKind>;
}
export interface SiteAccountTable {