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:
rootandClaude Opus 5.5 committed 2026-09-29 14:43:19 +08:00
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;