From 4a92dfd2dcca78ec8d2e0f626e30dfa529e4f6c5 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 14:43:19 +0800 Subject: [PATCH] feat(feeds): site-scoped RSS add infers account, parser and poll Adding a feed from a site page now only asks for the URL, purpose (discover / track / promotion) and an optional note. POST /feed-sources with siteId infers the site account (passkey holder first), records the site definition version as parserVersion, defaults the name to " RSS" and polling to 30 minutes, and returns non-blocking warnings when the URL host does not match the site. The dialog shows the redacted URL, fetches once right after saving and reports the item count or the error. The generic feed form on the sources page suggests linking a registered site when the URL host matches (can be undone). name is now optional. Takes effect after the stack is rebuilt. Co-Authored-By: Claude Opus 5.5 --- .../sources-identity/routes/sources.ts | 17 +- .../modules/sources-identity/service/feeds.ts | 61 +++- apps/server/test/sources-identity/api.test.ts | 31 ++ apps/web/app/api/feeds.test.ts | 14 + apps/web/app/api/feeds.ts | 28 ++ .../app/components/sites/site-feed-dialog.tsx | 292 ++++++++++++++++++ .../app/components/workbench/feed-form.tsx | 60 +++- .../app/components/workbench/feed-table.tsx | 11 +- docs/runbook/sites.md | 6 + packages/contracts/src/sources.ts | 9 +- 10 files changed, 512 insertions(+), 17 deletions(-) create mode 100644 apps/web/app/api/feeds.test.ts create mode 100644 apps/web/app/components/sites/site-feed-dialog.tsx diff --git a/apps/server/src/modules/sources-identity/routes/sources.ts b/apps/server/src/modules/sources-identity/routes/sources.ts index d691ab46..1ca2ebc7 100644 --- a/apps/server/src/modules/sources-identity/routes/sources.ts +++ b/apps/server/src/modules/sources-identity/routes/sources.ts @@ -44,6 +44,7 @@ import { userDataHistory, } from "../service/account.ts"; import { audit, closeDecisions } from "../service/common.ts"; +import { feedDefaultsForSite } from "../service/feeds.ts"; import { assignReleaseIdentity } from "../service/identity-admin.ts"; // 站点、站点账号、Feed、来源资源、观察的管理 API。凭证只写(存 secret,对外只给 secret_ref / 脱敏 URL)。 @@ -515,27 +516,31 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { }); route(app, D.feedSources.get, async ({ params }) => feedToApi(await getFeed(params.id))); route(app, D.feedSources.create, async ({ body, actor }) => { - if (body.siteId) await getSite(body.siteId); + const site = body.siteId ? await getSite(body.siteId) : null; if (body.siteAccountId) await checkAccount(body.siteAccountId, body.siteId ?? null); - const ref = await rt().ctx.secrets.create({ purpose: "feed_url", plaintext: body.url, label: `feed:${body.name}` }); + // 站点内添加:账号 / 解析器 / 名称按站点推断,域名不符只提示 + const inferred = site ? await feedDefaultsForSite(db(), site, body.url, body.siteAccountId ?? null) : null; + const name = body.name?.trim() || inferred?.name || new URL(body.url).hostname; + const ref = await rt().ctx.secrets.create({ purpose: "feed_url", plaintext: body.url, label: `feed:${name}` }); const row = await db() .insertInto("feed_source") .values({ site_id: body.siteId ?? null, - site_account_id: body.siteAccountId ?? null, - name: body.name, + site_account_id: inferred?.siteAccountId ?? body.siteAccountId ?? null, + name, + ...(inferred ? { parser_version: inferred.parserVersion } : {}), purpose: body.purpose ?? "generic", kind: body.kind ?? "rss", url_secret_ref: ref, url_display: redactUrl(body.url), enabled: body.enabled ?? true, - poll_interval_sec: body.pollIntervalSec ?? 900, + poll_interval_sec: body.pollIntervalSec ?? inferred?.pollIntervalSec ?? 900, filter: jsonb(body.filter ?? {}), }) .returningAll() .executeTakeFirstOrThrow(); await audit(db(), actor, "feed_source.created", { type: "feed_source", id: row.id }, { url: row.url_display }); - return feedToApi(row); + return inferred?.warnings.length ? { ...feedToApi(row), warnings: inferred.warnings } : feedToApi(row); }); route(app, D.feedSources.update, async ({ params, body, actor }) => { const feed = await getFeed(params.id); diff --git a/apps/server/src/modules/sources-identity/service/feeds.ts b/apps/server/src/modules/sources-identity/service/feeds.ts index bbaf1af3..2882a2f0 100644 --- a/apps/server/src/modules/sources-identity/service/feeds.ts +++ b/apps/server/src/modules/sources-identity/service/feeds.ts @@ -6,9 +6,10 @@ import { type FeedFetchResult, type FeedFetchState, type FeedItem, + getSiteDefinition, } from "@umt/adapters"; import type { FeedHealth } from "@umt/contracts"; -import { type FeedSourceRow, sql } from "@umt/db"; +import { type FeedSourceRow, type SiteRow, sql } from "@umt/db"; import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { clip, type Exec, openDecision } from "./common.ts"; @@ -53,6 +54,64 @@ export function extractSiteReleaseId(item: Pick, + url: string, + explicitAccountId: string | null, +): Promise<{ + siteAccountId: string | null; + parserVersion: string; + name: string; + pollIntervalSec: number; + warnings: string[]; +}> { + const def = getSiteDefinition(site.key); + let siteAccountId = explicitAccountId; + if (!siteAccountId) { + const accs = await db + .selectFrom("site_account") + .select(["id", "passkey_secret_ref", "auth_kind"]) + .where("site_id", "=", site.id) + .where("enabled", "=", true) + .orderBy("created_at") + .execute(); + const withPasskey = accs.filter((a) => a.passkey_secret_ref || a.auth_kind === "passkey"); + siteAccountId = (withPasskey[0] ?? accs[0])?.id ?? null; + } + const warnings: string[] = []; + const feedHost = hostKey(url); + const siteHosts = new Set([hostKey(site.base_url), def ? hostKey(def.domain) : null].filter(Boolean)); + if (feedHost && !siteHosts.has(feedHost)) { + warnings.push(`地址域名 ${new URL(url).hostname} 与站点「${site.name}」不一致,已按该站保存;请确认没有填错站点`); + } + return { + siteAccountId, + parserVersion: def ? `rss@1;${def.umt.version}` : "rss@1", + name: `${site.name} RSS`, + pollIntervalSec: SITE_FEED_POLL_SEC, + warnings, + }; +} + export function stateFromRow(f: FeedSourceRow): FeedFetchState { const cur = (f.cursor ?? {}) as StoredCursor; const err = f.last_error as FeedFetchState["lastError"] | null; diff --git a/apps/server/test/sources-identity/api.test.ts b/apps/server/test/sources-identity/api.test.ts index 7b099684..50dbb7b9 100644 --- a/apps/server/test/sources-identity/api.test.ts +++ b/apps/server/test/sources-identity/api.test.ts @@ -115,6 +115,37 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => { expect((await h.req("/feed-sources/00000000-0000-4000-8000-000000000000")).status).toBe(404); }); + it("站点内添加 Feed:只给地址即可,账号 / 解析器 / 名称按站点推断;域名不符只提示", async () => { + const acc = (await json(await h.req(`/site-accounts?siteId=${siteId}`))).body.data.items[0]; + const same = await json( + await h.req("/feed-sources", { body: { siteId, url: "https://audiences.me/torrentrss.php?passkey=k1" } }), + ); + expect(same.status).toBe(200); + expect(same.body.data).toMatchObject({ + name: "观众 RSS", + siteAccountId: acc.id, + purpose: "generic", + pollIntervalSec: 1800, + }); + expect(same.body.data.parserVersion).toMatch(/^rss@1;audiences@/); + expect(same.body.data.warnings).toBeUndefined(); + const other = await json( + await h.req("/feed-sources", { + body: { siteId, purpose: "promotion", name: "促销", url: "https://elsewhere.example/rss?passkey=k2" }, + }), + ); + expect(other.status).toBe(200); + expect(other.body.data.name).toBe("促销"); + expect(other.body.data.warnings?.[0]).toContain("elsewhere.example"); + const bare = await json(await h.req("/feed-sources", { body: { url: "https://any.example/feed.xml" } })); + expect(bare.body.data).toMatchObject({ + name: "any.example", + siteId: null, + siteAccountId: null, + parserVersion: "1", + }); + }); + it("快照:列表页 upsert 到 source_release,记录覆盖范围;站点级限流器每站一个实例", async () => { const r = await snapshotSite(h.rt, { siteId, kind: "periodic" }); expect(r.status).toBe("ok"); diff --git a/apps/web/app/api/feeds.test.ts b/apps/web/app/api/feeds.test.ts new file mode 100644 index 00000000..c2384b79 --- /dev/null +++ b/apps/web/app/api/feeds.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { feedHostKey } from "./feeds.ts"; + +describe("feedHostKey", () => { + it("同站子域名 / www 归到同一键", () => { + expect(feedHostKey("https://hhanclub.net/torrentrss.php?passkey=x")).toBe("hhanclub.net"); + expect(feedHostKey("https://www.hhanclub.net/")).toBe("hhanclub.net"); + expect(feedHostKey("https://rss.hhanclub.net/a")).toBe("hhanclub.net"); + }); + it("IP 原样;非法地址为 null", () => { + expect(feedHostKey("http://127.0.0.1:8080/rss")).toBe("127.0.0.1"); + expect(feedHostKey("not a url")).toBeNull(); + }); +}); diff --git a/apps/web/app/api/feeds.ts b/apps/web/app/api/feeds.ts index 04ee3d4c..731b84df 100644 --- a/apps/web/app/api/feeds.ts +++ b/apps/web/app/api/feeds.ts @@ -29,6 +29,34 @@ export function useFeeds(f: { siteId?: string; health?: FeedHealth } = {}) { }); } +/** 单个 Feed;poll 为真时每 2 秒刷新(站点内添加后等首抓结果) */ +export function useFeed(id: string | null, poll = false) { + return useQuery({ + queryKey: [...feedKeys.all, "one", id] as const, + enabled: !!id, + queryFn: ({ signal }) => + apiParsed(FeedSource, "/api/v1/feed-sources/:id", { pathParams: { id: id ?? "" }, signal }), + refetchInterval: poll ? 2 * TTL_SECOND : false, + }); +} + +/** 同站判定的主机键(与服务端 feedDefaultsForSite 一致):去 www.,取最后两级;IP 原样 */ +export function feedHostKey(u: string): string | null { + try { + const host = new URL(u).hostname.toLowerCase().replace(/^www\./, ""); + if (/^[\d.]+$/.test(host) || host.includes(":")) return host; + return host.split(".").slice(-2).join("."); + } catch { + return null; + } +} + +/** 游标窗口里的条目数(最近一次成功抓取的 Feed 条目数);没抓过为 null */ +export function feedWindowCount(f: FeedSource): number | null { + const w = f.cursor.window as { lastGuidSet?: unknown[] } | undefined; + return Array.isArray(w?.lastGuidSet) ? w.lastGuidSet.length : null; +} + export function useCreateFeed() { const qc = useQueryClient(); return useMutation({ diff --git a/apps/web/app/components/sites/site-feed-dialog.tsx b/apps/web/app/components/sites/site-feed-dialog.tsx new file mode 100644 index 00000000..1566e4f6 --- /dev/null +++ b/apps/web/app/components/sites/site-feed-dialog.tsx @@ -0,0 +1,292 @@ +import { type FeedPurpose, type FeedSource, redactUrl, type Site, type SiteAccount } from "@umt/contracts"; +import { CheckCircle2, CircleAlert, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { feedHostKey, feedWindowCount, useCreateFeed, useFeed, useFetchFeedNow } from "~/api/feeds.ts"; +import { FormField } from "~/components/console/form-field.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; + +// 站点内添加 RSS:站点固定,账号 / 解析器 / 轮询由服务端按站点带出;只填地址、用途、可选备注。 +// 保存后立即抓一次,在对话框里等结果(条目数或错误)。 + +const PURPOSES: { value: FeedPurpose; label: string; hint: string }[] = [ + { value: "generic", label: "发现", hint: "全站新种,进入识别和收录评估" }, + { value: "search", label: "追踪", hint: "按关键词、分类或收藏筛过的 RSS" }, + { value: "promotion", label: "促销", hint: "只含免费 / 促销种子的 RSS" }, +]; + +/** 首抓最多等 90 秒(站点限流排队);超时后提示去列表看 */ +const WAIT_MS = 90_000; + +function defaultAccount(accounts: SiteAccount[]): string { + return (accounts.find((a) => a.hasPasskey || a.authKind === "passkey") ?? accounts[0])?.id ?? ""; +} + +export function SiteFeedDialog({ + site, + accounts, + onOpenChange, +}: { + site: Site; + accounts: SiteAccount[]; + onOpenChange: (o: boolean) => void; +}) { + const siteAccounts = accounts.filter((a) => a.siteId === site.id && a.enabled); + const [url, setUrl] = useState(""); + const [purpose, setPurpose] = useState("generic"); + const [note, setNote] = useState(""); + const [accountId, setAccountId] = useState(() => defaultAccount(siteAccounts)); + const [error, setError] = useState(null); + const [created, setCreated] = useState(null); + const [fetchError, setFetchError] = useState(null); + const [startedAt, setStartedAt] = useState(0); + const [timedOut, setTimedOut] = useState(false); + const create = useCreateFeed(); + const fetchNow = useFetchFeedNow(); + + const polled = useFeed(created?.id ?? null, !!created && !timedOut); + const feed = polled.data ?? created; + const done = !!feed?.lastFetchAt; + + useEffect(() => { + if (!created || done || timedOut) return; + const t = setTimeout(() => setTimedOut(true), Math.max(0, startedAt + WAIT_MS - Date.now())); + return () => clearTimeout(t); + }, [created, done, timedOut, startedAt]); + + const trimmed = url.trim(); + let valid = false; + try { + valid = /^https?:$/.test(new URL(trimmed).protocol); + } catch { + valid = false; + } + const shown = valid ? redactUrl(trimmed) : null; + const siteHosts = [feedHostKey(site.baseUrl)]; + const mismatch = valid && !siteHosts.includes(feedHostKey(trimmed)); + const account = siteAccounts.find((a) => a.id === accountId); + + const submit = () => { + if (!valid) { + setError("填写完整的 RSS 地址(http / https)"); + return; + } + setError(null); + create.mutate( + { + siteId: site.id, + url: trimmed, + purpose, + ...(note.trim() ? { name: note.trim() } : {}), + ...(siteAccounts.length > 1 && accountId ? { siteAccountId: accountId } : {}), + }, + { + onSuccess: (f) => { + setCreated(f); + setStartedAt(Date.now()); + fetchNow.mutate(f.id, { onError: (e) => setFetchError(errorMessage(e)) }); + }, + onError: (e) => setError(errorMessage(e)), + }, + ); + }; + + return ( + + + + 添加 {site.name} RSS + 地址加密保存,页面上只显示去掉 passkey 的地址。 + + + {created && feed ? ( + + ) : ( +
{ + e.preventDefault(); + submit(); + }} + > + + 保存后显示为 {shown} + + ) : ( + "站点「控制面板 → RSS」里生成的链接" + ) + } + > + setUrl(e.target.value)} + /> + + {mismatch ? ( +

+ 地址域名与 {site.name}({new URL(site.baseUrl).hostname})不一致,仍可保存;确认没有填错站点。 +

+ ) : null} + +
+

用途

+
+ {PURPOSES.map((p) => ( + // biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达 + + ))} +
+

+ {PURPOSES.find((p) => p.value === purpose)?.hint} +

+
+ + {siteAccounts.length > 1 ? ( + + + + ) : null} + + + setNote(e.target.value)} + /> + + +

+ {account ? `账号 ${account.label}` : "该站没有账号,按地址里的 passkey 抓取"} · 解析器按站点定义 · 每 30 + 分钟抓一次,可在 Feed 详情里改 +

+
+ )} + + + {created ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} + +function FirstFetch({ + feed, + done, + timedOut, + fetchError, + warnings, +}: { + feed: FeedSource; + done: boolean; + timedOut: boolean; + fetchError: string | null; + warnings: string[]; +}) { + const err = feed.lastError + ? String((feed.lastError as { message?: unknown }).message ?? JSON.stringify(feed.lastError)) + : fetchError; + const count = feedWindowCount(feed); + return ( +
+

{feed.urlDisplay}

+ {warnings.map((w) => ( +

+ {w} +

+ ))} + {err ? ( +

+ + 首次抓取失败:{err} +

+ ) : done ? ( +

+ + 已保存,首次抓取到 {count ?? 0} 条 +

+ ) : timedOut ? ( +

+ 已保存。抓取还在排队(站点限流),结果稍后显示在 Feed 列表里。 +

+ ) : ( +

+ + 已保存,正在抓取… +

+ )} +
+ ); +} diff --git a/apps/web/app/components/workbench/feed-form.tsx b/apps/web/app/components/workbench/feed-form.tsx index b7a700bb..982731d5 100644 --- a/apps/web/app/components/workbench/feed-form.tsx +++ b/apps/web/app/components/workbench/feed-form.tsx @@ -8,7 +8,7 @@ import { } from "@umt/contracts"; import { useState } from "react"; import { toast } from "sonner"; -import { useCreateFeed, useUpdateFeed } from "~/api/feeds.ts"; +import { feedHostKey, useCreateFeed, useUpdateFeed } from "~/api/feeds.ts"; import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx"; import { Button } from "~/components/ui/button.tsx"; import { @@ -76,12 +76,26 @@ export function FeedFormDialog({ }) { const [d, setD] = useState(() => initial(feed)); const [errors, setErrors] = useState>({}); + // 新建时按地址域名建议关联已登记站点;用户动过站点选择后不再自动改 + const [siteTouched, setSiteTouched] = useState(!!feed); + const [suggested, setSuggested] = useState(null); const create = useCreateFeed(); const update = useUpdateFeed(); const pending = create.isPending || update.isPending; const siteAccounts = accounts.filter((a) => a.siteId === d.siteId); const set = (k: K, v: Draft[K]) => setD((x) => ({ ...x, [k]: v })); + const onUrl = (url: string) => { + if (siteTouched) return set("url", url); + const key = feedHostKey(url.trim()); + const match = key ? sites.find((s) => feedHostKey(s.baseUrl) === key) : undefined; + const accs = match ? accounts.filter((a) => a.siteId === match.id && a.enabled) : []; + const acc = accs.find((a) => a.hasPasskey || a.authKind === "passkey") ?? accs[0]; + setSuggested(match?.id ?? null); + setD((x) => ({ ...x, url, siteId: match?.id ?? "", siteAccountId: acc?.id ?? "" })); + }; + const suggestedSite = suggested && d.siteId === suggested ? sites.find((s) => s.id === suggested) : undefined; + const submit = () => { let filter: Record | undefined; if (d.filter.trim()) { @@ -95,7 +109,7 @@ export function FeedFormDialog({ } } const raw = { - name: d.name.trim(), + ...(d.name.trim() ? { name: d.name.trim() } : {}), purpose: d.purpose, siteId: d.siteId || null, siteAccountId: d.siteAccountId || null, @@ -109,7 +123,6 @@ export function FeedFormDialog({ const e = zodFieldErrors(parsed.error.issues); if (e.pollIntervalSec) e.pollMin = "轮询间隔至少 1 分钟"; if (e.url) e.url = feed ? "URL 格式不对" : "填写完整的 Feed URL"; - if (e.name) e.name = "名称不能为空"; setErrors(e); return; } @@ -139,7 +152,12 @@ export function FeedFormDialog({ submit(); }} > - + set("url", e.target.value)} + onChange={(e) => onUrl(e.target.value)} />
@@ -190,10 +208,35 @@ export function FeedFormDialog({ onChange={(e) => set("pollMin", e.target.value)} /> - + + 按地址域名关联到「{suggestedSite.name}」 + + + ) : undefined + } + > set("siteAccountId", v === NONE ? "" : v)} + // Radix 隐藏的原生 select 在选项列表同时变化时会回报 "",忽略 + onValueChange={(v) => v && set("siteAccountId", v === NONE ? "" : v)} > diff --git a/apps/web/app/components/workbench/feed-table.tsx b/apps/web/app/components/workbench/feed-table.tsx index efb6a116..c328511e 100644 --- a/apps/web/app/components/workbench/feed-table.tsx +++ b/apps/web/app/components/workbench/feed-table.tsx @@ -10,6 +10,7 @@ import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.ts import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { SiteFeedDialog } from "~/components/sites/site-feed-dialog.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; @@ -123,6 +124,7 @@ export function FeedTable({ const siteName = new Map((sites.data?.items ?? []).map((s) => [s.id, s.name])); const accountName = new Map((accounts.data?.items ?? []).map((a) => [a.id, a.label])); const open = feeds.data?.items.find((f) => f.id === openId) ?? null; + const scopedSite = siteId ? sites.data?.items.find((s) => s.id === siteId) : undefined; const editing: FeedSource | "new" | null = creating ? "new" : editingFeed; const setEditing = (v: FeedSource | null) => { setEditingFeed(v); @@ -286,7 +288,14 @@ export function FeedTable({ ) : null} - {editing ? ( + {editing === "new" && scopedSite ? ( + // 站点内添加:只填地址 / 用途 / 备注,其余按站点带出 + !o && setEditing(null)} + /> + ) : editing ? ( !o && setEditing(null)} diff --git a/docs/runbook/sites.md b/docs/runbook/sites.md index b5c4c6b1..4ed4cc24 100644 --- a/docs/runbook/sites.md +++ b/docs/runbook/sites.md @@ -50,6 +50,12 @@ `DELETE /sites/{id}` 在一个事务里:资源 `availability` 改为 `site_disabled`(资源和下载任务都保留,`source_release.site_id` 外键为 restrict)、Feed 停用并解除账号关联、关闭该站待处理、删全部账号(`site_user_data` 随账号级联删除)、站点行软删除(`deleted_at`,`enabled=false`,key 改为 `-deleted-<时间>`,同一定义可以重新登记)。事务提交后删除账号的全部 secret。 +## Feed(RSS)添加 + +- **站点内添加**(站点详情「快照与 Feed」→ 新建 Feed,`components/sites/site-feed-dialog.tsx`):只填地址、用途(发现 = `generic` / 追踪 = `search` / 促销 = `promotion`,缺省发现)、可选备注(即名称)。地址输入时下方预览脱敏后的显示值;域名与站点不符给黄色提示但允许保存。保存后立即 `POST /feed-sources/{id}/fetch` 一次,对话框每 2 s 轮询 `GET /feed-sources/{id}`(最多 90 s),显示首抓条目数(游标窗口 `lastGuidSet` 长度)或 `lastError`。 +- **`POST /feed-sources` 带 `siteId` 时的推断**(`service/feeds.ts feedDefaultsForSite`):`siteAccountId` 缺省取该站启用账号,优先带 passkey 的,再取最早建的;`parserVersion` 记为 `rss@1;<站点定义 umt.version>`(解析仍走通用 RSS 解析器,版本只作追溯);`name` 缺省「站点名 RSS」;`pollIntervalSec` 缺省 1800(站点请求克制;不挂站点的 Feed 仍缺省 900)。地址主机键(去 `www.`、取最后两级,IP 原样)与站点 `baseUrl` / 定义 `domain` 都不一致时,响应附 `warnings[]`(只在创建响应出现)。`name` 现在可省,不挂站点时缺省为地址主机名。 +- **来源页任意 RSS**(`components/workbench/feed-form.tsx`):完整表单不变;新建时地址域名匹配已登记站点会自动选中该站和账号并提示「按地址域名关联到 X · 不关联」,手动改过站点后不再自动改。 + ## 定时任务 | key | 名称 | 默认 | 行为 | diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts index a3beda8c..0e9987c3 100644 --- a/packages/contracts/src/sources.ts +++ b/packages/contracts/src/sources.ts @@ -328,9 +328,15 @@ export const FeedSource = EntityBase.extend({ possibleGap: z .object({ detectedAt: Timestamp, previousFetchedAt: Timestamp.nullable(), backfillQueued: z.boolean() }) .nullable(), + /** 只在创建响应里出现:不拦截的提示(如地址域名与所选站点不一致) */ + warnings: z.array(z.string()).optional(), }); export type FeedSource = z.infer; -/** url 为只写明文(可能含 passkey),服务端加密存为 secret 并生成 urlDisplay */ +/** + * url 为只写明文(可能含 passkey),服务端加密存为 secret 并生成 urlDisplay。 + * 带 siteId 时可只给 url(+ purpose):账号缺省取该站启用账号(优先带 passkey)、解析器按站点定义、名称缺省「站点名 RSS」; + * 不带 siteId 时名称缺省为地址主机名。 + */ export const FeedSourceCreate = FeedSource.pick({ siteId: true, siteAccountId: true, @@ -344,6 +350,7 @@ export const FeedSourceCreate = FeedSource.pick({ .partial({ siteId: true, siteAccountId: true, + name: true, purpose: true, kind: true, enabled: true,