import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { cursorPage, type FeedHealth, FeedSource, type FeedSourceCreate, type FeedSourceUpdate, Id, Observation, } from "@umt/contracts"; import { z } from "zod"; import { TTL_SECOND } from "~/lib/api.ts"; import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } 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 }) => fetchAllCursorPages((cursor) => apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200, cursor }), 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, }); } const ObservationPage = cursorPage(Observation); /** RSS 最近条目(观察记录,新的在前);只在详情打开时请求 */ export function useFeedObservations(feedId: string | null, limit = 20) { return useQuery({ queryKey: [...feedKeys.all, "observations", feedId, limit] as const, enabled: !!feedId, queryFn: ({ signal }) => apiParsed(ObservationPage, "/api/v1/observations", { params: { feedId: feedId ?? "", limit }, signal }), }); } /** 同站判定的主机键(与服务端 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; }