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]>
116 lines
4.2 KiB
TypeScript
116 lines
4.2 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
cursorPage,
|
||
type FeedHealth,
|
||
FeedSource,
|
||
type FeedSourceCreate,
|
||
type FeedSourceUpdate,
|
||
Id,
|
||
} from "@umt/contracts";
|
||
import { z } from "zod";
|
||
import { TTL_SECOND } from "~/lib/api.ts";
|
||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||
|
||
// Feed 来源(W2-B:/feed-sources)
|
||
|
||
const FeedPage = cursorPage(FeedSource);
|
||
|
||
export const feedKeys = {
|
||
all: ["feeds"] as const,
|
||
list: (f: { siteId?: string; health?: FeedHealth }) => ["feeds", "list", f] as const,
|
||
};
|
||
|
||
export function useFeeds(f: { siteId?: string; health?: FeedHealth } = {}) {
|
||
return useQuery({
|
||
queryKey: feedKeys.list(f),
|
||
queryFn: ({ signal }) =>
|
||
apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200 }), signal }),
|
||
refetchInterval: 30 * TTL_SECOND,
|
||
});
|
||
}
|
||
|
||
/** 单个 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({
|
||
mutationFn: (body: FeedSourceCreate) =>
|
||
apiMutate("/api/v1/feed-sources", { body }).then((d) => FeedSource.parse(d)),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||
});
|
||
}
|
||
|
||
export function useUpdateFeed() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: ({ id, body }: { id: string; body: FeedSourceUpdate }) =>
|
||
apiMutate("/api/v1/feed-sources/:id", { method: "PATCH", pathParams: { id }, body }).then((d) =>
|
||
FeedSource.parse(d),
|
||
),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||
});
|
||
}
|
||
|
||
/** 启停 / 熔断复位 */
|
||
export function useFeedAction() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: ({ id, action }: { id: string; action: "enable" | "disable" | "reset" }) =>
|
||
apiMutate(`/api/v1/feed-sources/:id/${action}`, { pathParams: { id } }).then((d) => FeedSource.parse(d)),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||
});
|
||
}
|
||
|
||
/** 立即抓取(仍受站点限流),返回任务 ID */
|
||
export function useFetchFeedNow() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: (id: string) =>
|
||
apiMutate("/api/v1/feed-sources/:id/fetch", { pathParams: { id } }).then((d) =>
|
||
z.object({ taskId: Id }).parse(d),
|
||
),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 疑似缺口:服务端在 Feed 窗口与上次零重叠时写 possibleGap 并排站点补漏;下一次抓取衔接上后清空。
|
||
* 没有服务端标记但"距上次成功超过 3 个周期"时,只作推断提示(标"可能")。
|
||
*/
|
||
export function feedGapHint(f: FeedSource, now = Date.now()): string | null {
|
||
if (f.possibleGap) {
|
||
const at = new Date(f.possibleGap.detectedAt).toLocaleString("zh-CN", { hour12: false });
|
||
return `疑似缺口(${at} 检测,${f.possibleGap.backfillQueued ? "已排补漏" : "Feed 未关联站点,无法自动补漏"})`;
|
||
}
|
||
if (!f.enabled || !f.lastSuccessAt) return null;
|
||
const gapMs = now - new Date(f.lastSuccessAt).getTime();
|
||
if (gapMs > f.pollIntervalSec * 1000 * 3) return "距上次成功已超过 3 个周期,可能漏项";
|
||
return null;
|
||
}
|