Files
UHD_Media_Tool/apps/web/app/api/feeds.ts
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +08:00

116 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ? "已排补漏" : "RSS 未关联站点,无法自动补漏"})`;
}
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;
}