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 "<site> 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 <[email protected]>
This commit is contained in:
1 parent
00ebdda7ed
commit
4a92dfd2dc
10 files changed
+512
-17
No files matched your search
@@ -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<FeedSource>(row), warnings: inferred.warnings } : feedToApi(row);
|
||||
});
|
||||
route(app, D.feedSources.update, async ({ params, body, actor }) => {
|
||||
const feed = await getFeed(params.id);
|
||||
|
||||
@@ -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<FeedItem, "guid" | "link" | "enc
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 站点 RSS 缺省轮询:30 分钟(站点请求克制:与快照下限一致;通用 Feed 仍缺省 15 分钟) */
|
||||
export const SITE_FEED_POLL_SEC = 1800;
|
||||
|
||||
/** 同站判定用的主机键:去掉 www.,取最后两级(hhanclub.net、tracker.hhanclub.net 视为同站) */
|
||||
function hostKey(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 的推断:账号(未指定时取该站启用账号,优先带 passkey 的,再取最早的)、
|
||||
* 解析器(通用 RSS 解析 + 站点定义版本,便于追溯)、名称缺省「站点名 RSS」、轮询缺省 30 分钟;地址域名与站点不符只提示不拦。
|
||||
*/
|
||||
export async function feedDefaultsForSite(
|
||||
db: Exec,
|
||||
site: Pick<SiteRow, "id" | "key" | "name" | "base_url">,
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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<FeedPurpose>("generic");
|
||||
const [note, setNote] = useState("");
|
||||
const [accountId, setAccountId] = useState(() => defaultAccount(siteAccounts));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<FeedSource | null>(null);
|
||||
const [fetchError, setFetchError] = useState<string | null>(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 (
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 {site.name} RSS</DialogTitle>
|
||||
<DialogDescription>地址加密保存,页面上只显示去掉 passkey 的地址。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{created && feed ? (
|
||||
<FirstFetch
|
||||
feed={feed}
|
||||
done={done}
|
||||
timedOut={timedOut}
|
||||
fetchError={fetchError}
|
||||
warnings={created.warnings ?? []}
|
||||
/>
|
||||
) : (
|
||||
<form
|
||||
id="site-feed-form"
|
||||
className="grid gap-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
label="RSS 地址"
|
||||
htmlFor="site-feed-url"
|
||||
required
|
||||
error={error}
|
||||
hint={
|
||||
shown && shown !== trimmed ? (
|
||||
<span className="break-all">
|
||||
保存后显示为 <span className="font-mono">{shown}</span>
|
||||
</span>
|
||||
) : (
|
||||
"站点「控制面板 → RSS」里生成的链接"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Input
|
||||
id="site-feed-url"
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={url}
|
||||
aria-invalid={!!error}
|
||||
placeholder={`${site.baseUrl.replace(/\/$/, "")}/torrentrss.php?passkey=…`}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
{mismatch ? (
|
||||
<p className="-mt-1 mb-2 text-caption text-warning">
|
||||
地址域名与 {site.name}({new URL(site.baseUrl).hostname})不一致,仍可保存;确认没有填错站点。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3" role="radiogroup" aria-label="用途">
|
||||
<p className="mb-1.5 text-caption font-medium text-foreground">用途</p>
|
||||
<div className="inline-flex flex-wrap gap-1 rounded-xl border border-border p-1">
|
||||
{PURPOSES.map((p) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={purpose === p.value}
|
||||
onClick={() => setPurpose(p.value)}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-caption transition-colors",
|
||||
purpose === p.value
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1.5 text-caption text-muted-foreground">
|
||||
{PURPOSES.find((p) => p.value === purpose)?.hint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{siteAccounts.length > 1 ? (
|
||||
<FormField label="账号" htmlFor="site-feed-account" hint="RSS 属于哪个账号(凭证失效时标记该账号)">
|
||||
<Select value={accountId} onValueChange={setAccountId}>
|
||||
<SelectTrigger id="site-feed-account" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{siteAccounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.label}
|
||||
{a.hasPasskey ? "(有 passkey)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
) : null}
|
||||
|
||||
<FormField label="备注" htmlFor="site-feed-note" hint="可选;留空显示为「站点名 RSS」">
|
||||
<Input
|
||||
id="site-feed-note"
|
||||
value={note}
|
||||
maxLength={80}
|
||||
placeholder={`${site.name} RSS`}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<p className="text-caption text-muted-foreground">
|
||||
{account ? `账号 ${account.label}` : "该站没有账号,按地址里的 passkey 抓取"} · 解析器按站点定义 · 每 30
|
||||
分钟抓一次,可在 Feed 详情里改
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-2 gap-2">
|
||||
{created ? (
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
完成
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="site-feed-form" disabled={create.isPending}>
|
||||
{create.isPending ? "保存中…" : "保存并抓取"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid gap-3 text-caption">
|
||||
<p className="font-mono break-all text-muted-foreground">{feed.urlDisplay}</p>
|
||||
{warnings.map((w) => (
|
||||
<p key={w} className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-warning">
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
{err ? (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 break-words text-destructive">
|
||||
<CircleAlert className="mt-0.5 size-4 shrink-0" />
|
||||
<span>首次抓取失败:{err}</span>
|
||||
</p>
|
||||
) : done ? (
|
||||
<p className="flex items-center gap-2 rounded-lg border border-success/40 bg-success/10 px-3 py-2 text-success">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span>已保存,首次抓取到 {count ?? 0} 条</span>
|
||||
</p>
|
||||
) : timedOut ? (
|
||||
<p className="rounded-lg border border-border px-3 py-2 text-muted-foreground">
|
||||
已保存。抓取还在排队(站点限流),结果稍后显示在 Feed 列表里。
|
||||
</p>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin" />
|
||||
<span>已保存,正在抓取…</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Draft>(() => initial(feed));
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
// 新建时按地址域名建议关联已登记站点;用户动过站点选择后不再自动改
|
||||
const [siteTouched, setSiteTouched] = useState(!!feed);
|
||||
const [suggested, setSuggested] = useState<string | null>(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 extends keyof Draft>(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<string, unknown> | 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();
|
||||
}}
|
||||
>
|
||||
<FormField label="名称" htmlFor="feed-name" required error={errors.name}>
|
||||
<FormField
|
||||
label="名称"
|
||||
htmlFor="feed-name"
|
||||
error={errors.name}
|
||||
hint={feed ? undefined : "可选;留空时用「站点名 RSS」或地址主机名"}
|
||||
>
|
||||
<Input
|
||||
id="feed-name"
|
||||
value={d.name}
|
||||
@@ -161,7 +179,7 @@ export function FeedFormDialog({
|
||||
value={d.url}
|
||||
aria-invalid={!!errors.url}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => set("url", e.target.value)}
|
||||
onChange={(e) => onUrl(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid gap-x-3 sm:grid-cols-2">
|
||||
@@ -190,10 +208,35 @@ export function FeedFormDialog({
|
||||
onChange={(e) => set("pollMin", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="站点" htmlFor="feed-site">
|
||||
<FormField
|
||||
label="站点"
|
||||
htmlFor="feed-site"
|
||||
hint={
|
||||
suggestedSite ? (
|
||||
<span>
|
||||
按地址域名关联到「{suggestedSite.name}」
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 text-foreground underline underline-offset-2"
|
||||
onClick={() => {
|
||||
setSiteTouched(true);
|
||||
setSuggested(null);
|
||||
setD((x) => ({ ...x, siteId: "", siteAccountId: "" }));
|
||||
}}
|
||||
>
|
||||
不关联
|
||||
</button>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={d.siteId || NONE}
|
||||
onValueChange={(v) => setD((x) => ({ ...x, siteId: v === NONE ? "" : v, siteAccountId: "" }))}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return;
|
||||
setSiteTouched(true);
|
||||
setD((x) => ({ ...x, siteId: v === NONE ? "" : v, siteAccountId: "" }));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="feed-site" className="w-full">
|
||||
<SelectValue />
|
||||
@@ -216,7 +259,8 @@ export function FeedFormDialog({
|
||||
<Select
|
||||
value={d.siteAccountId || NONE}
|
||||
disabled={!d.siteId}
|
||||
onValueChange={(v) => set("siteAccountId", v === NONE ? "" : v)}
|
||||
// Radix 隐藏的原生 select 在选项列表同时变化时会回报 "",忽略
|
||||
onValueChange={(v) => v && set("siteAccountId", v === NONE ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id="feed-account" className="w-full">
|
||||
<SelectValue />
|
||||
|
||||
@@ -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}
|
||||
</DetailSheet>
|
||||
|
||||
{editing ? (
|
||||
{editing === "new" && scopedSite ? (
|
||||
// 站点内添加:只填地址 / 用途 / 备注,其余按站点带出
|
||||
<SiteFeedDialog
|
||||
site={scopedSite}
|
||||
accounts={accounts.data?.items ?? []}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
/>
|
||||
) : editing ? (
|
||||
<FeedFormDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
|
||||
`DELETE /sites/{id}` 在一个事务里:资源 `availability` 改为 `site_disabled`(资源和下载任务都保留,`source_release.site_id` 外键为 restrict)、Feed 停用并解除账号关联、关闭该站待处理、删全部账号(`site_user_data` 随账号级联删除)、站点行软删除(`deleted_at`,`enabled=false`,key 改为 `<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 | 名称 | 默认 | 行为 |
|
||||
|
||||
@@ -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<typeof FeedSource>;
|
||||
/** 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,
|
||||
|
||||
Reference in new issue
Block a user