From d9040c3a329d517fa3d8cbbfc0db27c2a87d9343 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Tue, 2 Jun 2026 21:09:19 +0100 Subject: [PATCH 01/35] =?UTF-8?q?=F0=9F=8E=88=20perf:=20=E5=8F=AF=E8=A7=86?= =?UTF-8?q?=E5=8C=96=E5=9B=BE=E8=B0=B1=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/api-docs.md | 3 +- frontend/src/App.test.tsx | 95 +++++++-- .../components/GraphVisualization.test.tsx | 164 ++++++++++----- .../src/components/GraphVisualization.tsx | 81 ++++++- frontend/src/lib/api.ts | 13 +- frontend/src/pages/VisualizationPage.tsx | 199 +++++++++++------- persistence_api/graph_projection.py | 20 +- readme.md | 2 + tests/test_graph_projection.py | 48 ++++- 9 files changed, 460 insertions(+), 165 deletions(-) diff --git a/doc/api-docs.md b/doc/api-docs.md index 7f5408e..20438de 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -875,7 +875,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - `strategy`: `degree` 或 `seed` - `limit`: 默认子图规模上限,当前最大允许 `10000` -- `sample_mode`: `off` / `count` / `percent` +- `sample_mode`: `off` / `count` / `percent`;图谱页默认不启用采样 - `sample_value`: 当采样开启时的数量或百分比;`count` 会先用固定随机种子选择一个起点,再按 BFS 扩展到目标节点数,避免返回大量互不相连的随机点 - `sample_seed`: 固定随机种子,便于复现随机起点与补充分量顺序 @@ -913,6 +913,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - `nodes` 元素沿用 `BlogRecord`,并额外携带 `x`、`y`、`degree`、`incoming_count`、`outgoing_count`、`priority_score`、`component_id` - 当 `has_stable_positions` 为 `true` 时,前端会优先使用这些坐标直接渲染,而不是首次实时跑力导布局 +- 当前图谱页用 0 到当前 blog 总数的滑块选择 `N`,默认值为 `min(200, total_blogs)`;点击确认后请求 `strategy=seed&limit=N`,直接按 blog id 升序选择前 N 个 blog 节点,并只返回这些节点之间的边。图谱节点不按 `crawl_status` 过滤,因为发现关系本身可能来自抓取失败或尚未完成的父节点;只要边的两端 blog 仍存在,就会参与图谱投影 - 当 `sample_mode != off` 时,会返回可复现的随机起点 BFS 子图;若起点所在连通分量不足目标规模,会按同一随机序列继续从其他分量 BFS 补足 - 服务在返回前会检查底层 graph 是否已变化;若当前仓库数据与最新 snapshot 不一致,会先重建 snapshot,再返回最新视图 - `snapshot_namespace` 用于区分当前 view 依赖的 snapshot 来源;当前默认值为 `legacy` diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a4c49d4..0661118 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,8 +1,15 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +const { forceGraphProps } = vi.hoisted(() => ({ + forceGraphProps: [] as Record[], +})); + vi.mock("react-force-graph-3d", () => ({ - default: () =>
, + default: (props: Record) => { + forceGraphProps.push(props); + return
; + }, })); import App from "./App"; @@ -66,10 +73,35 @@ let statusPayload = { total_edges: 10, }; +class TestResizeObserver { + callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe() { + this.callback( + [ + { + contentRect: { width: 960, height: 720 }, + } as ResizeObserverEntry, + ], + this, + ); + } + + unobserve() {} + + disconnect() {} +} + beforeEach(() => { cleanup(); vi.restoreAllMocks(); vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.stubGlobal("ResizeObserver", TestResizeObserver); + forceGraphProps.length = 0; window.history.replaceState({}, "", "/"); catalogItems = [...baseCatalogItems, makeCatalogItem(33, "PROCESSING", "Newest Processing Blog")]; window.localStorage.clear(); @@ -121,7 +153,7 @@ beforeEach(() => { return new Response(JSON.stringify(statusPayload)); } if (url.pathname === "/api/stats") { - return new Response(JSON.stringify({ total_blogs: 34, total_edges: 10 })); + return new Response(JSON.stringify({ total_blogs: statusPayload.total_blogs, total_edges: statusPayload.total_edges })); } if (url.pathname === "/api/filter-stats") { return new Response( @@ -356,7 +388,7 @@ test("adds a random blog route that loads nine finished cards and refreshes them }); }); -test("lets visualization users choose a deterministic sampled graph size", async () => { +test("lets visualization users choose a graph size with a blog-count slider", async () => { window.history.replaceState({}, "", "/visualization"); render(); @@ -366,31 +398,46 @@ test("lets visualization users choose a deterministic sampled graph size", async }); expect(screen.getByRole("dialog", { name: "选择图谱规模" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "10000" })).toBeInTheDocument(); + const slider = await screen.findByRole("slider", { name: "节点数量" }); + expect(slider).toHaveAttribute("min", "0"); + expect(slider).toHaveAttribute("max", "34"); + expect(slider).toHaveValue("34"); expect(screen.queryByText(/使用固定随机种子 42 选择起点/)).not.toBeInTheDocument(); expect(screen.queryByText(/显示实际下载大小/)).not.toBeInTheDocument(); expect(screen.queryByText("该功能仍不成熟!")).not.toBeInTheDocument(); expect(screen.queryByText("数据统计")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "500" })); + fireEvent.change(slider, { target: { value: "20" } }); + expect(slider).toHaveValue("20"); + fireEvent.click(screen.getByRole("button", { name: "确认" })); await waitFor(() => { - expect(screen.queryByRole("dialog", { name: "选择图谱规模" })).not.toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: "正在渲染图谱" })).toBeInTheDocument(); }); expect(fetch).toHaveBeenCalledWith( - expect.stringContaining( - "/api/graph/views/core?strategy=degree&limit=500&sample_mode=count&sample_value=500&sample_seed=42", - ), + expect.stringContaining("/api/graph/views/core?strategy=seed&limit=20"), expect.anything(), ); - expect(screen.queryByText(/当前使用固定随机种子 42 展示 500 个节点/)).not.toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "12"); + act(() => { + forceGraphProps.at(-1)!.onEngineTick(); + forceGraphProps.at(-1)!.onEngineTick(); + }); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "12"); + act(() => { + forceGraphProps.at(-1)!.onEngineStop(); + }); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "正在渲染图谱" })).not.toBeInTheDocument(); + }); + expect(screen.queryByText(/当前使用固定随机种子 42 展示 20 个节点/)).not.toBeInTheDocument(); expect(screen.queryByText("全图最大节点数")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /刷新全图|返回全图/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /搜索博客/i })).not.toBeInTheDocument(); }); -test("uses cached visualization graph data for repeated sampled sizes", async () => { +test("ignores stale cached visualization graph data and reloads sampled sizes online", async () => { window.history.replaceState({}, "", "/visualization"); window.localStorage.setItem( "heyblog:visualization:3d-v1:seed-42:limit-200", @@ -414,10 +461,30 @@ test("uses cached visualization graph data for repeated sampled sizes", async () expect(screen.getByRole("dialog", { name: "选择图谱规模" })).toBeInTheDocument(); }); - fireEvent.click(screen.getByRole("button", { name: "200" })); + fireEvent.change(screen.getByRole("slider", { name: "节点数量" }), { target: { value: "20" } }); + fireEvent.click(screen.getByRole("button", { name: "确认" })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/graph/views/core?strategy=seed&limit=20"), + expect.anything(), + ); + }); +}); + +test("defaults visualization slider to two hundred when the blog count is larger", async () => { + statusPayload = { + ...statusPayload, + total_blogs: 500, + }; + window.history.replaceState({}, "", "/visualization"); + + render(); + + const slider = await screen.findByRole("slider", { name: "节点数量" }); - expect(screen.queryByText(/当前使用固定随机种子 42 展示 200 个节点/)).not.toBeInTheDocument(); - expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining("/api/graph/views/core"), expect.anything()); + expect(slider).toHaveAttribute("max", "500"); + expect(slider).toHaveValue("200"); }); test("adds a public filter stats route that renders success-source split", async () => { diff --git a/frontend/src/components/GraphVisualization.test.tsx b/frontend/src/components/GraphVisualization.test.tsx index 62d5d1d..3ced7a2 100644 --- a/frontend/src/components/GraphVisualization.test.tsx +++ b/frontend/src/components/GraphVisualization.test.tsx @@ -1,56 +1,85 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { GraphVisualization } from "./GraphVisualization"; +import { tuneNaturalClusterForces } from "./GraphVisualization"; +import type { ForwardedRef } from "react"; import type { GraphData } from "../types/graph"; -const { forceGraphRenders, ForceGraph3DMock } = vi.hoisted(() => { - const forceGraphRenders: Record[] = []; - - function ForceGraph3DMock(props: Record) { - forceGraphRenders.push(props); - const { ref, onNodeClick, graphData } = props; - if (ref) { - ref.current = { - d3Force: vi.fn(() => ({ - strength: vi.fn(), - distance: vi.fn(), - })), - d3ReheatSimulation: vi.fn(), - zoomToFit: vi.fn(), - camera: vi.fn(() => ({ - position: { - clone: () => ({ - normalize: () => ({ - multiplyScalar: () => ({ x: 0, y: 0, z: 360 }), +const { chargeForce, d3ReheatSimulation, forceCalls, forceGraphRenders, ForceGraph3DMock, linkForce } = vi.hoisted( + () => { + const forceGraphRenders: Record[] = []; + const forceCalls: Array<[string, unknown?]> = []; + const chargeForce = { + strength: vi.fn(), + distanceMax: vi.fn(), + }; + const linkForce = { + strength: vi.fn(), + distance: vi.fn(), + }; + const d3ReheatSimulation = vi.fn(); + + function ForceGraph3DMock(props: Record, ref: ForwardedRef>) { + forceGraphRenders.push(props); + const { onNodeClick, graphData } = props; + const resolvedRef = ref ?? props.ref; + if (resolvedRef) { + const graphInstance = { + d3Force: vi.fn((name: string, force?: unknown) => { + forceCalls.push([name, force]); + if (name === "charge") { + return chargeForce; + } + if (name === "link") { + return linkForce; + } + return undefined; + }), + d3ReheatSimulation, + zoomToFit: vi.fn(), + camera: vi.fn(() => ({ + position: { + clone: () => ({ + normalize: () => ({ + multiplyScalar: () => ({ x: 0, y: 0, z: 360 }), + }), }), - }), - length: () => 360, - copy: vi.fn(), - }, - })), - controls: vi.fn(() => ({ update: vi.fn() })), - cameraPosition: vi.fn(), - }; + length: () => 360, + copy: vi.fn(), + }, + })), + controls: vi.fn(() => ({ update: vi.fn() })), + cameraPosition: vi.fn(), + }; + if (typeof resolvedRef === "function") { + resolvedRef(graphInstance); + } else { + resolvedRef.current = graphInstance; + } + } + + return ( + + ); } - return ( - - ); - } + return { chargeForce, d3ReheatSimulation, forceCalls, forceGraphRenders, ForceGraph3DMock, linkForce }; + }, +); - return { forceGraphRenders, ForceGraph3DMock }; +vi.mock("react-force-graph-3d", async () => { + const React = await vi.importActual("react"); + return { + default: React.forwardRef(ForceGraph3DMock), + }; }); -vi.mock("react-force-graph-3d", () => ({ - default: ForceGraph3DMock, -})); - vi.mock("three", async () => { const actual = await vi.importActual("three"); return { @@ -137,6 +166,12 @@ class TestResizeObserver { beforeEach(() => { forceGraphRenders.length = 0; + forceCalls.length = 0; + chargeForce.strength.mockClear(); + chargeForce.distanceMax.mockClear(); + linkForce.strength.mockClear(); + linkForce.distance.mockClear(); + d3ReheatSimulation.mockClear(); vi.stubGlobal("ResizeObserver", TestResizeObserver); }); @@ -230,10 +265,20 @@ describe("GraphVisualization", () => { const graphProps = forceGraphRenders.at(-1); const [selectedLink, unrelatedLink] = graphProps!.graphData.links; - expect(graphProps!.linkWidth(selectedLink)).toBe(2); - expect(graphProps!.linkColor(selectedLink)).toBe("rgba(125, 211, 252, 0.78)"); - expect(graphProps!.linkWidth(unrelatedLink)).toBe(0.35); - expect(graphProps!.linkColor(unrelatedLink)).toBe("rgba(71, 85, 105, 0.16)"); + expect(graphProps!.linkWidth(selectedLink)).toBe(3.2); + expect(graphProps!.linkColor(selectedLink)).toBe("rgba(240, 249, 255, 1)"); + expect(graphProps!.linkWidth(unrelatedLink)).toBe(0.9); + expect(graphProps!.linkColor(unrelatedLink)).toBe("rgba(186, 230, 253, 0.55)"); + }); + + test("uses brighter default link color on the dark graph background", () => { + render(); + + const graphProps = forceGraphRenders.at(-1); + const [defaultLink] = graphProps!.graphData.links; + + expect(graphProps!.linkWidth(defaultLink)).toBe(1.6); + expect(graphProps!.linkColor(defaultLink)).toBe("rgba(224, 242, 254, 0.78)"); }); test("exposes icon-only zoom and reset controls", () => { @@ -254,4 +299,29 @@ describe("GraphVisualization", () => { expect(nodeObject.children).toHaveLength(3); expect(nodeObject.userData.iconUrl).toBe("https://icons.duckduckgo.com/ip3/alpha.example.com.ico"); }); + + test("tunes forces for natural clusters instead of a centered sphere", () => { + const graph = { + d3Force: vi.fn((name: string, force?: unknown) => { + forceCalls.push([name, force]); + if (name === "charge") { + return chargeForce; + } + if (name === "link") { + return linkForce; + } + return undefined; + }), + d3ReheatSimulation, + }; + + tuneNaturalClusterForces(graph as never); + + expect(forceCalls).toContainEqual(["center", null]); + expect(chargeForce.strength).toHaveBeenCalledWith(-95); + expect(chargeForce.distanceMax).toHaveBeenCalledWith(920); + expect(linkForce.distance).toHaveBeenCalledWith(72); + expect(linkForce.strength).toHaveBeenCalledWith(0.34); + expect(d3ReheatSimulation).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/GraphVisualization.tsx b/frontend/src/components/GraphVisualization.tsx index c1b04c7..409252c 100644 --- a/frontend/src/components/GraphVisualization.tsx +++ b/frontend/src/components/GraphVisualization.tsx @@ -5,10 +5,18 @@ import * as THREE from "three"; import { resolveBlogIconUrl } from "../lib/icon"; import type { GraphData, GraphEdge, GraphNode } from "../types/graph"; +export const GRAPH_RENDER_COOLDOWN_TICKS = 120; +const GRAPH_LINK_DISTANCE = 72; +const GRAPH_LINK_STRENGTH = 0.34; +const GRAPH_CHARGE_STRENGTH = -95; +const GRAPH_CHARGE_DISTANCE_MAX = 920; + interface GraphVisualizationProps { data: GraphData; onNodeClick?: (node: GraphNode) => void; highlightNodeId?: number; + onRenderProgress?: (progress: number) => void; + onRenderComplete?: () => void; } interface RenderNode extends Omit { @@ -188,6 +196,34 @@ function createNodeObject(node: RenderNode, color: string, size: number): THREE. return group; } +/** + * Tune the d3 force engine so related blogs cluster without a global spherical pull. + * + * @param graph Force graph instance exposed by react-force-graph-3d. + */ +export function tuneNaturalClusterForces(graph: ForceGraphMethods): void { + graph.d3Force("center", null); + + const chargeForce = graph.d3Force("charge") as + | { + strength?: (value: number) => unknown; + distanceMax?: (value: number) => unknown; + } + | undefined; + chargeForce?.strength?.(GRAPH_CHARGE_STRENGTH); + chargeForce?.distanceMax?.(GRAPH_CHARGE_DISTANCE_MAX); + + const linkForce = graph.d3Force("link") as + | { + distance?: (value: number) => unknown; + strength?: (value: number) => unknown; + } + | undefined; + linkForce?.distance?.(GRAPH_LINK_DISTANCE); + linkForce?.strength?.(GRAPH_LINK_STRENGTH); + graph.d3ReheatSimulation(); +} + /** * Render an interactive 3D force graph for blog relationship exploration. * @@ -196,9 +232,16 @@ function createNodeObject(node: RenderNode, color: string, size: number): THREE. * @param highlightNodeId Selected node id to emphasize. * @returns Graph container with 3D canvas and controls. */ -export function GraphVisualization({ data, onNodeClick, highlightNodeId }: GraphVisualizationProps) { +export function GraphVisualization({ + data, + onNodeClick, + highlightNodeId, + onRenderProgress, + onRenderComplete, +}: GraphVisualizationProps) { const graphRef = useRef | undefined>(undefined); const containerRef = useRef(null); + const renderTickRef = useRef(0); const [size, setSize] = useState({ width: 960, height: 720 }); const [isMeasured, setIsMeasured] = useState(false); const graphData = useMemo(() => buildGraphData(data), [data]); @@ -221,6 +264,18 @@ export function GraphVisualization({ data, onNodeClick, highlightNodeId }: Graph return () => observer.disconnect(); }, []); + useEffect(() => { + renderTickRef.current = 0; + }, [graphData]); + + useEffect(() => { + const graph = graphRef.current; + if (!graph || graphData.nodes.length === 0) { + return; + } + tuneNaturalClusterForces(graph); + }, [graphData]); + useEffect(() => { const graph = graphRef.current; if (!graph || !selectedGraphId) { @@ -259,6 +314,16 @@ export function GraphVisualization({ data, onNodeClick, highlightNodeId }: Graph graphRef.current?.zoomToFit(650, 80); }, []); + const handleEngineTick = useCallback(() => { + renderTickRef.current += 1; + onRenderProgress?.(Math.min(renderTickRef.current / GRAPH_RENDER_COOLDOWN_TICKS, 0.98)); + }, [onRenderProgress]); + + const handleEngineStop = useCallback(() => { + onRenderProgress?.(1); + onRenderComplete?.(); + }, [onRenderComplete, onRenderProgress]); + return (
@@ -281,17 +346,17 @@ export function GraphVisualization({ data, onNodeClick, highlightNodeId }: Graph linkTarget="target" linkColor={(link: RenderLink) => { if (!selectedGraphId) { - return "rgba(148, 163, 184, 0.28)"; + return "rgba(224, 242, 254, 0.78)"; } return sourceIdOf(link) === selectedGraphId || targetIdOf(link) === selectedGraphId - ? "rgba(125, 211, 252, 0.78)" - : "rgba(71, 85, 105, 0.16)"; + ? "rgba(240, 249, 255, 1)" + : "rgba(186, 230, 253, 0.55)"; }} linkWidth={(link: RenderLink) => { if (!selectedGraphId) { - return 0.8; + return 1.6; } - return sourceIdOf(link) === selectedGraphId || targetIdOf(link) === selectedGraphId ? 2 : 0.35; + return sourceIdOf(link) === selectedGraphId || targetIdOf(link) === selectedGraphId ? 3.2 : 0.9; }} linkDirectionalArrowLength={3.5} linkDirectionalArrowRelPos={1} @@ -307,7 +372,9 @@ export function GraphVisualization({ data, onNodeClick, highlightNodeId }: Graph }} d3VelocityDecay={0.38} d3AlphaDecay={0.025} - cooldownTicks={120} + cooldownTicks={GRAPH_RENDER_COOLDOWN_TICKS} + onEngineTick={handleEngineTick} + onEngineStop={handleEngineStop} controlType="orbit" /> ) : null} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 830a535..928aef7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -544,22 +544,13 @@ function toAuthSession(session: BackendAuthSession): AuthSession { * Fetch the default core graph view. * * @param limit Maximum node count requested for the core graph. - * @param options Optional deterministic sampling settings for graph selection. * @returns Normalized graph data. */ -export async function fetchGraphData( - limit = 200, - options: { sampleMode?: "off" | "count" | "percent"; sampleSeed?: number } = {}, -): Promise { +export async function fetchGraphData(limit = 200): Promise { const params = new URLSearchParams({ - strategy: "degree", + strategy: "seed", limit: String(limit), }); - if (options.sampleMode && options.sampleMode !== "off") { - params.set("sample_mode", options.sampleMode); - params.set("sample_value", String(limit)); - params.set("sample_seed", String(options.sampleSeed ?? 42)); - } const payload = await apiJson(`/api/graph/views/core?${params.toString()}`); return toGraphData(payload); } diff --git a/frontend/src/pages/VisualizationPage.tsx b/frontend/src/pages/VisualizationPage.tsx index 167c0b4..63b6ca3 100644 --- a/frontend/src/pages/VisualizationPage.tsx +++ b/frontend/src/pages/VisualizationPage.tsx @@ -1,51 +1,14 @@ import { Loader2 } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { BlogDetailPanel } from "../components/BlogDetailPanel"; import { GraphVisualization } from "../components/GraphVisualization"; import { Navigation } from "../components/Navigation"; -import { fetchBlogDetail, fetchGraphData, fetchSubgraph } from "../lib/api"; +import { fetchBlogDetail, fetchGraphData, fetchStats, fetchSubgraph } from "../lib/api"; import type { BlogDetail, GraphData, GraphNode } from "../types/graph"; -const GRAPH_LIMIT_OPTIONS = [200, 500, 1000, 10000] as const; -const GRAPH_SAMPLE_SEED = 42; -const GRAPH_CACHE_VERSION = "3d-v1"; - -type GraphLimit = (typeof GRAPH_LIMIT_OPTIONS)[number]; - -function graphCacheKey(limit: GraphLimit): string { - return `heyblog:visualization:${GRAPH_CACHE_VERSION}:seed-${GRAPH_SAMPLE_SEED}:limit-${limit}`; -} - -function readCachedGraph(limit: GraphLimit): GraphData | null { - try { - const raw = window.localStorage.getItem(graphCacheKey(limit)); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw) as GraphData; - if (Array.isArray(parsed.nodes) && Array.isArray(parsed.edges)) { - return parsed; - } - } catch { - window.localStorage.removeItem(graphCacheKey(limit)); - } - return null; -} - -function graphPayloadSizeMb(data: GraphData): string { - const bytes = new TextEncoder().encode(JSON.stringify(data)).length; - return (bytes / (1024 * 1024)).toFixed(2); -} - -function writeCachedGraph(limit: GraphLimit, data: GraphData): void { - try { - window.localStorage.setItem(graphCacheKey(limit), JSON.stringify(data)); - } catch { - // Browsers can reject large localStorage writes; graph rendering should still continue. - } -} +const DEFAULT_GRAPH_LIMIT = 200; /** * Render the dedicated graph exploration route. @@ -57,10 +20,22 @@ export function VisualizationPage() { const [graphData, setGraphData] = useState({ nodes: [], edges: [] }); const [blogDetail, setBlogDetail] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [selectedLimit, setSelectedLimit] = useState(null); - const [graphSizeMb, setGraphSizeMb] = useState(null); - const [usedCachedGraph, setUsedCachedGraph] = useState(false); + const [isStatsLoading, setIsStatsLoading] = useState(true); + const [isRendering, setIsRendering] = useState(false); + const [renderProgress, setRenderProgress] = useState(0); + const [maxGraphLimit, setMaxGraphLimit] = useState(0); + const [pendingLimit, setPendingLimit] = useState(DEFAULT_GRAPH_LIMIT); + const [selectedLimit, setSelectedLimit] = useState(null); const [highlightNodeId, setHighlightNodeId] = useState(); + const shouldShowProgressOverlay = isLoading || isRendering; + const progressPercent = useMemo(() => { + const loadingFloor = isLoading ? 0.08 : 0; + return Math.round(Math.max(loadingFloor, renderProgress) * 100); + }, [isLoading, renderProgress]); + + useEffect(() => { + void loadGraphLimitBounds(); + }, []); useEffect(() => { const highlight = searchParams.get("highlight"); @@ -74,34 +49,50 @@ export function VisualizationPage() { void openBlog(blogId, { loadNeighborhood: true }); }, [searchParams]); + /** + * Load the current graph-size slider range from public stats. + * + * @returns Promise resolved after slider bounds update. + */ + async function loadGraphLimitBounds() { + try { + setIsStatsLoading(true); + const stats = await fetchStats(); + const totalBlogs = Math.max(0, stats.totalNodes); + setMaxGraphLimit(totalBlogs); + setPendingLimit(Math.min(DEFAULT_GRAPH_LIMIT, totalBlogs)); + } catch { + toast.error("图谱规模加载失败,请刷新页面重试。"); + setMaxGraphLimit(DEFAULT_GRAPH_LIMIT); + setPendingLimit(DEFAULT_GRAPH_LIMIT); + } finally { + setIsStatsLoading(false); + } + } + /** * Load the selected graph size using deterministic backend sampling. * * @param limit Requested node count. * @returns Promise resolved after graph state updates. */ - async function loadFullGraph(limit: GraphLimit) { + async function loadFullGraph(limit: number) { setSelectedLimit(limit); setBlogDetail(null); setHighlightNodeId(undefined); - - const cachedGraph = readCachedGraph(limit); - if (cachedGraph) { - setGraphData(cachedGraph); - setGraphSizeMb(graphPayloadSizeMb(cachedGraph)); - setUsedCachedGraph(true); - return; - } + setIsRendering(false); + setRenderProgress(0); try { - setUsedCachedGraph(false); setIsLoading(true); - const graphResponse = await fetchGraphData(limit, { sampleMode: "count", sampleSeed: GRAPH_SAMPLE_SEED }); + const graphResponse = await fetchGraphData(limit); + setRenderProgress(0.12); + setIsRendering(true); setGraphData(graphResponse); - setGraphSizeMb(graphPayloadSizeMb(graphResponse)); - writeCachedGraph(limit, graphResponse); } catch { setSelectedLimit(null); + setIsRendering(false); + setRenderProgress(0); toast.error("图谱加载失败,请刷新页面重试。"); } finally { setIsLoading(false); @@ -156,11 +147,20 @@ export function VisualizationPage() {
- + setRenderProgress((current) => Math.max(current, progress))} + onRenderComplete={() => { + setRenderProgress(1); + setIsRendering(false); + }} + /> {blogDetail ? : null}
- {!selectedLimit || isLoading ? ( + {!selectedLimit || shouldShowProgressOverlay ? (
-

- 选择图谱规模 -

-
- {GRAPH_LIMIT_OPTIONS.map((limit) => ( - +
+ )} + + ) : ( +
+

+ 正在渲染图谱 +

+
+ + {isLoading ? "正在加载图谱数据..." : "正在计算 3D 力导布局..."} +
+
- {limit} - - ))} -
- {isLoading ? ( -
- - 正在加载图谱数据... +
+
+
{progressPercent}%
- ) : null} + )}
) : null} diff --git a/persistence_api/graph_projection.py b/persistence_api/graph_projection.py index 74d521f..6caba12 100644 --- a/persistence_api/graph_projection.py +++ b/persistence_api/graph_projection.py @@ -139,12 +139,13 @@ def _available_graph( blogs: list[dict[str, Any]], edges: list[dict[str, Any]], ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - nodes = [dict(blog) for blog in blogs if str(blog.get("crawl_status")) == "FINISHED"] - finished_ids = {int(node["id"]) for node in nodes} + """Return graph nodes and edges whose endpoints still exist in blogs.""" + nodes = [dict(blog) for blog in blogs] + node_ids = {int(node["id"]) for node in nodes} filtered_edges = [ dict(edge) for edge in edges - if int(edge["from_blog_id"]) in finished_ids and int(edge["to_blog_id"]) in finished_ids + if int(edge["from_blog_id"]) in node_ids and int(edge["to_blog_id"]) in node_ids ] return nodes, filtered_edges @@ -418,7 +419,7 @@ def build_core_graph_view( """Return the default structured subgraph view.""" nodes = snapshot["nodes"] edges = snapshot["edges"] - limit = _clamp_int(limit, 24, MAX_CORE_LIMIT) + limit = _clamp_int(limit, 0, MAX_CORE_LIMIT) sampled_ids = _sample_node_ids( nodes, edges, @@ -443,7 +444,16 @@ def build_core_graph_view( adjacency, _, _ = _build_adjacency(filtered_nodes, edges) ordered_nodes = _sorted_nodes(filtered_nodes) if strategy == "seed": - seed_nodes = sorted(filtered_nodes, key=lambda node: int(node["id"]))[: min(len(filtered_nodes), 18)] + selected_ids = {int(node["id"]) for node in sorted(filtered_nodes, key=lambda node: int(node["id"]))[:limit]} + return _build_view_payload( + snapshot, + selected_ids, + strategy=strategy, + limit=limit, + sample_mode=sample_mode, + sample_value=sample_value, + sample_seed=sample_seed, + ) else: strategy = "degree" seed_nodes = ordered_nodes[: min(len(ordered_nodes), max(12, limit // 4))] diff --git a/readme.md b/readme.md index 793d111..6730dcc 100644 --- a/readme.md +++ b/readme.md @@ -19,6 +19,8 @@ 2026年5月29日,有了第一个fork,也是人生中第一次fork,开心 +2026年6月1日,摆脱oyyt为本项目画了一个虚拟形象,开心 + ## 文档导航 diff --git a/tests/test_graph_projection.py b/tests/test_graph_projection.py index 96bcd5e..acb6d89 100644 --- a/tests/test_graph_projection.py +++ b/tests/test_graph_projection.py @@ -178,14 +178,58 @@ def test_core_view_count_sampling_expands_from_random_seed_by_bfs() -> None: assert {edge["id"] for edge in payload["edges"]} == {13, 14} -def test_core_view_seed_strategy_prefers_oldest_nodes() -> None: +def test_core_view_seed_strategy_returns_first_n_nodes_by_id() -> None: blogs, edges = sample_graph() + for blog_id in range(4, 32): + blogs.append( + { + "id": blog_id, + "url": f"https://extra-{blog_id}.example", + "normalized_url": f"https://extra-{blog_id}.example", + "domain": f"extra-{blog_id}.example", + "title": f"Extra {blog_id}", + "icon_url": None, + "status_code": 200, + "crawl_status": "FINISHED", + "friend_links_count": 0, + "last_crawled_at": None, + "created_at": "2026-03-31T00:00:00Z", + "updated_at": "2026-03-31T00:00:00Z", + }, + ) snapshot = build_graph_snapshot_payload(blogs, edges, version="v1", generated_at="2026-03-31T00:00:00Z") + payload = build_core_graph_view(snapshot, strategy="seed", limit=24) + + assert payload["meta"]["strategy"] == "seed" + assert {node["id"] for node in payload["nodes"]} == set(range(1, 25)) + assert {edge["id"] for edge in payload["edges"]} == {11, 12} + + +def test_core_view_seed_strategy_keeps_failed_parent_discovery_edges() -> None: + blogs, edges = sample_graph() + blogs[0]["crawl_status"] = "FAILED" + + snapshot = build_graph_snapshot_payload(blogs, edges, version="v1", generated_at="2026-03-31T00:00:00Z") payload = build_core_graph_view(snapshot, strategy="seed", limit=2) + assert {node["id"] for node in payload["nodes"]} == {1, 2} + assert {edge["id"] for edge in payload["edges"]} == {11} + node_by_id = {node["id"]: node for node in payload["nodes"]} + assert node_by_id[1]["crawl_status"] == "FAILED" + assert node_by_id[2]["incoming_count"] == 1 + + +def test_core_view_seed_strategy_allows_zero_nodes() -> None: + blogs, edges = sample_graph() + snapshot = build_graph_snapshot_payload(blogs, edges, version="v1", generated_at="2026-03-31T00:00:00Z") + + payload = build_core_graph_view(snapshot, strategy="seed", limit=0) + assert payload["meta"]["strategy"] == "seed" - assert {node["id"] for node in payload["nodes"][:2]} == {1, 2} + assert payload["meta"]["limit"] == 0 + assert payload["nodes"] == [] + assert payload["edges"] == [] def test_core_view_allows_ten_thousand_node_limit() -> None: From e3e168377883b0625dc75e8a8e5e3197a7c81a09 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Tue, 2 Jun 2026 21:46:25 +0100 Subject: [PATCH 02/35] =?UTF-8?q?=F0=9F=A6=84=20refactor:=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=95=B0=E6=8D=AE=E5=BA=93=EF=BC=8C=E5=B0=86=E7=88=AC?= =?UTF-8?q?=E8=99=AB=E7=8A=B6=E6=80=81=E5=92=8C=E5=8D=9A=E5=AE=A2=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=8B=86=E5=88=86=EF=BC=8C=E4=BD=BF=E5=85=B6=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E4=B8=8D=E6=B7=B7=E6=B7=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260602_01_add_blog_acceptance_status.py | 115 ++++++++++++++++++ backend/main.py | 2 + crawler/crawling/bootstrap.py | 1 + crawler/crawling/orchestrator.py | 1 + crawler/crawling/pipeline.py | 25 ++++ doc/api-docs.md | 10 ++ doc/config-reference.md | 2 +- doc/crawler-url-filtering.md | 3 +- persistence_api/main.py | 5 + persistence_api/models.py | 7 ++ persistence_api/repository.py | 96 ++++++++++++++- shared/http_clients/persistence_http.py | 8 ++ tests/test_repository.py | 89 ++++++++++++++ 13 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/20260602_01_add_blog_acceptance_status.py diff --git a/alembic/versions/20260602_01_add_blog_acceptance_status.py b/alembic/versions/20260602_01_add_blog_acceptance_status.py new file mode 100644 index 0000000..f1090d5 --- /dev/null +++ b/alembic/versions/20260602_01_add_blog_acceptance_status.py @@ -0,0 +1,115 @@ +"""Split blog acceptance from crawl execution status. + +Revision ID: 20260602_01 +Revises: 20260601_01 +Create Date: 2026-06-02 21:30:29 BST +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260602_01" +down_revision = "20260601_01" +branch_labels = None +depends_on = None + + +def _columns(table_name: str) -> set[str]: + """Return currently present column names for one table. + + Args: + table_name: Database table name to inspect. + + Returns: + Set of column names currently present in the database. + """ + return {column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def upgrade() -> None: + """Add acceptance and crawl-error fields, then backfill accepted graph rows. + + Args: + None. + + Returns: + None. The migration mutates the active database schema in place. + """ + blog_columns = _columns("blogs") + if "acceptance_status" not in blog_columns: + op.add_column( + "blogs", + sa.Column("acceptance_status", sa.Text(), nullable=False, server_default="UNKNOWN"), + ) + for column_name in ( + "accepted_by", + "crawl_error_kind", + "crawl_error_message", + ): + if column_name not in blog_columns: + op.add_column("blogs", sa.Column(column_name, sa.Text(), nullable=True)) + for column_name in ( + "accepted_at", + "last_crawl_attempt_at", + "successful_crawl_at", + ): + if column_name not in blog_columns: + op.add_column("blogs", sa.Column(column_name, sa.DateTime(timezone=True), nullable=True)) + + op.execute( + """ + UPDATE blogs b + SET acceptance_status = 'ACCEPTED', + accepted_by = COALESCE(b.accepted_by, r.accepted_by, 'unknown'), + accepted_at = COALESCE(b.accepted_at, r.updated_at, b.created_at) + FROM raw_discovered_urls r + WHERE b.normalized_url = r.normalized_url + AND r.status = 'success' + AND b.acceptance_status = 'UNKNOWN' + """ + ) + op.execute( + """ + UPDATE blogs + SET acceptance_status = 'ACCEPTED', + accepted_by = COALESCE(accepted_by, 'seed'), + accepted_at = COALESCE(accepted_at, created_at) + WHERE acceptance_status = 'UNKNOWN' + AND blog_id NOT IN (SELECT to_blog_id FROM edges) + """ + ) + op.execute( + """ + UPDATE blogs + SET acceptance_status = 'ACCEPTED', + accepted_by = COALESCE(accepted_by, 'graph'), + accepted_at = COALESCE(accepted_at, created_at) + WHERE acceptance_status = 'UNKNOWN' + AND blog_id IN (SELECT from_blog_id FROM edges UNION SELECT to_blog_id FROM edges) + """ + ) + + +def downgrade() -> None: + """Remove acceptance and crawl-error fields. + + Args: + None. + + Returns: + None. The migration mutates the active database schema in place. + """ + for column_name in ( + "successful_crawl_at", + "last_crawl_attempt_at", + "crawl_error_message", + "crawl_error_kind", + "accepted_at", + "accepted_by", + "acceptance_status", + ): + if column_name in _columns("blogs"): + op.drop_column("blogs", column_name) diff --git a/backend/main.py b/backend/main.py index 3a86d0b..bd99f13 100644 --- a/backend/main.py +++ b/backend/main.py @@ -436,6 +436,7 @@ def get_blogs_catalog( has_title: str | None = None, has_icon: str | None = None, min_connections: str | None = None, + acceptance_status: str | None = "ACCEPTED", ) -> dict[str, Any]: return _call_upstream_with_http_error_translation( lambda: get_state().persistence.list_blogs_catalog( @@ -450,6 +451,7 @@ def get_blogs_catalog( has_title=has_title, has_icon=has_icon, min_connections=min_connections, + acceptance_status=acceptance_status, ) ) diff --git a/crawler/crawling/bootstrap.py b/crawler/crawling/bootstrap.py index 5d150b5..81cae71 100644 --- a/crawler/crawling/bootstrap.py +++ b/crawler/crawling/bootstrap.py @@ -57,6 +57,7 @@ def bootstrap_seeds(self, seed_path: Path) -> dict[str, Any]: url=raw_url, normalized_url=normalized.normalized_url, domain=normalized.domain, + accepted_by="seed", ) created += int(inserted) self.logger.bootstrap_success(seed_path) diff --git a/crawler/crawling/orchestrator.py b/crawler/crawling/orchestrator.py index e9ec5f2..194a3af 100644 --- a/crawler/crawling/orchestrator.py +++ b/crawler/crawling/orchestrator.py @@ -242,6 +242,7 @@ def _store_page_links( normalized_url=normalized.normalized_url, domain=normalized.domain, feed_url=decision.feed_url, + accepted_by=decision.accepted_by, ) edge = FriendLinkEdge( from_blog_id=blog.id, diff --git a/crawler/crawling/pipeline.py b/crawler/crawling/pipeline.py index 54fabe1..ddef372 100644 --- a/crawler/crawling/pipeline.py +++ b/crawler/crawling/pipeline.py @@ -26,6 +26,29 @@ ShouldStopHook = Callable[[], bool] +def _crawl_error_kind(error: Exception) -> str: + """Return a stable crawl failure category for persistence. + + Args: + error: Exception raised while processing one blog. + + Returns: + Machine-readable failure kind used to separate retryable crawl errors + from blog acceptance semantics. + """ + + if isinstance(error, PageTooLargeError): + return "page_too_large" + if isinstance(error, TimeoutError): + return "timeout" + error_name = type(error).__name__.lower() + if "http" in error_name: + return "http_status" + if "request" in error_name: + return "request_error" + return "crawl_error" + + class CrawlPipeline: """Coordinate seed bootstrap, one-shot crawl batches, and export writing. @@ -292,6 +315,8 @@ def _mark_blog_failed(self, blog_id: int, error: Exception) -> None: crawl_status=state.status, status_code=state.status_code, friend_links_count=state.friend_links_count, + crawl_error_kind=_crawl_error_kind(error), + crawl_error_message=str(error)[:1000], ) self.logger.crawl_error(blog_id=blog_id, error=error) diff --git a/doc/api-docs.md b/doc/api-docs.md index 20438de..84e8cc0 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -395,6 +395,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - `url`: URL 筛选,匹配 `url` / `normalized_url` - `status`: 抓取状态精确筛选;会先做 `trim + uppercase`,仅允许 `WAITING`、`PROCESSING`、`FINISHED`、`FAILED` - `statuses`: 多状态筛选,逗号分隔;会对每个值做 `trim + uppercase`,仅允许 `WAITING`、`PROCESSING`、`FINISHED`、`FAILED` +- `acceptance_status`: 博客接受状态筛选,默认 `ACCEPTED`;允许 `ACCEPTED`、`UNKNOWN`、`REJECTED`。该字段表示 URL 是否已被 seed、RSS 或模型确认为博客,独立于 `crawl_status` - `sort`: 排序方式,允许 `id_asc`、`id_desc`、`recent_activity`、`connections`、`recently_discovered`、`random` - `has_title`: 是否要求有标题;支持布尔值,也接受 `1/0`、`true/false`、`yes/no` - `has_icon`: 是否要求有 icon;支持布尔值,也接受 `1/0`、`true/false`、`yes/no` @@ -405,8 +406,10 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 空白字符串会被视为未传参 - 非法 `status` 返回 `422` - 非法 `statuses` 返回 `422` +- 非法 `acceptance_status` 返回 `422` - 非法 `sort` 返回 `422` - 当 `statuses` 存在时优先于 `status`,用于同时查询多个 `crawl_status` +- 默认只返回 `acceptance_status=ACCEPTED` 的 URL;`crawl_status=FAILED` 只表示最近一次抓取尝试失败,不表示该 URL 不是博客 - `has_title` / `has_icon` 仅在传入真值时启用过滤;传入假值会保留参数值但不额外筛掉空字段记录 - `id_asc` 按业务 `blog_id ASC` - `recent_activity` 按 `activity_at DESC, connection_count DESC, blog_id DESC` @@ -1840,6 +1843,13 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 | `title` | `string \| null` | 站点主页解析出的 ``,缺失时为 `null` | | `icon_url` | `string \| null` | 站点标签页 icon URL;优先使用页面声明的 icon 链接,缺失时可能回退为 `${origin}/favicon.ico` | | `status_code` | `number \| null` | 最近抓取 HTTP 状态码 | +| `acceptance_status` | `string` | 博客接受状态,当前主要使用 `ACCEPTED` 与 `UNKNOWN`;该字段决定“是否被确认为博客” | +| `accepted_by` | `string \| null` | 接受来源,例如 `seed`、`rss`、`model` | +| `accepted_at` | `string \| null` | URL 被确认为博客的时间 | +| `crawl_error_kind` | `string \| null` | 最近一次抓取失败分类,例如 `timeout`、`page_too_large`、`http_status` | +| `crawl_error_message` | `string \| null` | 最近一次抓取失败详情摘要 | +| `last_crawl_attempt_at` | `string \| null` | 最近一次抓取尝试时间 | +| `successful_crawl_at` | `string \| null` | 最近一次成功完成抓取时间 | | `crawl_status` | `string` | 当前抓取状态,常见值有 `WAITING` `PROCESSING` `FAILED` `FINISHED` | | `friend_links_count` | `number` | 最近一次抓取发现的友链数 | | `last_crawled_at` | `string \| null` | 最近抓取时间 | diff --git a/doc/config-reference.md b/doc/config-reference.md index db54bbc..4f0ffa7 100644 --- a/doc/config-reference.md +++ b/doc/config-reference.md @@ -54,7 +54,7 @@ Docker Compose 也会从仓库根目录的 `.env` 读取变量。 | `HEYBLOG_CANDIDATE_PAGE_FETCH_CONCURRENCY` | `4` | `crawler` | 友链候选页抓取并发度,最小为 `1` | | `HEYBLOG_RUNTIME_WORKER_COUNT` | `3` | `crawler` | runtime 持续抓取的 worker 数 | | `HEYBLOG_RAW_DISCOVERED_URL_LIMIT` | `1000000` | `crawler` | `raw_discovered_urls` 行数达到该值后拒绝启动 crawler,并让正在运行的 runtime 在下一次 claim 前自动停止;设为 `-1` 表示不限制 | -| `HEYBLOG_MAX_FETCHED_PAGE_BYTES` | `2000000` | `crawler` | 单个页面允许读取的最大字节数;超限后当前 blog 直接记为 `FAILED`,超大页不会继续进入解析阶段 | +| `HEYBLOG_MAX_FETCHED_PAGE_BYTES` | `2000000` | `crawler` | 单个页面允许读取的最大字节数;超限后当前 crawl attempt 记为 `FAILED` 并记录错误分类,超大页不会继续进入解析阶段;这不会撤销已接受博客的 `acceptance_status` | | `HEYBLOG_FRIEND_LINK_DOMAIN_BLOCKLIST` | 空 | `crawler` | 逗号分隔的域名黑名单 | | `HEYBLOG_FRIEND_LINK_TLD_BLOCKLIST` | 空 | `crawler` | 逗号分隔的顶级域黑名单 | | `HEYBLOG_FRIEND_LINK_EXACT_URL_BLOCKLIST` | 空 | `crawler` | 逗号分隔的精确 URL 黑名单 | diff --git a/doc/crawler-url-filtering.md b/doc/crawler-url-filtering.md index 2f6c770..27465ed 100644 --- a/doc/crawler-url-filtering.md +++ b/doc/crawler-url-filtering.md @@ -140,6 +140,7 @@ crawler 的两种主要运行方式: - `icon_url` 如果超时或异常,则由 `CrawlPipeline._mark_blog_failed()` 标记为 `FAILED`。 +`FAILED` 只表示最近一次抓取尝试没有完整结束,不会撤销 `acceptance_status=ACCEPTED` 的博客判定;RSS、模型或 seed 接受来源会保留在 `accepted_by` / `accepted_at` 中。 ## 5. 首页友链页发现逻辑 @@ -556,7 +557,7 @@ identity 输出里会记录: - 一个博客的整次 crawl 有总超时预算 - 候选页可并发抓取 - 单页超出字节上限会触发 `PageTooLargeError` -- 若候选页超大,当前 blog 会被标记为 `FAILED` +- 若候选页超大,当前 blog 会被标记为 `FAILED`,并记录 `crawl_error_kind=page_too_large`;这只影响抓取生命周期,不表示该 URL 不是博客 因此“没有抓到友链”不一定是过滤规则问题,也可能是: diff --git a/persistence_api/main.py b/persistence_api/main.py index 84d5f5e..ad9700d 100644 --- a/persistence_api/main.py +++ b/persistence_api/main.py @@ -49,6 +49,7 @@ class UpsertBlogRequest(BaseModel): domain: str email: str | None = None feed_url: str | None = None + accepted_by: str | None = None class CreateIngestionRequest(BaseModel): @@ -68,6 +69,8 @@ class BlogResultRequest(BaseModel): metadata_captured: bool = False title: str | None = None icon_url: str | None = None + crawl_error_kind: str | None = None + crawl_error_message: str | None = None class AddEdgeRequest(BaseModel): @@ -309,6 +312,7 @@ def list_blogs_catalog( has_title: str | None = None, has_icon: str | None = None, min_connections: str | None = None, + acceptance_status: str | None = "ACCEPTED", ) -> dict[str, Any]: return _call_with_value_error_http_translation( lambda: get_state().repository.list_blogs_catalog( @@ -323,6 +327,7 @@ def list_blogs_catalog( has_title=has_title, has_icon=has_icon, min_connections=min_connections, + acceptance_status=acceptance_status, ), status_code=422, ) diff --git a/persistence_api/models.py b/persistence_api/models.py index a9b2e6c..691bcde 100644 --- a/persistence_api/models.py +++ b/persistence_api/models.py @@ -51,6 +51,13 @@ class BlogModel(Base): title: Mapped[str | None] = mapped_column(Text, nullable=True) icon_url: Mapped[str | None] = mapped_column(Text, nullable=True) status_code: Mapped[int | None] = mapped_column(Integer, nullable=True) + acceptance_status: Mapped[str] = mapped_column(Text, nullable=False, default="UNKNOWN") + accepted_by: Mapped[str | None] = mapped_column(Text, nullable=True) + accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + crawl_error_kind: Mapped[str | None] = mapped_column(Text, nullable=True) + crawl_error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + last_crawl_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + successful_crawl_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) crawl_status: Mapped[CrawlStatus] = mapped_column( Enum(CrawlStatus, name="crawl_status"), nullable=False, diff --git a/persistence_api/repository.py b/persistence_api/repository.py index 1ed1f66..17cfbbe 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -59,6 +59,8 @@ from shared.config import Settings from shared.observability import get_logger +BLOG_ACCEPTANCE_ACCEPTED = "ACCEPTED" +BLOG_ACCEPTANCE_UNKNOWN = "UNKNOWN" BLOG_CATALOG_ALLOWED_STATUSES = frozenset({status.value for status in CrawlStatus}) BLOG_CATALOG_DEFAULT_PAGE_SIZE = 50 BLOG_CATALOG_MAX_PAGE_SIZE = 200 @@ -66,6 +68,9 @@ BLOG_CATALOG_ALLOWED_SORTS = frozenset( {"id_asc", "id_desc", "recent_activity", "connections", "recently_discovered", "random"} ) +BLOG_CATALOG_ALLOWED_ACCEPTANCE_STATUSES = frozenset( + {BLOG_ACCEPTANCE_ACCEPTED, BLOG_ACCEPTANCE_UNKNOWN, "REJECTED"} +) INGESTION_PRIORITY_LIST_LIMIT = 20 BLOG_LABELING_DEFAULT_PAGE_SIZE = 50 BLOG_LABELING_MAX_PAGE_SIZE = 200 @@ -449,6 +454,7 @@ def normalize_blog_catalog_query( has_title: bool | str | None = None, has_icon: bool | str | None = None, min_connections: int | str | None = None, + acceptance_status: str | None = BLOG_ACCEPTANCE_ACCEPTED, ) -> dict[str, Any]: """Normalize catalog query params into one shared spec.""" normalized_statuses: list[str] | None = None @@ -477,6 +483,11 @@ def normalize_blog_catalog_query( normalized_sort = _normalize_catalog_text(sort) or BLOG_CATALOG_DEFAULT_SORT if normalized_sort not in BLOG_CATALOG_ALLOWED_SORTS: raise ValueError(f"Unsupported blog catalog sort: {normalized_sort}") + normalized_acceptance_status = _normalize_catalog_text(acceptance_status) + if normalized_acceptance_status is not None: + normalized_acceptance_status = normalized_acceptance_status.upper() + if normalized_acceptance_status not in BLOG_CATALOG_ALLOWED_ACCEPTANCE_STATUSES: + raise ValueError(f"Unsupported blog acceptance status: {normalized_acceptance_status}") return { "page": max(page, 1), @@ -490,6 +501,7 @@ def normalize_blog_catalog_query( "has_title": _normalize_catalog_bool(has_title), "has_icon": _normalize_catalog_bool(has_icon), "min_connections": _normalize_catalog_int(min_connections), + "acceptance_status": normalized_acceptance_status, } @@ -934,6 +946,37 @@ def ensure_legacy_compat_schema(engine: Any) -> None: connection.execute( text(f'ALTER TABLE raw_discovered_urls DROP CONSTRAINT IF EXISTS "{constraint_name}"') ) + if "blogs" in existing_tables: + blog_columns = {column["name"] for column in inspector.get_columns("blogs")} + for column_name, ddl in ( + ("acceptance_status", "ALTER TABLE blogs ADD COLUMN acceptance_status TEXT NOT NULL DEFAULT 'UNKNOWN'"), + ("accepted_by", "ALTER TABLE blogs ADD COLUMN accepted_by TEXT"), + ("accepted_at", "ALTER TABLE blogs ADD COLUMN accepted_at TIMESTAMP"), + ("crawl_error_kind", "ALTER TABLE blogs ADD COLUMN crawl_error_kind TEXT"), + ("crawl_error_message", "ALTER TABLE blogs ADD COLUMN crawl_error_message TEXT"), + ("last_crawl_attempt_at", "ALTER TABLE blogs ADD COLUMN last_crawl_attempt_at TIMESTAMP"), + ("successful_crawl_at", "ALTER TABLE blogs ADD COLUMN successful_crawl_at TIMESTAMP"), + ): + if column_name not in blog_columns: + connection.execute(text(ddl)) + connection.execute( + text( + "UPDATE blogs SET acceptance_status = 'ACCEPTED', " + "accepted_by = COALESCE(accepted_by, 'seed'), " + "accepted_at = COALESCE(accepted_at, created_at) " + "WHERE acceptance_status = 'UNKNOWN' " + "AND blog_id NOT IN (SELECT to_blog_id FROM edges)" + ) + ) + connection.execute( + text( + "UPDATE blogs SET acceptance_status = 'ACCEPTED', " + "accepted_by = COALESCE(accepted_by, 'graph'), " + "accepted_at = COALESCE(accepted_at, created_at) " + "WHERE acceptance_status = 'UNKNOWN' " + "AND blog_id IN (SELECT from_blog_id FROM edges UNION SELECT to_blog_id FROM edges)" + ) + ) blog_rows = connection.execute( text( "SELECT id, blog_id, url, normalized_url, domain, identity_key, identity_ruleset_version " @@ -1061,6 +1104,13 @@ def as_blog_payload( "title": self.title, "icon_url": self.icon_url, "status_code": self.model.status_code, + "acceptance_status": self.model.acceptance_status, + "accepted_by": self.model.accepted_by, + "accepted_at": _iso(self.model.accepted_at), + "crawl_error_kind": self.model.crawl_error_kind, + "crawl_error_message": self.model.crawl_error_message, + "last_crawl_attempt_at": _iso(self.model.last_crawl_attempt_at), + "successful_crawl_at": _iso(self.model.successful_crawl_at), "crawl_status": self.model.crawl_status.value, "friend_links_count": int(self.model.friend_links_count), "last_crawled_at": _iso(self.model.last_crawled_at), @@ -1635,6 +1685,7 @@ def upsert_blog( domain: str, email: str | None = None, feed_url: str | None = None, + accepted_by: str | None = None, ) -> tuple[int, bool]: ... def get_next_waiting_blog(self, *, include_priority: bool = True) -> dict[str, Any] | None: ... @@ -1678,6 +1729,8 @@ def mark_blog_result( metadata_captured: bool = False, title: str | None = None, icon_url: str | None = None, + crawl_error_kind: str | None = None, + crawl_error_message: str | None = None, ) -> None: ... def add_edge( @@ -1731,6 +1784,7 @@ def list_blogs_catalog( has_title: bool | str | None = None, has_icon: bool | str | None = None, min_connections: int | None = None, + acceptance_status: str | None = BLOG_ACCEPTANCE_ACCEPTED, ) -> dict[str, Any]: ... def list_blog_labeling_candidates( @@ -2270,6 +2324,7 @@ def _upsert_blog_in_session( domain: str, email: str | None = None, feed_url: str | None = None, + accepted_by: str | None = None, preferred_blog_id: int | None = None, ) -> tuple[BlogModel, bool]: """Create or update one blog row and initialize its business id. @@ -2282,6 +2337,8 @@ def _upsert_blog_in_session( email: Optional contact email to fill when the row is missing one. feed_url: Optional RSS/Atom feed URL discovered for the blog. Stored when present; an existing feed is never overwritten with ``None``. + accepted_by: Optional acceptance source such as ``seed``, ``rss``, + or ``model``. When present, the blog is durably accepted. preferred_blog_id: Preferred externally meaningful ``blogs.blog_id``. Returns: @@ -2313,6 +2370,12 @@ def _upsert_blog_in_session( existing.email = email if feed_url is not None and not (existing.feed_url or "").strip(): existing.feed_url = feed_url + if existing.acceptance_status != BLOG_ACCEPTANCE_ACCEPTED: + existing.acceptance_status = BLOG_ACCEPTANCE_ACCEPTED + existing.accepted_at = existing.accepted_at or now_utc() + if accepted_by is not None: + existing.accepted_by = accepted_by + existing.accepted_at = existing.accepted_at or now_utc() existing.identity_key = identity.identity_key existing.identity_reason_codes = _dump_reason_codes(identity.reason_codes) existing.identity_ruleset_version = identity.ruleset_version @@ -2335,6 +2398,9 @@ def _upsert_blog_in_session( domain=stored_domain, email=email, feed_url=feed_url, + acceptance_status=BLOG_ACCEPTANCE_ACCEPTED, + accepted_by=accepted_by, + accepted_at=now_utc(), crawl_status=CrawlStatus.WAITING, friend_links_count=0, created_at=now_utc(), @@ -2467,6 +2533,7 @@ def upsert_blog( domain: str, email: str | None = None, feed_url: str | None = None, + accepted_by: str | None = None, ) -> tuple[int, bool]: with session_scope(self.session_factory) as session: blog, inserted = self._upsert_blog_in_session( @@ -2476,6 +2543,7 @@ def upsert_blog( domain=domain, email=email, feed_url=feed_url, + accepted_by=accepted_by, ) return int(_business_blog_id(blog)), inserted @@ -2541,6 +2609,9 @@ def create_ingestion_request(self, *, homepage_url: str, email: str) -> dict[str identity_ruleset_version=ruleset_version, domain=domain, email=normalized_email, + acceptance_status=BLOG_ACCEPTANCE_ACCEPTED, + accepted_by="seed", + accepted_at=now_utc(), crawl_status=CrawlStatus.WAITING, friend_links_count=0, created_at=now_utc(), @@ -2963,6 +3034,8 @@ def requeue_failed_blogs(self) -> dict[str, Any]: for blog in blogs: blog.crawl_status = CrawlStatus.WAITING blog.status_code = None + blog.crawl_error_kind = None + blog.crawl_error_message = None blog.updated_at = timestamp requests = session.scalars( @@ -2988,16 +3061,28 @@ def mark_blog_result( metadata_captured: bool = False, title: str | None = None, icon_url: str | None = None, + crawl_error_kind: str | None = None, + crawl_error_message: str | None = None, ) -> None: with session_scope(self.session_factory) as session: blog = self._get_blog_by_business_id(session, blog_id) if blog is None: return - blog.crawl_status = CrawlStatus(crawl_status) + resolved_status = CrawlStatus(crawl_status) + timestamp = now_utc() + blog.crawl_status = resolved_status blog.status_code = status_code blog.friend_links_count = friend_links_count - blog.last_crawled_at = now_utc() - blog.updated_at = now_utc() + blog.last_crawled_at = timestamp + blog.last_crawl_attempt_at = timestamp + blog.updated_at = timestamp + if resolved_status == CrawlStatus.FINISHED: + blog.successful_crawl_at = timestamp + blog.crawl_error_kind = None + blog.crawl_error_message = None + elif resolved_status == CrawlStatus.FAILED: + blog.crawl_error_kind = crawl_error_kind + blog.crawl_error_message = crawl_error_message if metadata_captured: blog.title = title blog.icon_url = icon_url @@ -3308,6 +3393,7 @@ def list_blogs_catalog( has_title: bool | str | None = None, has_icon: bool | str | None = None, min_connections: int | None = None, + acceptance_status: str | None = BLOG_ACCEPTANCE_ACCEPTED, ) -> dict[str, Any]: query = normalize_blog_catalog_query( page=page, @@ -3321,9 +3407,12 @@ def list_blogs_catalog( has_title=has_title, has_icon=has_icon, min_connections=min_connections, + acceptance_status=acceptance_status, ) with session_scope(self.session_factory) as session: statement, metrics = self._blog_select() + if query["acceptance_status"] is not None: + statement = statement.where(BlogModel.acceptance_status == query["acceptance_status"]) if query["site"] is not None: pattern = f"%{query['site']}%" statement = statement.where( @@ -3410,6 +3499,7 @@ def list_blogs_catalog( "has_title": query["has_title"], "has_icon": query["has_icon"], "min_connections": query["min_connections"], + "acceptance_status": query["acceptance_status"], }, ) diff --git a/shared/http_clients/persistence_http.py b/shared/http_clients/persistence_http.py index d25c943..c5bc533 100644 --- a/shared/http_clients/persistence_http.py +++ b/shared/http_clients/persistence_http.py @@ -170,6 +170,7 @@ def upsert_blog( domain: str, email: str | None = None, feed_url: str | None = None, + accepted_by: str | None = None, ) -> tuple[int, bool]: payload = self._post( "/internal/blogs/upsert", @@ -179,6 +180,7 @@ def upsert_blog( "domain": domain, "email": email, "feed_url": feed_url, + "accepted_by": accepted_by, }, ) return int(payload["id"]), bool(payload["inserted"]) @@ -316,6 +318,8 @@ def mark_blog_result( metadata_captured: bool = False, title: str | None = None, icon_url: str | None = None, + crawl_error_kind: str | None = None, + crawl_error_message: str | None = None, ) -> None: self._post( f"/internal/blogs/{blog_id}/result", @@ -326,6 +330,8 @@ def mark_blog_result( "metadata_captured": metadata_captured, "title": title, "icon_url": icon_url, + "crawl_error_kind": crawl_error_kind, + "crawl_error_message": crawl_error_message, }, ) @@ -408,6 +414,7 @@ def list_blogs_catalog( has_title: bool | None = None, has_icon: bool | None = None, min_connections: int | None = None, + acceptance_status: str | None = "ACCEPTED", ) -> dict[str, Any]: return self._get( "/internal/blogs/catalog", @@ -423,6 +430,7 @@ def list_blogs_catalog( "has_title": has_title, "has_icon": has_icon, "min_connections": min_connections, + "acceptance_status": acceptance_status, }, ) diff --git a/tests/test_repository.py b/tests/test_repository.py index 3ecf520..df9e620 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -187,6 +187,87 @@ def test_repository_mark_blog_result_persists_site_metadata(tmp_path: Path) -> N assert blog["icon_url"] == "https://blog.example.com/favicon.ico" +def test_repository_keeps_accepted_blog_visible_after_crawl_failure(tmp_path: Path) -> None: + """Crawl failures must not undo durable blog acceptance. + + Args: + tmp_path: Temporary directory used for the SQLite test database. + + Returns: + None. Assertions verify acceptance fields and catalog eligibility. + """ + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + blog_id, inserted = repository.upsert_blog( + url="https://friend.example.com/", + normalized_url="https://friend.example.com/", + domain="friend.example.com", + accepted_by="rss", + ) + assert inserted is True + + repository.mark_blog_result( + blog_id=blog_id, + crawl_status="FAILED", + status_code=413, + friend_links_count=0, + crawl_error_kind="page_too_large", + crawl_error_message="homepage exceeded max page bytes", + ) + + blog = repository.get_blog(blog_id) + assert blog is not None + assert blog["acceptance_status"] == "ACCEPTED" + assert blog["accepted_by"] == "rss" + assert blog["crawl_status"] == "FAILED" + assert blog["crawl_error_kind"] == "page_too_large" + assert blog["successful_crawl_at"] is None + + catalog = repository.list_blogs_catalog() + assert [item["id"] for item in catalog["items"]] == [blog_id] + assert catalog["filters"]["acceptance_status"] == "ACCEPTED" + + +def test_repository_successful_crawl_clears_previous_error(tmp_path: Path) -> None: + """A later successful crawl should clear stale failure details. + + Args: + tmp_path: Temporary directory used for the SQLite test database. + + Returns: + None. Assertions verify failure details do not survive a success. + """ + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + blog_id, _ = repository.upsert_blog( + url="https://blog.example.com/", + normalized_url="https://blog.example.com/", + domain="blog.example.com", + accepted_by="model", + ) + + repository.mark_blog_result( + blog_id=blog_id, + crawl_status="FAILED", + status_code=None, + friend_links_count=0, + crawl_error_kind="timeout", + crawl_error_message="timed out", + ) + repository.mark_blog_result( + blog_id=blog_id, + crawl_status="FINISHED", + status_code=200, + friend_links_count=3, + ) + + blog = repository.get_blog(blog_id) + assert blog is not None + assert blog["acceptance_status"] == "ACCEPTED" + assert blog["accepted_by"] == "model" + assert blog["crawl_error_kind"] is None + assert blog["crawl_error_message"] is None + assert blog["successful_crawl_at"] is not None + + def test_repository_defaults_blog_email_to_none(tmp_path: Path) -> None: """New blogs should keep a nullable email field until claimed by a user.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") @@ -1140,6 +1221,7 @@ def test_repository_blog_catalog_normalizes_query_inputs(tmp_path: Path) -> None "has_title": None, "has_icon": None, "min_connections": 0, + "acceptance_status": "ACCEPTED", } last_page = repository.list_blogs_catalog(page=99, page_size=2) @@ -2335,6 +2417,13 @@ def test_repository_blog_detail_aggregates_bidirectional_relationships(tmp_path: "title": "delta.example title", "icon_url": "https://delta.example/favicon.ico", "status_code": 200, + "acceptance_status": "ACCEPTED", + "accepted_by": None, + "accepted_at": detail["recommended_blogs"][0]["blog"]["accepted_at"], + "crawl_error_kind": None, + "crawl_error_message": None, + "last_crawl_attempt_at": detail["recommended_blogs"][0]["blog"]["last_crawl_attempt_at"], + "successful_crawl_at": detail["recommended_blogs"][0]["blog"]["successful_crawl_at"], "crawl_status": "FINISHED", "friend_links_count": 1, "last_crawled_at": detail["recommended_blogs"][0]["blog"]["last_crawled_at"], From db61b2a1fbef30ba7b4aeb2bed782254f51f915e Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Tue, 2 Jun 2026 22:30:36 +0100 Subject: [PATCH 03/35] =?UTF-8?q?=F0=9F=8E=88=20perf:=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?icon=5Furl=E7=9A=84=E6=8F=90=E5=8F=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/crawling/fetching/base.py | 13 ++++ crawler/crawling/fetching/httpx_fetcher.py | 47 ++++++++++++++ crawler/crawling/metadata.py | 58 ++++++++++++++--- crawler/crawling/orchestrator.py | 20 +++++- doc/api-docs.md | 2 +- doc/crawler-url-filtering.md | 2 +- frontend/src/App.test.tsx | 2 +- .../components/GraphVisualization.test.tsx | 11 ++-- .../src/components/GraphVisualization.tsx | 46 +++++++++----- frontend/src/lib/icon.ts | 43 ++++++++++--- persistence_api/repository.py | 12 +--- pyproject.toml | 1 + tests/test_pipeline.py | 63 +++++++++++++++++-- tests/test_repository.py | 6 +- tests/test_site_metadata.py | 6 +- 15 files changed, 272 insertions(+), 60 deletions(-) diff --git a/crawler/crawling/fetching/base.py b/crawler/crawling/fetching/base.py index 99484a0..be8c5f9 100644 --- a/crawler/crawling/fetching/base.py +++ b/crawler/crawling/fetching/base.py @@ -88,3 +88,16 @@ def fetch_many( ``FetchAttempt`` result. """ ... + + def validate_icon_url(self, url: str, *, timeout_seconds: float | None = None) -> str | None: + """Return a reachable final icon URL, or ``None`` when unusable. + + Args: + url: Absolute HTTP(S) icon candidate URL to verify. + timeout_seconds: Optional per-request timeout override in seconds. + + Returns: + Final URL after redirects when the candidate is reachable and looks + like an image resource; otherwise ``None``. + """ + ... diff --git a/crawler/crawling/fetching/httpx_fetcher.py b/crawler/crawling/fetching/httpx_fetcher.py index c5106ad..41b1f7d 100644 --- a/crawler/crawling/fetching/httpx_fetcher.py +++ b/crawler/crawling/fetching/httpx_fetcher.py @@ -4,6 +4,7 @@ import asyncio from typing import Any +from urllib.parse import urlsplit import httpx @@ -99,6 +100,35 @@ def fetch_many( "Use 'await fetch_many_async(...)' instead." ) + def validate_icon_url(self, url: str, *, timeout_seconds: float | None = None) -> str | None: + """Return the final URL for a reachable image-like favicon candidate. + + Args: + url: Absolute HTTP(S) icon candidate URL. + timeout_seconds: Optional request timeout override. + + Returns: + Final URL after redirects when the candidate responds successfully + with an image-like content type; otherwise ``None``. + """ + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return None + request_kwargs: dict[str, Any] = {} + if timeout_seconds is not None: + request_kwargs["timeout"] = timeout_seconds + headers = {"Range": "bytes=0-0"} + try: + response = self.client.head(url, **request_kwargs) + if response.status_code in {405, 501} or response.status_code >= 400: + response = self.client.get(url, headers=headers, **request_kwargs) + response.raise_for_status() + except httpx.HTTPError: + return None + if not self._is_icon_response(response): + return None + return str(response.url) + async def fetch_many_async( self, urls: list[str], @@ -312,3 +342,20 @@ def _raise_if_content_length_too_large(self, response: httpx.Response) -> None: raise PageTooLargeError( f"page exceeded max size limit ({size} > {self.max_page_bytes} bytes): {response.url}" ) + + def _is_icon_response(self, response: httpx.Response) -> bool: + """Return whether an HTTP response looks like a usable icon image. + + Args: + response: HTTPX response returned by a HEAD or lightweight GET + request. + + Returns: + True for successful image-like responses; false otherwise. + """ + content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type.startswith("image/"): + return True + if content_type in {"application/octet-stream", "binary/octet-stream"}: + return urlsplit(str(response.url)).path.lower().endswith((".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp")) + return False diff --git a/crawler/crawling/metadata.py b/crawler/crawling/metadata.py index d79f1c9..2e936b0 100644 --- a/crawler/crawling/metadata.py +++ b/crawler/crawling/metadata.py @@ -8,6 +8,7 @@ from bs4 import BeautifulSoup from bs4 import Tag +from extract_favicon import from_html as extract_favicons_from_html from crawler.utils import clean_text @@ -102,14 +103,56 @@ def _pick_icon_url(page_url: str, soup: BeautifulSoup) -> str | None: continue ranked_candidates.append((priority, index, resolved_href)) - if ranked_candidates: - ranked_candidates.sort(key=lambda item: (item[0], item[1])) - return ranked_candidates[0][2] + if not ranked_candidates: + return None + ranked_candidates.sort(key=lambda item: (item[0], item[1])) + return ranked_candidates[0][2] + + +def _favicon_score(favicon: object, ordinal: int) -> tuple[int, int]: + """Return a sortable quality score for one `extract-favicon` result. + + Args: + favicon: Favicon-like object returned by `extract-favicon`. + ordinal: Original candidate order used as a stable tie-breaker. + + Returns: + Tuple where larger values are preferred. + """ + width = int(getattr(favicon, "width", 0) or 0) + height = int(getattr(favicon, "height", 0) or 0) + area = width * height + return (area, -ordinal) - if not _is_http_url(page_url): + +def _pick_icon_url_with_library(page_url: str, html: str) -> str | None: + """Pick the best explicit icon URL using `extract-favicon`. + + Args: + page_url: Final fetched page URL used to resolve relative icon paths. + html: Raw homepage HTML to parse. + + Returns: + Best HTTP(S) icon candidate from page metadata, or ``None`` when the + page declares no usable icon. + """ + favicons = extract_favicons_from_html(html, root_url=page_url, include_fallbacks=False) + candidates = [ + (favicon, str(getattr(favicon, "url", "") or "").strip()) + for favicon in sorted(favicons, key=lambda item: str(getattr(item, "url", ""))) + ] + usable = [ + (favicon, url) + for favicon, url in candidates + if url and _is_http_url(url) + ] + if not usable: return None - parsed = urlsplit(page_url) - return f"{parsed.scheme}://{parsed.netloc}/favicon.ico" + _favicon, url = max( + enumerate(usable), + key=lambda item: _favicon_score(item[1][0], item[0]), + )[1] + return url def extract_site_metadata(page_url: str, html: str) -> SiteMetadata: @@ -128,4 +171,5 @@ def extract_site_metadata(page_url: str, html: str) -> SiteMetadata: if soup.title is not None: title = clean_text(soup.title.get_text(" ", strip=True)) or None - return SiteMetadata(title=title, icon_url=_pick_icon_url(page_url, soup)) + icon_url = _pick_icon_url_with_library(page_url, html) or _pick_icon_url(page_url, soup) + return SiteMetadata(title=title, icon_url=icon_url) diff --git a/crawler/crawling/orchestrator.py b/crawler/crawling/orchestrator.py index 194a3af..f1d6b04 100644 --- a/crawler/crawling/orchestrator.py +++ b/crawler/crawling/orchestrator.py @@ -87,6 +87,7 @@ def crawl_blog(self, blog: dict[str, object]) -> int: timeout_seconds=self._remaining_timeout_seconds(deadline, blog_record.url), ) metadata = extract_site_metadata(homepage.url, homepage.text) + validated_icon_url = self._validated_icon_url(metadata.icon_url, deadline, blog_record.url) candidate_pages = self._discover_candidate_pages(homepage) discovered_count = self._crawl_candidate_pages( blog_record, @@ -100,11 +101,28 @@ def crawl_blog(self, blog: dict[str, object]) -> int: status_code=homepage.status_code, discovered_count=discovered_count, title=metadata.title, - icon_url=metadata.icon_url, + icon_url=validated_icon_url, ), ) return discovered_count + def _validated_icon_url(self, icon_url: str | None, deadline: float, blog_url: str) -> str | None: + """Return a reachable icon URL, or ``None`` when no candidate validates. + + Args: + icon_url: Candidate icon URL extracted from homepage metadata. + deadline: Monotonic crawl deadline shared by the current blog crawl. + blog_url: Source blog URL used in timeout error messages. + + Returns: + Final reachable icon URL when validation succeeds; otherwise + ``None``. + """ + if not icon_url: + return None + remaining = self._remaining_timeout_seconds(deadline, blog_url) + return self.fetcher.validate_icon_url(icon_url, timeout_seconds=remaining) + def _discover_candidate_pages(self, homepage: FetchResult) -> list[str]: """Discover friend-link candidate pages starting from the homepage. diff --git a/doc/api-docs.md b/doc/api-docs.md index 84e8cc0..d0a3b38 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -1841,7 +1841,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 | `domain` | `string` | 域名 | | `email` | `string \| null` | 博主联系邮箱;仅在用户自助优先录入时写入,默认 `null` | | `title` | `string \| null` | 站点主页解析出的 `<title>`,缺失时为 `null` | -| `icon_url` | `string \| null` | 站点标签页 icon URL;优先使用页面声明的 icon 链接,缺失时可能回退为 `${origin}/favicon.ico` | +| `icon_url` | `string \| null` | 站点标签页 icon URL;仅在 crawler 从页面 metadata 提取并验证可访问后持久化,缺失或验证失败时为 `null`。前端可使用第三方 favicon API 做展示兜底,但不回写该字段 | | `status_code` | `number \| null` | 最近抓取 HTTP 状态码 | | `acceptance_status` | `string` | 博客接受状态,当前主要使用 `ACCEPTED` 与 `UNKNOWN`;该字段决定“是否被确认为博客” | | `accepted_by` | `string \| null` | 接受来源,例如 `seed`、`rss`、`model` | diff --git a/doc/crawler-url-filtering.md b/doc/crawler-url-filtering.md index 27465ed..b96a944 100644 --- a/doc/crawler-url-filtering.md +++ b/doc/crawler-url-filtering.md @@ -137,7 +137,7 @@ crawler 的两种主要运行方式: - `status_code=首页 HTTP 状态码` - `friend_links_count=本次接受的外链博客数` - `title` -- `icon_url` +- `icon_url`,仅当页面 metadata 提取出的 icon 候选能通过轻量 HTTP 验证时写入;无候选或验证失败时保持 `NULL` 如果超时或异常,则由 `CrawlPipeline._mark_blog_failed()` 标记为 `FAILED`。 `FAILED` 只表示最近一次抓取尝试没有完整结束,不会撤销 `acceptance_status=ACCEPTED` 的博客判定;RSS、模型或 seed 接受来源会保留在 `accepted_by` / `accepted_at` 中。 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0661118..8874f0e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -373,7 +373,7 @@ test("adds a random blog route that loads nine finished cards and refreshes them expect(screen.getByText("Extra Blog 32")).toBeInTheDocument(); expect(screen.getByAltText("extra-blog-32.example.com icon")).toHaveAttribute( "src", - "https://icons.duckduckgo.com/ip3/extra-blog-32.example.com.ico", + "https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://extra-blog-32.example.com&size=64", ); fireEvent.click(screen.getByRole("button", { name: /刷新随机博客/i })); diff --git a/frontend/src/components/GraphVisualization.test.tsx b/frontend/src/components/GraphVisualization.test.tsx index 3ced7a2..cfcebea 100644 --- a/frontend/src/components/GraphVisualization.test.tsx +++ b/frontend/src/components/GraphVisualization.test.tsx @@ -87,10 +87,10 @@ vi.mock("three", async () => { TextureLoader: class { setCrossOrigin = vi.fn(); - load = vi.fn((url: string, onLoad?: () => void) => { + load = vi.fn((url: string, onLoad?: (texture: any) => void) => { const texture = new actual.Texture(); texture.userData = { url }; - onLoad?.(); + onLoad?.(texture); return texture; }); }, @@ -197,14 +197,15 @@ describe("GraphVisualization", () => { id: "1", blogId: 1, label: "Alpha Blog", - iconUrl: "https://icons.duckduckgo.com/ip3/alpha.example.com.ico", + iconUrl: "https://alpha.example.com/favicon.ico", val: 1, }), expect.objectContaining({ id: "2", blogId: 2, label: "Beta Blog", - iconUrl: "https://icons.duckduckgo.com/ip3/beta.example.com.ico", + iconUrl: + "https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://beta.example.com&size=64", val: 1, }), ]), @@ -297,7 +298,7 @@ describe("GraphVisualization", () => { const nodeObject = graphProps!.nodeThreeObject(iconNode); expect(nodeObject.children).toHaveLength(3); - expect(nodeObject.userData.iconUrl).toBe("https://icons.duckduckgo.com/ip3/alpha.example.com.ico"); + expect(nodeObject.userData.iconUrl).toBe("https://alpha.example.com/favicon.ico"); }); test("tunes forces for natural clusters instead of a centered sphere", () => { diff --git a/frontend/src/components/GraphVisualization.tsx b/frontend/src/components/GraphVisualization.tsx index 409252c..98ffaf7 100644 --- a/frontend/src/components/GraphVisualization.tsx +++ b/frontend/src/components/GraphVisualization.tsx @@ -2,7 +2,7 @@ import { RotateCcw, ZoomIn, ZoomOut } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ForceGraph3D, { type ForceGraphMethods } from "react-force-graph-3d"; import * as THREE from "three"; -import { resolveBlogIconUrl } from "../lib/icon"; +import { resolveBlogIconUrls } from "../lib/icon"; import type { GraphData, GraphEdge, GraphNode } from "../types/graph"; export const GRAPH_RENDER_COOLDOWN_TICKS = 120; @@ -26,6 +26,7 @@ interface RenderNode extends Omit<GraphNode, "id" | "iconUrl"> { label: string; val: number; iconUrl?: string; + iconUrls: string[]; } interface RenderLink extends Omit<GraphEdge, "source" | "target"> { @@ -58,6 +59,7 @@ function buildGraphData(data: GraphData): RenderGraphData { if (!id) { continue; } + const iconUrls = resolveBlogIconUrls(node); nodesById.set(id, { ...node, id, @@ -65,7 +67,8 @@ function buildGraphData(data: GraphData): RenderGraphData { original: node, label: nodeTitle(node), val: 1, - iconUrl: resolveBlogIconUrl(node), + iconUrls, + iconUrl: iconUrls[0], }); } @@ -165,20 +168,13 @@ function createNodeObject(node: RenderNode, color: string, size: number): THREE. group.add(glow); group.add(core); + group.userData = { blogId: node.blogId, iconUrl: node.iconUrl }; - if (node.iconUrl) { + const iconUrls = node.iconUrls.length > 0 ? node.iconUrls : node.iconUrl ? [node.iconUrl] : []; + if (iconUrls.length > 0) { const loader = new THREE.TextureLoader(); loader.setCrossOrigin("anonymous"); - const texture = loader.load( - node.iconUrl, - () => { - core.visible = false; - }, - undefined, - () => { - core.visible = true; - }, - ); + const texture = new THREE.Texture(); texture.colorSpace = THREE.SRGBColorSpace; const icon = new THREE.Sprite( new THREE.SpriteMaterial({ @@ -190,9 +186,31 @@ function createNodeObject(node: RenderNode, color: string, size: number): THREE. icon.scale.set(size * 2.1, size * 2.1, 1); icon.position.set(0, 0, size * 0.08); group.add(icon); + + const loadIcon = (index: number) => { + const candidate = iconUrls[index]; + if (!candidate) { + core.visible = true; + icon.visible = false; + return; + } + loader.load( + candidate, + (loadedTexture) => { + loadedTexture.colorSpace = THREE.SRGBColorSpace; + icon.material.map = loadedTexture; + icon.material.needsUpdate = true; + core.visible = false; + icon.visible = true; + group.userData.iconUrl = candidate; + }, + undefined, + () => loadIcon(index + 1), + ); + }; + loadIcon(0); } - group.userData = { blogId: node.blogId, iconUrl: node.iconUrl }; return group; } diff --git a/frontend/src/lib/icon.ts b/frontend/src/lib/icon.ts index 99628c5..c226588 100644 --- a/frontend/src/lib/icon.ts +++ b/frontend/src/lib/icon.ts @@ -31,6 +31,40 @@ export function resolveDuckDuckGoIconUrl(node: Pick<GraphNode, "domain">): strin return `https://icons.duckduckgo.com/ip3/${hostname}.ico`; } +/** + * Build Google's public favicon service URL for one blog domain. + * + * @param node Blog-like frontend node that may include a domain. + * @returns Google favicon URL, or undefined when the domain is missing. + */ +export function resolveGoogleIconUrl(node: Pick<GraphNode, "domain">): string | undefined { + const hostname = node.domain?.trim(); + if (!hostname) { + return undefined; + } + return `https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${encodeURIComponent(hostname)}&size=64`; +} + +/** + * Resolve displayable icon candidates for a blog node. + * + * @param node Blog-like frontend node with optional crawled icon metadata. + * @returns Ordered icon candidates for UI display fallback. + */ +export function resolveBlogIconUrls( + node: Pick<GraphNode, "domain" | "iconUrl" | "url">, +): string[] { + const originFaviconUrl = resolveOriginFaviconUrl(node); + const normalizedIconUrl = node.iconUrl?.trim() || undefined; + const candidates = [ + normalizedIconUrl, + resolveGoogleIconUrl(node), + resolveDuckDuckGoIconUrl(node), + originFaviconUrl, + ]; + return Array.from(new Set(candidates.filter((candidate): candidate is string => Boolean(candidate)))); +} + /** * Resolve the best displayable icon URL for a blog node. * @@ -40,12 +74,5 @@ export function resolveDuckDuckGoIconUrl(node: Pick<GraphNode, "domain">): strin export function resolveBlogIconUrl( node: Pick<GraphNode, "domain" | "iconUrl" | "url">, ): string | undefined { - const originFaviconUrl = resolveOriginFaviconUrl(node); - const normalizedIconUrl = node.iconUrl?.trim() || undefined; - - if (normalizedIconUrl && normalizedIconUrl !== originFaviconUrl) { - return normalizedIconUrl; - } - - return resolveDuckDuckGoIconUrl(node) ?? normalizedIconUrl ?? originFaviconUrl ?? undefined; + return resolveBlogIconUrls(node)[0]; } diff --git a/persistence_api/repository.py b/persistence_api/repository.py index 17cfbbe..bc3f17b 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -1051,11 +1051,7 @@ def _resolved_blog_icon_url(model: BlogModel) -> str | None: icon_url = (model.icon_url or "").strip() if icon_url: return icon_url - - parsed = urlparse(model.url) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - return None - return f"{parsed.scheme}://{parsed.netloc}/favicon.ico" + return None @dataclass(frozen=True, slots=True) @@ -3446,11 +3442,7 @@ def list_blogs_catalog( ) if query["has_icon"] is True: statement = statement.where( - or_( - and_(BlogModel.icon_url.is_not(None), BlogModel.icon_url != ""), - BlogModel.url.like("http://%"), - BlogModel.url.like("https://%"), - ) + and_(BlogModel.icon_url.is_not(None), BlogModel.icon_url != "") ) if query["min_connections"] > 0: statement = statement.where(metrics["connection_count"] >= query["min_connections"]) diff --git a/pyproject.toml b/pyproject.toml index 606d33a..ac78163 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "beautifulsoup4>=4.12,<5", "fastapi>=0.115,<1", "feedparser>=6.0,<7", + "extract-favicon>=0.5.4,<1", "httpx>=0.28,<1", "pydantic>=2.7,<3", "pyarrow>=18.1,<25", diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index c6b2762..819ec7e 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -11,6 +11,7 @@ from crawler.crawling.fetching.base import FetchResult from crawler.crawling.pipeline import CrawlPipeline from persistence_api.db import session_scope +from persistence_api.models import BlogModel from persistence_api.models import RawDiscoveredUrlModel from persistence_api.repository import Repository from shared.config import Settings @@ -26,6 +27,7 @@ def __init__( batch_results: dict[str, FetchAttempt] | None = None, on_fetch: Callable[[str, float | None], None] | None = None, on_fetch_many: Callable[[list[str], float | None], None] | None = None, + valid_icon_urls: dict[str, str | None] | None = None, ) -> None: self.responses = responses self.batch_results = batch_results or {} @@ -35,6 +37,8 @@ def __init__( self.fetch_timeouts: list[float | None] = [] self.fetch_many_calls: list[tuple[list[str], int, float | None]] = [] self.batch_completion_order: list[str] = [] + self.valid_icon_urls = valid_icon_urls or {} + self.icon_validation_calls: list[tuple[str, float | None]] = [] def fetch(self, url: str, *, timeout_seconds: float | None = None) -> FetchResult: self.calls.append(url) @@ -76,6 +80,10 @@ def fetch_many( for url in urls } + def validate_icon_url(self, url: str, *, timeout_seconds: float | None = None) -> str | None: + self.icon_validation_calls.append((url, timeout_seconds)) + return self.valid_icon_urls.get(url) + def build_pipeline(tmp_path: Path) -> tuple[CrawlPipeline, Repository]: """Construct a pipeline backed by a temporary repository.""" @@ -142,7 +150,10 @@ def test_pipeline_persists_only_valid_friend_links(tmp_path: Path) -> None: status_code=200, text=friend_page_html, ), - } + }, + valid_icon_urls={ + "https://blog.example.com/static/favicon.png": "https://cdn.example.com/favicon.png", + }, ) discovered = pipeline._crawl_blog(blog) @@ -295,7 +306,10 @@ def test_pipeline_persists_site_title_and_icon_metadata(tmp_path: Path) -> None: status_code=200, text=friend_page_html, ), - } + }, + valid_icon_urls={ + "https://blog.example.com/static/favicon.png": "https://cdn.example.com/favicon.png", + }, ) pipeline._crawl_blog(blog) @@ -303,11 +317,12 @@ def test_pipeline_persists_site_title_and_icon_metadata(tmp_path: Path) -> None: refreshed = repository.get_blog(int(blog["id"])) assert refreshed is not None assert refreshed["title"] == "Alpha Blog" - assert refreshed["icon_url"] == "https://blog.example.com/static/favicon.png" + assert refreshed["icon_url"] == "https://cdn.example.com/favicon.png" + assert pipeline.fetcher.icon_validation_calls[0][0] == "https://blog.example.com/static/favicon.png" -def test_pipeline_falls_back_to_origin_favicon_when_page_has_no_icon_link(tmp_path: Path) -> None: - """Missing explicit icon markup should still produce an origin favicon candidate.""" +def test_pipeline_keeps_icon_null_when_page_has_no_icon_link(tmp_path: Path) -> None: + """Missing explicit icon markup should leave persisted icon metadata empty.""" pipeline, repository = build_pipeline(tmp_path) blog = seed_blog(repository) @@ -326,7 +341,43 @@ def test_pipeline_falls_back_to_origin_favicon_when_page_has_no_icon_link(tmp_pa refreshed = repository.get_blog(int(blog["id"])) assert refreshed is not None assert refreshed["title"] == "Plain Blog" - assert refreshed["icon_url"] == "https://blog.example.com/favicon.ico" + assert refreshed["icon_url"] is None + with session_scope(repository.session_factory) as session: + stored_icon_url = session.scalar(select(BlogModel.icon_url).where(BlogModel.blog_id == int(blog["id"]))) + assert stored_icon_url is None + assert pipeline.fetcher.icon_validation_calls == [] + + +def test_pipeline_keeps_icon_null_when_icon_validation_fails(tmp_path: Path) -> None: + """Unreachable extracted icon candidates should not be persisted.""" + pipeline, repository = build_pipeline(tmp_path) + blog = seed_blog(repository) + + pipeline.fetcher = FakeFetcher( + { + "https://blog.example.com/": FetchResult( + url="https://blog.example.com/", + status_code=200, + text=( + "<html><head><title>Plain Blog" + '' + "" + ), + ), + }, + valid_icon_urls={"https://blog.example.com/missing.ico": None}, + ) + + pipeline._crawl_blog(blog) + + refreshed = repository.get_blog(int(blog["id"])) + assert refreshed is not None + assert refreshed["title"] == "Plain Blog" + assert refreshed["icon_url"] is None + with session_scope(repository.session_factory) as session: + stored_icon_url = session.scalar(select(BlogModel.icon_url).where(BlogModel.blog_id == int(blog["id"]))) + assert stored_icon_url is None + assert pipeline.fetcher.icon_validation_calls[0][0] == "https://blog.example.com/missing.ico" def test_pipeline_enqueues_discovered_children_without_depth_gating(tmp_path: Path) -> None: diff --git a/tests/test_repository.py b/tests/test_repository.py index df9e620..e11dd0f 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1356,7 +1356,7 @@ def test_repository_random_catalog_filters_admin_non_blog_and_saves_user_labels( def test_repository_blog_catalog_uses_display_identity_fallbacks_for_legacy_rows(tmp_path: Path) -> None: - """Catalog should remain usable for older rows that were created before metadata capture existed.""" + """Catalog should keep title fallback but not synthesize unverified icons.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") blog_id, inserted = repository.upsert_blog( url="https://legacy.example/posts/1", @@ -1375,9 +1375,9 @@ def test_repository_blog_catalog_uses_display_identity_fallbacks_for_legacy_rows title_filtered = repository.list_blogs_catalog(has_title=True) icon_filtered = repository.list_blogs_catalog(has_icon=True) assert [row["id"] for row in title_filtered["items"]] == [blog_id] - assert [row["id"] for row in icon_filtered["items"]] == [blog_id] + assert icon_filtered["items"] == [] assert title_filtered["items"][0]["title"] == "legacy.example" - assert icon_filtered["items"][0]["icon_url"] == "https://legacy.example/favicon.ico" + assert title_filtered["items"][0]["icon_url"] is None def test_repository_blog_catalog_has_title_filters_on_stored_title_only(tmp_path: Path) -> None: diff --git a/tests/test_site_metadata.py b/tests/test_site_metadata.py index 4e1d1ba..93e1dad 100644 --- a/tests/test_site_metadata.py +++ b/tests/test_site_metadata.py @@ -4,7 +4,7 @@ def test_extract_site_metadata_ignores_non_http_icon_urls() -> None: - """Unsafe icon schemes should be skipped in favor of a safe fallback.""" + """Unsafe icon schemes should be skipped without synthesizing a fallback.""" metadata = extract_site_metadata( "https://blog.example.com/", """ @@ -19,11 +19,11 @@ def test_extract_site_metadata_ignores_non_http_icon_urls() -> None: ) assert metadata.title == "Alpha Blog" - assert metadata.icon_url == "https://blog.example.com/favicon.ico" + assert metadata.icon_url is None def test_extract_site_metadata_returns_none_when_page_url_is_not_http() -> None: - """Fallback favicon should only be synthesized for HTTP(S) page URLs.""" + """Non-HTTP page URLs should not produce icon candidates.""" metadata = extract_site_metadata( "ftp://blog.example.com/", "Alpha Blog", From bdffd609e3a9a845548732dd4b0746c7a88767b7 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Tue, 2 Jun 2026 22:54:44 +0100 Subject: [PATCH 04/35] =?UTF-8?q?=F0=9F=90=9E=20fix:=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BA=86=E5=9B=BE=E8=B0=B1=E4=B8=AD=E4=B8=8D=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?icon=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 154 ++++++++++++++++++ doc/api-docs.md | 21 +++ frontend/server.py | 5 + .../components/GraphVisualization.test.tsx | 6 +- .../src/components/GraphVisualization.tsx | 4 +- frontend/src/lib/icon.test.ts | 25 +++ frontend/src/lib/icon.ts | 22 +++ graph-icons-debug.png | Bin 0 -> 144868 bytes tests/test_service_split.py | 125 ++++++++++++++ 9 files changed, 357 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/icon.test.ts create mode 100644 graph-icons-debug.png diff --git a/backend/main.py b/backend/main.py index bd99f13..89a3b0a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,11 +3,14 @@ from __future__ import annotations from dataclasses import dataclass +import ipaddress +import socket from threading import Thread from time import sleep from typing import Any from typing import Callable from typing import NoReturn +from urllib.parse import urlsplit import httpx from fastapi import Depends, FastAPI, HTTPException, Request @@ -75,6 +78,145 @@ class CreateBlogLabelTagRequest(BaseModel): ACTIVE_CRAWLER_RUNNER_STATUSES = frozenset({"starting", "running", "stopping"}) +ICON_PROXY_MAX_BYTES = 1_000_000 +ICON_PROXY_ALLOWED_SCHEMES = frozenset({"http", "https"}) +ICON_PROXY_IMAGE_EXTENSIONS = (".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif", ".avif") + + +def _is_private_icon_proxy_host(hostname: str) -> bool: + """Return whether one hostname resolves to local or private network space. + + Args: + hostname: Parsed URL hostname to validate before proxying. + + Returns: + True when the hostname itself or any resolved address is unsafe for the + public icon proxy. + """ + try: + ip_addresses = [ipaddress.ip_address(hostname)] + except ValueError: + try: + resolved = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except socket.gaierror: + return True + ip_addresses = [] + for item in resolved: + address = item[4][0] + try: + ip_addresses.append(ipaddress.ip_address(address)) + except ValueError: + return True + + return any( + address.is_private + or address.is_loopback + or address.is_link_local + or address.is_multicast + or address.is_reserved + or address.is_unspecified + for address in ip_addresses + ) + + +def _validate_icon_proxy_url(url: str) -> str: + """Normalize and validate a remote icon URL before proxying it. + + Args: + url: User-supplied absolute URL. + + Returns: + The trimmed URL when it is an allowed public HTTP(S) URL. + + Raises: + HTTPException: If the URL is unsupported or points at unsafe address + space. + """ + clean_url = url.strip() + parsed = urlsplit(clean_url) + if parsed.scheme.lower() not in ICON_PROXY_ALLOWED_SCHEMES or not parsed.hostname: + raise HTTPException(status_code=422, detail="unsupported_icon_url") + if _is_private_icon_proxy_host(parsed.hostname): + raise HTTPException(status_code=422, detail="unsafe_icon_url") + return clean_url + + +def _is_image_like_icon_response(response: httpx.Response) -> bool: + """Return whether one HTTP response looks like an icon image. + + Args: + response: HTTP response from the remote icon URL. + + Returns: + True when the content type is image-like, or a generic binary response + has a known image file extension. + """ + content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type.startswith("image/"): + return True + if content_type in {"application/octet-stream", "binary/octet-stream"}: + return urlsplit(str(response.url)).path.lower().endswith(ICON_PROXY_IMAGE_EXTENSIONS) + return False + + +def _fetch_icon_proxy_response(url: str) -> Response: + """Fetch one remote icon and return it as a same-origin image response. + + Args: + url: Validated public HTTP(S) icon URL. + + Returns: + FastAPI response containing the icon bytes. + + Raises: + HTTPException: If the remote URL cannot be fetched, is too large, or + does not return an image-like response. + """ + try: + current_url = url + for _ in range(4): + with httpx.stream( + "GET", + current_url, + follow_redirects=False, + timeout=8.0, + headers={"User-Agent": "HeyBlogBot/0.1 (+https://example.invalid/heyblog)"}, + ) as response: + if response.status_code in {301, 302, 303, 307, 308} and response.headers.get("location"): + current_url = _validate_icon_proxy_url(str(httpx.URL(str(response.url)).join(response.headers["location"]))) + continue + response.raise_for_status() + if not _is_image_like_icon_response(response): + raise HTTPException(status_code=502, detail="icon_proxy_not_image") + content_length = response.headers.get("content-length") + if content_length is not None: + try: + if int(content_length) > ICON_PROXY_MAX_BYTES: + raise HTTPException(status_code=502, detail="icon_proxy_too_large") + except ValueError: + pass + chunks: list[bytes] = [] + size = 0 + for chunk in response.iter_bytes(): + size += len(chunk) + if size > ICON_PROXY_MAX_BYTES: + raise HTTPException(status_code=502, detail="icon_proxy_too_large") + chunks.append(chunk) + content_type = response.headers.get("content-type", "image/x-icon") + return Response( + content=b"".join(chunks), + media_type=content_type, + headers={"cache-control": "public, max-age=86400"}, + ) + raise HTTPException(status_code=502, detail="icon_proxy_too_many_redirects") + except HTTPException: + raise + except httpx.TimeoutException as exc: + raise HTTPException(status_code=504, detail="icon_proxy_timeout") from exc + except httpx.HTTPStatusError as exc: + raise HTTPException(status_code=502, detail=f"icon_proxy_http_{exc.response.status_code}") from exc + except httpx.RequestError as exc: + raise HTTPException(status_code=502, detail="icon_proxy_fetch_failed") from exc def _crawler_runtime_is_active(runtime: dict[str, Any]) -> bool: @@ -461,6 +603,18 @@ def lookup_blog_candidates(url: str) -> dict[str, Any]: lambda: get_state().persistence.lookup_blog_candidates(url=url) ) + @app.get("/api/icons/proxy") + def proxy_icon(url: str) -> Response: + """Return one remote icon through the backend origin for graph textures. + + Args: + url: Absolute HTTP(S) icon URL to fetch. + + Returns: + Image response with cache headers when the remote resource is valid. + """ + return _fetch_icon_proxy_response(_validate_icon_proxy_url(url)) + @app.post("/api/auth/register") def register_user(payload: UserAuthRequest) -> dict[str, Any]: return _call_upstream_with_http_error_translation( diff --git a/doc/api-docs.md b/doc/api-docs.md index d0a3b38..64ec33f 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -77,6 +77,7 @@ Public API 由 `backend` 服务统一暴露,供 public 浏览、图谱与 inge - `POST /api/blogs/{blog_id}/user-labels` - `GET /api/blogs/lookup` - `GET /api/blogs/{blog_id}` +- `GET /api/icons/proxy` - `GET /api/graph/views/core` - `GET /api/graph/nodes/{blog_id}/neighbors` - `GET /api/graph/snapshots/latest` @@ -423,6 +424,26 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 当前前端使用方式: +#### `GET /api/icons/proxy` + +用途:把已知 icon URL 作为同源图片返回,供 3D 图谱 WebGL texture 加载使用。 + +查询参数: + +- `url`: 绝对 `http` / `https` 图片 URL。前端通常传入 `icon_url` 或 favicon API fallback URL。 + +响应: + +- 成功时返回远端图片字节,`Content-Type` 沿用远端图片 MIME,并设置 `Cache-Control: public, max-age=86400` +- 仅允许公网 HTTP(S) URL;localhost、私网、link-local、reserved 等地址会返回 `422` +- 远端超时返回 `504` +- 远端非 2xx、非图片 MIME、或响应超过 1MB 时返回 `502` + +说明: + +- 该接口不改变 `blogs.icon_url` 的持久化语义,只解决浏览器 WebGL 对跨域 texture 的 CORS 要求 +- 普通 `` 展示仍可直接使用 `icon_url` 或前端 favicon fallback;图谱纹理建议统一使用该代理后的同源 URL + #### `POST /api/blogs/{blog_id}/user-labels` 用途:随机博客页为单个博客 URL 增加一次公共用户标注。该接口写入 `blog_labels_userlabel`,表结构和 `blog_labels` 一致,均按 `normalized_url` 存储 `title` 与 `label_id` 计数字典;不会修改训练用的 `blog_labels`。 diff --git a/frontend/server.py b/frontend/server.py index add1438..75b0004 100644 --- a/frontend/server.py +++ b/frontend/server.py @@ -126,10 +126,15 @@ async def proxy_api(path: str, request: Request) -> Response: content=await request.body(), headers=headers, ) + response_headers = {} + cache_control = forwarded.headers.get("cache-control") + if cache_control: + response_headers["cache-control"] = cache_control return Response( content=forwarded.content, status_code=forwarded.status_code, media_type=forwarded.headers.get("content-type"), + headers=response_headers, ) @app.get("/{path:path}", include_in_schema=False) diff --git a/frontend/src/components/GraphVisualization.test.tsx b/frontend/src/components/GraphVisualization.test.tsx index cfcebea..3cfdfb1 100644 --- a/frontend/src/components/GraphVisualization.test.tsx +++ b/frontend/src/components/GraphVisualization.test.tsx @@ -197,7 +197,7 @@ describe("GraphVisualization", () => { id: "1", blogId: 1, label: "Alpha Blog", - iconUrl: "https://alpha.example.com/favicon.ico", + iconUrl: "/api/icons/proxy?url=https%3A%2F%2Falpha.example.com%2Ffavicon.ico", val: 1, }), expect.objectContaining({ @@ -205,7 +205,7 @@ describe("GraphVisualization", () => { blogId: 2, label: "Beta Blog", iconUrl: - "https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://beta.example.com&size=64", + "/api/icons/proxy?url=https%3A%2F%2Ft2.gstatic.com%2FfaviconV2%3Fclient%3DSOCIAL%26type%3DFAVICON%26fallback_opts%3DTYPE%2CSIZE%2CURL%26url%3Dhttps%3A%2F%2Fbeta.example.com%26size%3D64", val: 1, }), ]), @@ -298,7 +298,7 @@ describe("GraphVisualization", () => { const nodeObject = graphProps!.nodeThreeObject(iconNode); expect(nodeObject.children).toHaveLength(3); - expect(nodeObject.userData.iconUrl).toBe("https://alpha.example.com/favicon.ico"); + expect(nodeObject.userData.iconUrl).toBe("/api/icons/proxy?url=https%3A%2F%2Falpha.example.com%2Ffavicon.ico"); }); test("tunes forces for natural clusters instead of a centered sphere", () => { diff --git a/frontend/src/components/GraphVisualization.tsx b/frontend/src/components/GraphVisualization.tsx index 98ffaf7..47d4809 100644 --- a/frontend/src/components/GraphVisualization.tsx +++ b/frontend/src/components/GraphVisualization.tsx @@ -2,7 +2,7 @@ import { RotateCcw, ZoomIn, ZoomOut } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ForceGraph3D, { type ForceGraphMethods } from "react-force-graph-3d"; import * as THREE from "three"; -import { resolveBlogIconUrls } from "../lib/icon"; +import { resolveProxiedBlogIconUrls } from "../lib/icon"; import type { GraphData, GraphEdge, GraphNode } from "../types/graph"; export const GRAPH_RENDER_COOLDOWN_TICKS = 120; @@ -59,7 +59,7 @@ function buildGraphData(data: GraphData): RenderGraphData { if (!id) { continue; } - const iconUrls = resolveBlogIconUrls(node); + const iconUrls = resolveProxiedBlogIconUrls(node); nodesById.set(id, { ...node, id, diff --git a/frontend/src/lib/icon.test.ts b/frontend/src/lib/icon.test.ts new file mode 100644 index 0000000..9754a2e --- /dev/null +++ b/frontend/src/lib/icon.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; +import { resolveBlogIconUrls, resolveProxiedBlogIconUrls } from "./icon"; + +describe("icon helpers", () => { + test("keeps direct display candidates unchanged", () => { + const urls = resolveBlogIconUrls({ + url: "https://blog.example.com/posts/1", + domain: "blog.example.com", + iconUrl: "https://cdn.example.com/icon.png", + }); + + expect(urls[0]).toBe("https://cdn.example.com/icon.png"); + }); + + test("wraps graph texture candidates with the same-origin icon proxy", () => { + const urls = resolveProxiedBlogIconUrls({ + url: "https://blog.example.com/posts/1", + domain: "blog.example.com", + iconUrl: "https://cdn.example.com/icon.png", + }); + + expect(urls[0]).toBe("/api/icons/proxy?url=https%3A%2F%2Fcdn.example.com%2Ficon.png"); + expect(urls.every((url) => url.startsWith("/api/icons/proxy?url="))).toBe(true); + }); +}); diff --git a/frontend/src/lib/icon.ts b/frontend/src/lib/icon.ts index c226588..b1ef3ac 100644 --- a/frontend/src/lib/icon.ts +++ b/frontend/src/lib/icon.ts @@ -65,6 +65,28 @@ export function resolveBlogIconUrls( return Array.from(new Set(candidates.filter((candidate): candidate is string => Boolean(candidate)))); } +/** + * Wrap one remote icon URL with the same-origin backend icon proxy. + * + * @param iconUrl Absolute remote icon URL. + * @returns Same-origin proxy URL suitable for CORS-sensitive WebGL textures. + */ +export function resolveIconProxyUrl(iconUrl: string): string { + return `/api/icons/proxy?url=${encodeURIComponent(iconUrl)}`; +} + +/** + * Resolve proxied icon candidates for WebGL texture loading. + * + * @param node Blog-like frontend node with optional crawled icon metadata. + * @returns Ordered same-origin icon proxy URLs. + */ +export function resolveProxiedBlogIconUrls( + node: Pick, +): string[] { + return resolveBlogIconUrls(node).map(resolveIconProxyUrl); +} + /** * Resolve the best displayable icon URL for a blog node. * diff --git a/graph-icons-debug.png b/graph-icons-debug.png new file mode 100644 index 0000000000000000000000000000000000000000..ae03e02a0b0cbd8445df473e84a5e5517f2024a9 GIT binary patch literal 144868 zcmYg%bzGFq_cjemDj*;!AtfLn-O`{SCDJI}-3%xvGfwc64ISZBf0bfy9@97 zeIB3R`_Jy)^VykuX3m^5=UnGH5g%0K@gF~bjDmuKuc#oSj)H=T{E4Q9gNFP{xrp&a zK_Nm>l#$Z({C<>;ZA>|f0D`uC^rHxO&43p?iPeiDs$-2dN)D|u=1CJLb7XNs*H z(|7DkP6CVb}C2-WyOCPrF5#8mE^ZI2%a}gq>y5-dU&m$zV;|<;# zE{EcI$Q;H+I_RHP!qkpL^+XxBUt4GYc7r7Pu3fQY(JDch=27K{Fr8Ygn_b3}|#=@BZ0*YKr}dwzv34YvxqOZ1T2$UmSO9xLw}UD51U~ z*fWm%?_{`D+t{S>OAyIQhRm`~_7xn#tR?ARB9R}@venyvyA{ug@+rik|I^kdoKo*) zO?Dij*}KG|dpu^U#8&yQq|#}}yu3?0QfG2XvF@5)|0BM1$a}-#7!8pfw`r#G@E6fi zLuy6G2FCwxMBFXs#Z2HV*U(9|ey7ZmZaenTe;v8B4As4v%@Tv!{hP4vbX1qgjU$Zz z%<9k7R!h()_gdb6AS~Vr{I^N0SY}8A{R40&_xZLCyJ88}e|vM7%w}b$ULxKPp>6|V z{ChOp6&|VGy&0MpQDn11Alm4s&kfCV8~>i#X-@SDnXB$l?Sl2*_k@%Gy`)ohqe9-S zI#7m9En71;cZ5hzmFGi|R{8&JaI>gZq&HoHTg@gk5fyCl=Lr9iIkp+ieHyx*Ntp5n zi<-lH%j$_&6V9xU{t7yQx)f#*H^FygbA-TWG5_c*J zmxmV8(8swJxcA&o4@X(_YO3CTzV!P1&qRF&H8Om;=f4@$=Dv|_8r~3}mxdOI&gSE~ zaz6d5@eEulSA{fQQHz$s*T4G+1sx$eyd|*|yu;RY|2v^l!W)Db*4FOuM(5FaTrwcE zhq=d5p*8h-OPKTOlXwHc>tLiR(eaS%Gn_Z3PM4^3!RN^g9Zxa}SYAF=Gz+1_!Jt^X zy!}CeLDC9&%KC(<5=Sb217)N>o(B(~BJ6XH|MgBdt(WyxrdtL$(qBKJo#_xoS#vD1 z_rGO!h_24>m9R#(9dZd6x?B}M%3zd|BO#$^`~OD#{7rYXeD(lF<9Qh;0A?L8n#?_P zo;REL$7D=tKVhH*YIx+rZ-9^tZaa%}}DazsVUl2|neufF~? zgLZSffFJ9e0ro6PJ7eEI|J5M|zT&&fS2wvw0LYp{$92)iLa8l}?Y9Zy8mHw?7b!07 zYGCaSB72=LaCm;A;#2M2YN)jTUWSm3#F=e16V7{-8;iCRoKCbz_xvONY4y?~)p4Z| z+nhF7k*b6@^(;gooZhdbvQ)`5FMhk}?OnqNVi}OwRW}!5@C1V*mgV_PErY5-gS(%3 z0z;dsw%3%<+Aa!vP_qc?-?J$%mvew{VKRGUFc<(hMXZ*-lerR^%2&wUKPZ37iW)h` zaW5NHm3kSH*imOMr4>!rhgHmV*@aCboqXt!*;s8l({9t5Oa|iaMhbQCTQQ-)u==&4 z2V!SngiC~WmWzi7?Nh#kwc2E-UbFqf@1)SE)K}nIrzp@Nb02m1s<0El33k*3N(iSG zv_G;8Rw9=Q!}RVv=?TxdnI83M7EcXWyK_Ard$EOLLE>zVp$Q`St2Uo0OMQ$~GbgIf z_BEI}3@#N}NM7Rb7^4Zsom@H2K~~ipw(`Ssz4j2QLkea+b>RkHi?>??(dsk>PvzqC zaYyQfv)#x22#jWzp0rJqb}Srv3RB8s|FO0dL6;k0pN9RW`Unw-BVLuaFtUV29kg`U zs-T7gXv#debAK*?=xe-)Jpw^dN>#e8hR!fJAzQU;=({sIQq$2THX}hxZV@!_Ke4%D zlyrVqO;l)o(y%zA*^YH=BojuD$Onw4KE_&}fiT301wcaLh>K#t25x6HT=$`g^7zqo z(qu_PzpFE!TN{q6R1HsZdAi)ULE zE(~QsPfT%o>TT&WSZMhQyZLX}N)>CWA+~8o0soDug&fl>Vxp+>{9_cRMfck~>3~M< zoV(C)TEBBrskWU$RkHiLO{48d!^=OTp>m8l?i`=9DyJi zGzra!Okc;7_8kh?GbsQ}2@@Fe&qC;=)9>}$8@wWSSN?baQei^hi^{km!-mlm-!4hH7{XAuj_Q+JUjsnPJH%_rhbtz|1O%>qOZf3uV?|mm=Y- z9nholMV@`vahNK8SL~X{%ERRnK3|c5yd04X_iCGFN~VU{7A3y}xTEUixAgTW`@@Vf zgvMt<6fljI>r!1*v`+x~GD%n|ML(7$B1T!hxYqLJc%YDAajyv=A#u3fN72^3ZN=4b zHd*!<8Uz~~##ic`>2CGW(!KIQKsaI3SK8*ZYqx+07|1U!5q14sd0fm9*f}Pu?a#pn zvRYQr#LQ7WjgYZ7z5Z41v2y3b5^y|AHg8+$x7#|e@W4_Q1j`H07sBeQ5_=Z%GV4Tj z{+UDIXUnYq882dAFHkHe*zb!?p{hm{p-P8yni$|0?gPRSEHn+$(suP1K3WTc{c>L7 z81CchLiB7A=D4rC@aj*k(UtI7Dq8U%lz8&sy&L1AB3`g=c+A#y<9?e(>$`J?S^p$_ z)Ol(Wdlc+I80;!lPM}Li9z?umIur8MAJPCB6FL6PcL;cwB`OYh_htZi1CI0eI+%Qp zk4-3@KrSl!5N*O|9h*#Y3#}%Zcj9*`PNMyvC-^0D|MmN*jw!Q5dL;sk$bt6T%!_&Z z1uPzNdqMlVW6aJ9sjA&BosM2BURs|8f0AV_lT#z)tLfTHejO}+(YKv0!^M0IxUU)Ln0Num`)8tWu}2#U9ivK z)@ex;F1@*f6f!>`Xn*wcOam??K2Vc=%}wSb|lKKP54s8FvR3q0J6BYl;up zJ@Xler7+kge*dyBl8N@})6oSNu>E&ESh}RH?zW79pqj>@)y`)X9lGMaYelUxb}VvW ze&l6pzkORuTdVtP((>phro8=HeN`@+pikc2VmCT?6u1KYbwUif{Pa|1ewF#7$F$4F zl?U+V^G@4x4X~y6cyRf07AnzeZ;p8yZuIKz^00W`z#Vy{+Ra9MANmgBfVu^98jV3CmN1ThooFL~RldE6*YZ=$wD$!%l>3hCxQ0;;s*^0kHWC zpRpyx{9aCxdY#g36J@h+gX>Psn2?lLi|>xGpb(b`lu_NgEKwh-Q@n%wuLB-#r9`Hu z-g-i&mYAIUBMaj>;YrE{p?&5H=j$B+OTtMm!NLjI`v&65eSeqF$6&1jbNshz(V_6usQw^ha;o$2hl`iVi$2#Ud)fZ) z_V(Te0l}}@lLZjRldf4Y-$B2X0L}y5QudbDvE^*x5}uY5`*a3P_9Kb8b0>h(4pXMz zr>g@WgP@6i_H-nuF;4dxpl4h^85P;m!A(W#y_F6f%d>ORgli5nWsg5pMWw-U2qnBA zm`Ou88KJoy_Z!ScJ?*Er=;72Z?pkJEE)9Dq;`avgp?~Gt_{Adx?qbTk&j6q+MIBXi?5C9*I zX5AWDy_Ubf*qx6G7#^tUyy}_zWoCiYS_;M!F@U}stW$N(AFKf|fU@(jpuWH!KCYIy z_x@E@Z%af%4Ij-C+3I073Psa4aMk73>QT*|Lc7`Q1DJ(KIQ&3m=ww9mba}rTdsnh@ zU2h=!K=|C3vg_s=J8H<1=iun$bk#iKvA_QYe(y`mH}JjOnzoCeQil%MByiOgGkHY~ z3`Y>!4jO{UySJM54pTgB5+f{I-dhu1grb)*2{V!H2 z_yKIkQSVVHrl*r5^zv3fc>`;b7KbTuXs2=xw*cqXgfJI7bc2(KV0V$3_6%YH{^4*L zO>K~`S?0Lj)sR+oqcg;=+e}!ROdq1m5*FaI6WV_nKh8w6=g@LiFk!=W3FjM^xwtA3lu0!HnY zXXi+#h#z2HcJX6cHL&9Dx{EY_nY~qyXonFaVtH1v49_C)%j!IOyVE|G>i7C(@~l}5 zJFV7S^Ec1H^;HA}PZEF~b4y{Vg@mZM_8yk5H#-jYl`JlggV!&@yWs9Zp8X%p}D$J2!b^$dG!{bN&F2(~gyK@Us z2qb))K)Xu&ER;#2TIj3{UAt--536Od2)L8$P_B7YiksG3k~?SXdm0N4Lh<}PW;1WM ze){{)GG8U#yR|rYOtWtdB(k*yGwfQ}1gx zyMPb3KQXIT;{sr?wbcso^K}D@@80_M+uOWcx9}0jdF1L9G@Pz;R{*G9cSH*8+;0OL zE@_1(F~>e^7HY32dG9v?8l=Me+RSfDNq0_w_gh8*?R!tAeAXo=spT!9I*&ZSL`hme zv7B5(;1Bk^s-L!R;JdvV8#37mbuK}N59c)@d!ypJA6msQX9ja0?iY^sld1(DNG!X9 zJT-ZLxVIuc#zfoNP^ZXx^g$(`nTm(ngsoEiRX;Lb{heeOmEXS?cJ-^GHf-B^$ z699A2>p;D=4Y<2HGpV4Pp4kkzk#LaQ!F&I3>p}LmkEo1f%3XLBG2xX>!B?QL;Zbn;`GO9$L z+rtlmibL_=oU2aGyx+Tdtfr~jvz3zn3nmg2CbLx+X}+^tWH&Dlq%=GAZ+GJPoHQl% z9B;$kCedOVb)@XVK)V4%INa>wwR2Bs4>P=hfFACbui}ktxn~8M;|)fm9Q@%Y%-~&2 zom5n5Se~B+J0a2ym2>tka1Q~I;mLn-w4sa`kYxMXtM)obb@Ux32AZJWQG-Kj!aHEk zBa?Q06ZB|;4`_}K*cJQrE~?)90l9%N)Xe;$Eo@2(p8RP3v2zemJn`M*59f>X;-6YP z`}c7PyzWw)$Ix?(|UZ$*HrAaiizH&5Drxd7#my+-klu}gdb2ULafeiV)ajP znFwR;wfoKAUg3AZ<2VC)2NruC zL+#hw8pT6h0eh!i004Xui?j@VR#2JrZ`oyQ&ZBP?4wI30cVSE%|6}Bw~fMe;> z=3$wc@(q1&s}sz9`c?WmYYP`6V6Ejim^ZF!EIMp~X`8xH>#+Gq$i<^L1R6Ff^05Z| z7%iQ|X+m<2ZTOifZ_9}Q;@X$#egwR74I3E`GS5VMqbL0Cn(lax&u(aX98MsdLQ#83 z!(TsH)d4wNy-b0{{RWG;VD#FIy%EBH;a}GUWaCy@fpy{1!7EZadl%X@r-x~!63~zd zZo#azbJf$(l}GU+3@IN*(}GED_btt*Ms%gO5}a|7unx z5(5LIL%0SjK=)_ZmVmb1RP6Q7q03IlTT|lll9CW^*0?FTi>3#G_nB4BJ3PZc)#Pl% zMrw_v04aW@a4Yo6VzhCYR)XpX5j1>j(WLm+nGXSTOS(p(60n zL{dEI@oa92cju)>SVqu2zuk8u_MLEeQp)1_s>*VRY8TdtFqQC4)y+=1JS*=&$aed6 zGj@P(=jS+zB-}X(C%ENAXIAqZE5m~asTcOB9l#f0>&Eyz9FBBR`v_MwBFMZZoP$iz z_M)fFtt`)lkLN^A0(RSsa`Ew*hCf;oy~)CBx*Da6c<{eSAr!r5`@SGx3rZEuPeV6E??MIO`NzXZG4xb%TKp@zQ^h(&ErU7SaSV1o3sMcuex-~4tl@4W!RjIcyuWlad%RguaqcGJcYmwxOuK)!1!8IV zY^LmwL=bteMR_P&_fE3<8_A>k=c~Z_Y*%`3>z@lUYa9(df_<5=abchfB;znbjDf&` zCkOh5RUaow@Cz`vxkaDc=Sl6&dTg7cWy_)C3n+2lJVk&tYC>MDEF9_FUJM{bg{$9A zStBlyL2`dK7{w*-t2Z}F896fttxFruqJ8z|`Q>oqJG!YgxZ~$)lpH>{FE-|x-^&*# ziN>NmMh7-Vs;8~_XJ_1)0d;3AvTv7XnPK)b<{UBou|JMTct4@pf z0feE20DYg&TA@CAc3S{tZIvuh&Psn&LCTFrTkC`!fj1N1UCtBcSMS&5k%9C1dZ(@TfPUG$|eCqD~bJw`K;zj?_%7FAL$@ryF;D?$vq<9dzHW>xEk+;-;+5(aMm zhK_o68Yl5pqSo$jVyhSW#Z{pz&;@p2=S}pALd`S9X5Dvr*vUc}nru1;eO1Q}557jt zC@Vx8j&{Nsjmmp*SWaIIJunq ze~jphjOr=?7^W$k(Pugl0#E0&6OdJ-_3r+TfM%j5l8ds`u8ZE&?LR6n#G?7uninT> z8ytKlxn-^X#=ZTxfza@vByeOWkox{gE8q_LfN)#uVHP_b*VC>tv}n+MM&&|o+X;0D zFl;h6(6zSvF*MtUP33-YP3cPpTKKP z{HAv|quWa|$abUJ()tWL_;X^$yg`Jd%V(CSycD`V^tJD7MDprmJ=mCvFoGK0CLK8X zc!(VQCa!lzavDRvc7$LRAmX&M0rb2p3?8UeG=xip?DIjy)g4BbwdmBiF%c7wQPQFx zI_|{bH0JXw>Yp5M?iabc8F}rNkth*tTN#2x-?}aqVE&Wd9I26=h8{@CN1Qi}XD>Yc z4=jx@jbtgj3^>VGnaV6(+6sxg%JS%5bu@gPqu{DVle|$t`!Uz^GRTyu@E3Ijo(E&b z*hW7dLhDn5{fJMlzqZ!Na7s-((LN#RUo0SoviQf%A7n-H@$g1V-*w3HPun152o@Q8 zcm{9rMl90?xcHbay~b?G??7sY%Lt1{B%QqnrN}{v%~-&R5uiK!@)#<5HPp*AjIy0v};W14Ut` zdu2M9Hdt57LN`am&?M43osl7JhyabKEYAbfzSzE zzV8i?$PsZF7JKkNYuwrmx&BZ>jn?`<*h<9WuH`_>DTT(6}#}?V1g78egPE*Y9&dg?53Q6xM@rz111Tiu-=YD|I4YYFRqh3Ub zis=~;mw`>$`jbDP01Rt*N0&8s_v%0})bgz+5n_wwHfamxzgcyOFXxS#@4VQ!#96p0 zudt7jz!+E-Px`5val^J&&4k<4;dRaxfQ^?DuSMoAI))n6Sj)oG7o;|*y3^y-{T&(XrLHf~wdHfBL&evg(8I!%-iMj|>P40dC$l1x! zIq|{u&fPJLE1&y*V3w#=Z`)N?t`|H<9~JKa3XiJMQn|FC0eXxbSs_IMl&G~*9?h4} zokc|530J$|!jH!!f*--*$__bB6)RMZ*A&b&F57S;eNVpq2rCWdM+FMr5zar_o^-a$ zJgotD#F!3Ps89)Hnsz7^k6`&qyM%(rDQ)N{7dnKd1E6E*>xaRYh-t08-VCobEBS|G zqj8a=xfBTpg0J+?k~Z$x{ZUKr&b1D^)55p}-<$>$s)$S^WLm!}_b{Xrw zsyoxv&X+qD7lQtM$mAaa#(u3*x%#XeV7J`Xlv$%_uUxHC1n`@^kkbW~1K_2X-SO*D zY1{p|%Mt$t68i4-qgk1w3fM-Kr|yX3aoc+>SvvhL>kR?C`q2JCqQwhw81f)=SkxCM zVoS3Eq;ce3@1mD9ZnK+Ot?i*V`aswnhLfDh+gp#NoVD! zI`=K4J+bOts6a;5my68Q+$O^qY18g*<_N9QKHUxX_~>$+g2U-hW#(AgPV>HJo-E(? zR|jHveqS+8%R|3w#D2xfW^IqT-CU-Xgo~c%3IO$*pYjK7hcE~8`Z}5Kn)bJE-I^Lm zM}1trqt<@uuliC0UH^BFPlyJDn&u1+297x#ZeO~O5+38=aI>bruGx0pD(+gcL zS#hcR7;=_prY1}fXafI<>(I9hyogIH?!QEW^>|}|%%!lAaS2)0X4`(@n}o}|kn4BA zM16nVhDmeQRsGFlZHpFq<#UB~af8JaoxDf?f&;M~ng`1+%9tye@SQG;S}8D5B4>*n$At%ReH^IVDRgjk z-eb1XE^p}00pzAXUM_L*O&l@YzEv^e&~m;qzjl9wOd`F=fEkz(uV5NZx}%rYp2Bho z79H&8I738q``@^=Fnb=aQrsFC+F{AnMYD2|9FPwsz zQ}8?i8-gLARXXhtya^9B`^brCK|=J$9uh)O?fG{(f;3A6%?>v-;t0SR$B#Df!|$j{ zPGGklK%LtvseC4?1|Em^+&x#&{5Z_6UG>MwY=h*m_~}h0h`Nhb0gRso2>IrBa)nmm zKahOpc+IKTN}$qP(`7^eURk$;KY)Fjk4T@#?_pdO z%rwHyw1+cV*fF2Uy}yfsxfFxtcH0d!AXM(_5u=`N%yY&SObZ(AVU-Fd%o&k4*$#J1WH&BN6h{7{Jt4 z-bU&BIU6ptJ>q-kEX@4g2T@0t*Ce4}#T)M1*~Kam6zq$&c$$#Up81D9g|qvi+!4r z524mkq0QlMKdV2^Q_OMIC1fQd8tTVc=U6@n*%&;sBEHykgq>e)k}jT%c1f~OMeMb) zc!kb8tgW>sX3HrN*#n`hEl~#@ij$9uEc2d*81A|2igo3ok^t-J&2-V?=rf)s+xNXJ z6c@oALpp-T{PwQ&313B)^nt;uj5=XMV<}axpF%~96Q`e`nDVY{AB$W4a2827+mEw3 z{y5UYO?oSpg=zmx(P`T~=%c$x7TP}E^e&B}cONS3_;!p^)q?g8(Nm|fq({|!53k|_ z4d@`9BqZ4Rl_aL$R`?;URH~%4A2xjG5G7O@n3~pJ4cx7_sRkmD$owaO|8yMtoavTE z={s9XTf+u->E=^04>0^U@wmgn%-w7mGM%}MaT`SxGtfRYyV_vAFYk)QhHho!ICOly z0^pn&-rg=s<86G>>Sc3S^k`P+EaR!nDVvl%Yp3sqYp7<;e6P-9>yK5tk}88@&R4n% z7!dT!cMTv=x#ku~lT`l{&;Ob8_?w$onPUFMz18lIR4SknFoHKOd-qS;d=6`fhB3fnLixcn%2>~uIk&-r}O3F!B%TV}4?XOVXrPUN*zG22?` z%Rap7GirB?adL&}UF?s|+lrQM+Gt;nQTz6UQF>6awzFDMXK!w&_W0Z^GW>DheqPh# zbpnN=!5kfGY_1>_&XF>Uzf`b3rs7Bz1~bkOv6(r31ZfIoTxfGXikxo#U|^!i9`9`G zJ`jZ_$dxHD#n%u^D!xZf4n=#<2qdE7J8PyStPj$;1<$ekwU^&Dhg&(HspwIE**VB; zCzYyKK(7w+`DqXE?(`3K#iyc2GO^-I8|>|}iQ2TGC?T$cBLCyu;Up?+k8-ug&??8< zFWSM$k&THjv{5wvvQXlZMQ0zpiA?9E)n;;BN{EZ}uD|o5w+k=~zAi|cKO`^;h&)jy z{qNuF9TG&wiv}GNm^@Rz<$%=ggZCKwsnUBMUf({DGYqa{SGPLujG8JtQkU*{k?2}z z{7(MzFJoBzaD!zJTe&&b{r~Yto+7?O865buW#n?v^6mlREKIgWp`^M0Ve?`=Z_4yJOA)APIS+eFSv>d9F3&l6hiQRR*ncjjQC9PM z*fr+Qc<%mVpg2&Ts$J$pN!?KV5Z!{>n2A*O?99T@|7LRU(bT|B8hlfGFL&s5^d_PJ z9XUKn($FVyYu(o*Y>jn<1g}lDPG58sO%4UKPz5hvo;~At6gIH1b)lU_6x>>yuV8qX$S9 z#NB1~rI_L3j=O`>kj9%m8axm7f7roabPJN%3ZfZn&=>3|DwdZ@PQ<21(F1{+&@T3^;~qB|_MAtI1b@gy_ukiyPjA3?|Oj_HAzs<8^NK8)<~Ha}G! zid?)D!2;0d#OG&b(_veSVISm0f_KMB=?<6I;Z^pg{;~G$FSq}w!};HtWs?>AM(v?y z3N|?h6~7ETR$OOE$z)BqgXz!}<2v&i4#;@u0ym6^di8AFeXo%;i~C0Ri#a=Sqd+_l zTmF%oxH5WcRIaCcDVEYrV=)YJ1S>StvQ9rHPW!}fe7!;a;cQF_64f*Ug z@6kJZT&1G)T%uN)WwKm3zf#k_mv4#C!(wrVmRykwCRnC;ITCaZDOPRJK1=t&;ox|F+>5R3*zJkSi?0| z+WB~{ImVl^_W5t=0AY`ymW)@q7+6-UD!XLKJm_TCtq0rxD8Z+W>kW@1$?KryBO|6n z?jSt@(wWXk#<9wZi$2@*WXCo%w|RO~KN|CD9LCP_J_y=Id^00TCa5>npzp=t7LZ+N2f3?A5qp4{d$yj$VME;_hL!bCZ2Z8GpuqCT3z3`Rbyb#$_H& zn;a4#A(9K-3O~o?6okG~Bf*p+lGO>qp|4K*nj9#K|E@`{Z0wGoD@m(2`;hgMmwkG& z>A2z}7M2eeMYh#D%UUnFj!T>|q}{@eSrl>sNCZbr9{4$FO? zpSvDji6N=9{Qr{a_Z<}!)tGjIS5xwrF2vrGuX6Kcy#vv>EcnA9yfyl$3V0=Gc!jwRy&yvK%L7qST2@yzM4UC zQkWIK@W<9q)VAK(BYa$rVfuC>tTFvW4^jNh1HMa-Glg3ebeE%~32K=E-t8cgy>HuW zO8^RF<9};ed|L*}b4E1!XQsmiXWhKYC9ae`vm|B`7!yiZKK3gN8R8g|f+q1ea9A_d zRZ!VX&K{qogTHWSlrJiP}M*pHebQ$OmuynpEalQikbphB7&#_^Th@vJyqzPIX?^DPx3kNa3iz zx&^18b{Q+6pRXoIvQ6Gk)uVh|8vM7C$F-#hd8Onlf7Am#XM8)kn##ngBo3hp%HXl{4R!iX*?ml|;8<)JcZKm=z^19*Lv* zH?3B$8Zto3RZ20{6n@@*&?Q;AZZ)l$wWArQjW!V_){b61sm47vh+nS8eX~D*#l&pr zDUkvxmr-04oxN*IP9B2p3C0(UOlQn)>28sXw(5fY#s91Bkmgsp($7is{!wa&K5HP> zKTr<$?AZ)?X!-0*x5XbW_AKJO-fr2>tGudXIwzW$K0(3ID{2&t-&v64V}WpI&gXr{ z*DK42een}VA@VRgYfGxUE$L~Q!jhR+pD7X*5{e{mzRrYupKob$jUbD`q**!f+YrgX zCe6JMz{9j9wu5qCP#q|PyE{X_H2FW0``|!%!LWwd3`X_tf_b7s0xAbKtmmH&7) zpRS!QNm98c_m10hWF{fOkyJoAp!A z&@l;Wr^+>MRiq6+_FA)%Cn@CiT;Ok!gRPuyvy||gVL`uY#MH6}KBIg%7@!bZW=>-4h-g7C?AZgk~}<3+Of&$@o{rj4pNm6KdqaDMlz!lp77 z`$IvGPl#Y7)rh2Ll40ZgI1~G#kFY7S&`Q_>&oM3V{>^W<4TU!xk6WFR78BQRJ09f< zsl3*<;2qtcYIf}QQ0amI_z_7k)VMQqBKADxrf_WPil&9k^yGzr7e z_ynL+WYiK88JjlTYc!$mXUC*&Y7qLk?wHtjP^9YS=Q)**y3c<#h~Zzpa~6nlsO{4n zj*l(8O5+;!-<_Mj7Z%!Kg;U8}>YrS(o25pcR!TF3U1{2M!; z>DGC``N~wWPz`w)sx0+6cGTkd+Q54(-Lq&F3H=6$CmVH~BF{PWa>1{~>~I^iKrr>- z#&6Lrm+a3bjXCXPW@x;Pc;;bn!p%9)K&qE>K$XVaLyxrNI6QbmhPU3qY>EknYK~wY z?ceIasd%~4k{K%05Dj_;%otsC56k{fP2M5gOmW@E>phu0_c0eDX6D*;lxY+MWXi{MW=g`Ch>%Os) zsO-UR(n){p!Y(gT0424 zO0Ia%!x&W+o?}5OL2o!B*i=?c6vMQvX1+5&9f~=;6izh>@t4@wr12%|XWW0F8SdM= z%q+Q+RJ3M~pZb~#P5*h@IQ(|c-T=T*Wvf2-_IbQEh-5n9yE?+N8cr-tS|N4dlU?L! zXQ#bQGeXv7ScnX0kbb5~S%H$GY)Zjnb7d4QI(jwPli6As*(7TL`~wg0V&>4+qPfF! zde($fR-U7c8LGb8GtVEeFW#LwW{V$K(kUBttLi?zQnS?OEd3~*`7YIXz`SsXrRb|5 zEi-zE4wry0iV6EAF}rN>7Jm^3>V7^zeKMMl0G~eMi6al)-~>wbM`sx0Y=84qm-?85 zSQ>|K-58Fs8S!88QeQHgTE=xSdP6+RpUG9>pSxNaRcZ(iI(}m53==*h8ikBc9SgkY z^Qb@Qgb?Gs$CQ_>(f&|@*Y|TvY*0C5k`d}ZHrXbetx~0h?I%?}G5Vr(t~)&Y$fMLO zv!Ke|rkhql*^1}2C7(>68dj2*U&zaZZD!4>0vSJmFk)S={237A-jPT4If9|Z4@W98 z{P`df0Z<`jVZfg_RO(MHYFio}7Ab{?xhk;!EoqTg*Y{eH_EH@Ft%j6w1XSJ&waxMN z&8!t6ZizZAG<-Uc*s}wh4I!nImI{oC!RZGUVyi#>K6}soVm-6`Ij#9H zT-wW?+ng32ppI5i7S@N+25QP7q;Ro#(j3m(?I#B20e$?ED8ntHUEnkPTd>F1Z-pQ}e)oi9=Ev4ZR z6_-y&AYRIwQd{roHd=HmoT(1ImtJBN62@A1g$T^YqntG4;r&yO_t@fNIJ;$;QPsX}lqEikwMyi(3_t+&n5q|px5*)zVVQ-9$ zI^7xLSy-`mO)^bc&OA=AD?Kb~d4~wRQh*MHt&C*qxjCfO{;!6Jh@xV!@<$`@o7ekI zcw?jZW{N*HXJXHtmaT2xXkIoyG-BxmtJCqoXSotAf>c7VPD)S)4Pv)Md)!H_UsH5X ze+w%#r3;#$rHskhea7>;OmJ+&Rjzl#QNiD)fHB}VqaeMwD+UFL>uYz|d1H{&!Ktd+ zW$gn?AhHfS{)az*U^>~+s|b;K-m;y#MO$+n_tnf?OtsesEQP60r_~u% z3Pd00e{}Ws6V867URCI5W>6s@L!S_om7>&R$@9`vTio14DUFiY$fUvDX($wHg6QK4)KKNvNNodA_?ULvm64uW#S- zbW0{xDfOSj1NAAxFtk#3f{a<7?mJ#R7iiWkR6~|{qW`H0Jo?b@@zs91TjV|Np5ZL) zG*WXT)7vvY8yiO`Y4KHwYz?SVrdN$s6s^Q>O#UFBD_^cq@#l@Jp0;sqKyf1pzO47n zO40X5=pfH>d|d$cIy!e2$MdR^);iV{wjbs~O_P?6Q_&SybuyN^#nX#Tx;C#rq$%|y zuaGLd-&dA<4|K{!^9O)E2FaxCZ$PucZh>xaB1Gb(WI^nqh2u$8D6pih>GZT(#D1mT zN#{+=@oZWST_>uHwhLms+!L{juPkxDK3iu(XRWyP7YmR}@|T>x%)LKd4qKYt`|fA0 zZFbUi_;O*q?U+5+XV<7uUMq1b|D%h`-mi{3fizLrrS!efcMplwgC5JaJZ)MCW zHlEJgHhq#pcwA!~=0!=MZH5f!jP-sqN+S7``|Bq$?Pk z7nS2+ELr?p8G4X`4?yWRe(>wl>EmG!x4>N+{n0?w9By6vQR5Re;C#@EdOwC z$5$`=Y0W3Sw%?fb{-tLK9q__BFhqmf+gmwGK!E(xo}c`(h{RJ=++*jY^~s0(2d{$% zmdIcR$8|X7i2tXAc2ZpmlFxV|AH}VoiyA~g+Q(NMy1ccA0xr+?K(7R|khrSL#;47C z^_SHB6X%cVd(yY@l@x=bMI(W{@!oD^QacEdZ{%OwHBqWmD(4T;#esB@ra$|5qAP#l zyIb}YenyGLTF6xE_-PuJg<+>b=E2oU{{E#x%0`o4-j9Nny)$zR$Dusd;S<;1c@A0`!;`#^?a_iBb0yCn7zH%ktV^p7 zvb$WWg?GW`aFzU5@7`-z9I3XUtnu6|`+078`QFX~!fBk8>@naLvc*tZOZ$ne$_B!G z-J6?w7#W*rujRt-|f^m4=zVMVC{Z=dz$mZrUWO9ivNk1yVj$xPB~wY zpj_UhO_?>|m7mL#>7pL?Tlsd$#p zU6kuT1Q?f2qt0unmZS+LyQZ~nO!R*dgQA|qq0CX@>BbCfoyAZ6KbpRQz0U7zI*pw+ zMq?X|oyPWwZQDj;+cq0pP1D$R(%81%yTAYYe1dad*WP=rSu<|Jf_tz zeEA|8a1b4ILGV26)??ogWLi&eT1B$L7-?Zu$;Ix};js}jM+vBN|QNj;>{ zIrU($P^nv{SD)A+h~mhy9t{tXb0Arolk4N&`|g$N@ki3bgvdp>tdwB zn}#CJFgz*OA+j0(<25$yX@w7Vu-_3Yb`PqkayG{_Yw=$doHkkkR_r?k&Tm#KM{Ys+ z6L%2_Y1t@iTMd5m-h>FxhpZ+TDV@3sT0&rt}K{wMoP`| zj}>k?!}9z2pE~!Rc^~b4`OTq`A;%U(KVtN8msNG$Eoarx;>n1QX`QoZhdc z25i4qRq7`uHM46vy&K6a3eq#F8V1g2@bD1XIXJ9|ufy{fj?UtLb}lKI+Sf)+A2xHj zqAB6u2dFm1`X|EHAOjKvez{=H(!t$%U`M2jgvUJVBh)0CbS$6a4kG&_gxSHigTgd~5A^0hlGe_=vl#BP5 zAj)ia>*m<2{Lrth4m+mgABUtLW0+p|ZE9}0EX*%I%i9J{?hfW(4~;$ode(-Pme0z|AoZ z-x@Q}I!&&+)plNMSv3cB|B%UBl|HH`1j%IAePkqWQo($3?jAQK?<(DbwFJ^vVk9X) z+HqZOT}mCHF>>!Iw?eP#3ssTsGFp1}Rq3W3Rd~4ySbxrqjvCjpF&z;FDMDcVF=w%y z!|mZc#G1GJw8dY#RbIGw^dl*64GISlTJdwl(Q6eLJ#yfb6JEQRXtb+h+xQ%JVR&rF zVA3>O^pSDTpt*2msIHv^S-Q%1P!9~D>o9>A0g4`ryL@D~kHb}qW2$NPLItdTgryD& zSG>w=2(ox_U4GNI>@-aH4IC0!7U#pnE9vI~-)*iAb+B)UiD3(v&IpnvF5hq}M-f~J z9xK2W%2=*u(vB5BymkbnvO$#^)gUuUW128B7mqSv!>}7_5MRR{Eo|na``)=vmgDuE z<1*FfaplA=*JFabNXv9Q>;5kF!g&5d4WS397oWEU-Y!Kw(s@-@jalB`&IS5;x<2~E zC8x7|SK!3I@(aA!PY`_DVyqnl7Ca-s&f_y|cFaj>q*kSS)il$3`QXQY3&?A)mv3r=v3NJY3uSnH_WDPwZI^@@q$`-BDlEUV9s1;;%2$5cxJYA>I*N4N%E{2^u*&cmbE8%LWQRyr*Dp2i z^*pZ&P1q$?jU1YY+qBkWH|aB()A6Ip(bL*sY4>8rHx?N>NO|tK>yt3_P!_I)Am|!Y1`(vtZxnA5~1;4Ak$R|!vt%e$a7g|r^daffP(L3I;4ziv* zHb1xs4w6~IMGBg0h*u3(FHpwPm|aG8*LO>`eW(34y|G020U63)65gIr5|{UBLxO3> zvV)n_nIuftQa_wT<$}jJAMrdmLRG_NL4r)F(~e7t+P>MmQIqov0O#~SN|ee)>VJ=` zH8%^PVP#eI9J-TcJ;4oo{(9fud<(L;4FmDb?sKajaxsV{K z4usH=s#?UG%Xz#s>%;^2%T+FiLu)S@9LVK6>dq2VG>HbUxtYl8|KIy9^_jWu^Dpy0qs#U+^0DA1{*YC`d zwfVmsj`kRXza6xEZ1wcCs)F@{Lc?c_R}c$%uf)mxu*IRUzK=Zic6H7bAWr<);ba2y z(Is^CI{(s!z?$t>ad`Kf6c;FK&jXn;bwd4C0gu`BTF^jQk45kcwl7R%PBMY&0Q^P1 zQ13&@c6z~HCF$Z$KDcwJp&vQd%gL#s^TP%|GjTX>!+a9I>*UT_nZ-~OEYj*Uc22IU5VnuviTg4)eKzy$| zD7Ar2(z}e3GlpzVG`8av^V|j6ehvnzYAQW6JUh=|J@`@x%E#uWXB8pc2vF^B9R>-e zJ(M}Wwq5?+3~U4u#1N=HU4_{6no&2WNE82KXS;&m?jpaqimK4AGagJ9FZJ%4_c_DEb;FbeTL8=bj z@3Lvk9$DZ|`*sgwz8Ol26FczjzSe-~R8P_JO^o%4Jc7hxfkFbBe(%SWeCF;qkW6CI zQimRNjPmMfvjiFHMEWaBpLpqHhAL?Pd4Dt&*OfI@Rc3GQIU28fsqtOf-KTIkPR3oB zcpB}tH<~2gCPaY-WAV7kvMHbh=h6#`IDU(iH`WY25Rq7l*hdm>uJ+YC!znRabg4)6 zNV3E7Zn9y2XqKf9g`04Bx>AZ;KJf0s?``L=-W$scQ#P?%e)dJTp63RX+J@`somq~9 zQnMt+$e6%skzF@^v$z9+f;^3G?XFslCXd%SanZwR*CK)1$3AK<+bE&ZY0sVafm|Cr#a{V9ETkHdrFgu3MnfS)xs-1Kz6TkbiWi zugxbkysukUwNH1le$V(veD;^BD>=ymfl-j0DQUO$SKu6}@AjL6i!Ko6I#a!usOYjC zBNZ9PEv1l8qC4RXwL&LuMTw-vnU=XVg>Q*9DjcyPSl4-a9c+gv6bW#F=tyB==7p0O z(V)y|19SPvL1PvNOJtU!RCODW(C7aA^ZiI%Jvmwkk{SVNpWau9)*(v_xc9TYIdSRT z+@O@axvz%<(UlweOqnu$3QQ}_reqc;$b7zk8{6?WI`g;mpE9aT?90l#YsBKa0R{s> zip)pSVvZ3RMZA>J<(-=m`bE*Xo^jnf(`*ZuComiOql zQF1jw>_wh%3<`9=p5NUBfLN2kw*>687ljT3&pdXSOWkI>+Qk3u!t9@%;7R8C<8!{G zrc!&9&c63`Fiz&M@pUxi*BzA^O{jO7;_vP_=qe5no%ZNFZj{wE7}mMR-sL)-*P`&7 z_bAKhUXCfFM3)n&{v7+oxPkoRSFZ2OqJ*%j)cGW85kN^xAh${)wqY!S|nv;M$+vHghPyVJi9 zDVFN+Cg^T69LZ=cag8n=re%NLRYunw@66YIrBJ8C%+_6;Sp%2i$798gCfe%Xo_g5y z&YxX%Xp83G{BUhw!@h>-SVRkgmC7KP-~>mXju`EJR~ zme6(A+rE=j_YIkh2GFNBZep}Mj#uUR->&~n_`aKd9vgdvT_2XN@4MPtQfr0JP*-bR zEnJ??FXKB!l4v>Jr@k}vK!>?l$T)-I=+p8#4CwHCikz)KXQle^_qz z@W1BO|L6+$R%G_OneXsA^)TmPRMXL!pZPgq$>x?bk3+NBSqu&7eZMC|u`^7G594P~ zhA;vCcJ4(;i{lQe0SgI%4kVJ$D|emsl=Yn|BJEI}(ZZFo?xneBF?MDBL%xuO?%jrk z8e*Vo$woNpVlgD~{dm@->0&*

_qNh!v0@wQLxIev2aX94$34owW9W6JyDeK92R472Q6X`jlT;p>7nNA;qp3kQ{NKR&lCz(pI-RNa@sDj?@RVp@- zzfD-a()z{kNK&Jd`=t4?#Uh<7^xHJ@rpO=WIchZb+wBi!mYFY_Z=G7TsHS2aViS<` zVj$hh^Ab#+Jx1I{bjEAz7YCXLuaaeh4z6z7Z=7n&Zi;Z4@O|wAeFa{TFR3*!`t{=F zL$e!~&*(sf zCIVGdtr)=!?h}4(XDCE$c$pbJ@1E0Mgx=3%i73CAgGaW_dEdsQIbM$#d%CZm(P9Li zuSX{~{d#;@ae+GjPRmQD3zjI8>tt@L-hP-!G799pILH7(=-=Uc@9qcO*|p(x*D_1n zcjwh~zsrS|u#ttejp_8e2ni>_oZOIG2*uB;S@6FlgHs((CSSNBZh8M0K2Dn<$vg|t zaCX4hBx52b_A!QNU?7O)ZXSs9%@WQei0pZjv*F58p|fN}!7abxUrx_qstusfy1Dap zy+dvj^dC81^KP!q@vpZGTZgbe=rbkpykbioBZbKHoZGXa5(DL(xky*!<&MOs``D$0 zF-QXJF}9E>m_5NRBI!vBI_Yz=fhUb|n$K|&u(qz}&Io|0ly3?B~jJM#Pw zxn_Ew){AE?gjjX$-F{=yjQ{ixc|QaIWThKWg6V1pRrRCG>p(>Y8qwB|SRNb=o%~SZ-H<>?HD+WDG!X z;=rH#0!a;<`gB)q&(u&6tfC$Vg)ZvM>M{&|S1mu#XgOk-hrU?+YaAEp_IdmoYuUsi zyD<3{LkOKyZeevCxK8=?6ik(t7l^2P()w&DvPIpxR?XxH0h1%V?Jpw6)pa{#nkO6%B~GG$^A#Lp`;r5mA6p-jdl6V4@pxe zNMa4lUk;pf41qOBS|!4karG#z*JcJTmt84$@K-c~32}^e}LO8@g5N6|{ zx9}3CN$2VWA(rPpY`T!v24rPRxCNAYrQy!EiO{I_s>=0u*KMy6sev}$$Elq~ZLe?C zF||D!V>jV}w0QkmL-Pk#ej7iH#BGFyoosa8_UYN|lXTtBH|Gm0^%K`^p_Cui5Nb}3 zRkw7EZ$b%OR<=X&z0cTE@!flQ_vrg8$Fi93>ZK>V-lE;M@>v?3IgsFij`}r747FkB zc$)-)#Gujx#TCO+ZYVzwRk0q3{;^mdB(1TD?uOwp;{_n&hIUw)OVNL40>rU)Xf zW)xYvRYLE#F60&_35xyU?$wzlM3Rv*fjXF0PCX>J5On>6(lKue!5kFiD;=0L+gQC| z*?R}o!y12B`ldAp)m>jvk>B0j(w?O3#GXly8opnlm%0k|C3i8k3kM<|k%izjB#-g) zfw-Xn8YGVQtz7?k)WVgoq@L0k|GCsB@3HC7Z1>k`tHl~Mg7@c{n;Uq}Q}#>uSUvls zf|0=dsr_*_AP^O@kGAF+V(pL4N9bzz^J}#N)$2`r^rmmiqg&4#TN0#(`&7Wq7D|?} z5+vYCzKmpib30$tXfT!Wy8IxxsCxw}K6jKqFE+N-%nZKEZbvLv?z1Qa3=8-TI_tpuz?~QYuf15kJ|3uYZMEN!b7t8d*UM%( zuSaGGo3EQ*;`p>E)&LSn4TM01p-Gp?c6|QP_Tz<%`{8hBVx*=$YqV)m`~9KgL*_H2 z1!+!F=$37nEQfosqt^=xpNM&e`MhJmlb5|2pu6+L z({lG!QDdE)V;Xe>u@0B3@bpn zSA1B{t-m@Cy64H&p+?^8Xy5>Y%L2rp-}BmN$o7h%jLp0Hu<1kna#`IX01MrJ@P6%_ zfFtzKpD(T@MyK-g#&P`@U0=!vj?RuOi7FwE?K6S%vLq|C7& z3q+edav-N{W%RhNd+EFWu^-~~;lV?-QO&lBMX%|)l8)8BUSrP*o=P&K#_#@6ys{Dk z;3gIJ`VU_(jN7=IC{IYCPU7LB#2&g-fnEXcCb8+>aiRTpj=zZX2S#u#-P%&uXNfml ztSUc(Y95NV!vSALxiO8~E7a1YtZ(@+W7J&$Ho@>8I|6(xnqxK|VQ8!(wNg>S`%e1jf@hwdZ8}=M4{@*8wK*G!E9@WAM z@2=B6+h+A9O&4kDZ?Da9V>+Sh2BGX7C0VwU&_SOzkO}2??|z7VHZEme8e}`8JE~-= z2eltk5|OIWeW0#s?h(I+>0MSkoZ_C}GX zJG5mkGUD^ux>69UK|CKFIN7bAvu5dD>b}?Sl(-%b@kQd);9J)HIT?-{gKGIs5Qmx12sk zE%DIKUWD@Ycz(^cUi(YfuB#rR#T!T9>E=^;>MY2-! z@fuL&_y7M_4EZOmtr*IXlnbDuSMpy&Mt%&Pa9EveV`lWh%_R-g%Hhv+S*w1g?V^rBoDiI`#;{UhRN1TCDdnDxiI-;yHCbFnvUC` zpCWo2sn)<8J)J}5AI`gFJ^|~CQH|Qw6NEg-$fXE!r>7zyOj`9m+@3|O z*cRk-HZ3~OE@ayc@aqVvB@tlYK(6LsA#Y>2e=adsgWo!3X}0TpRsn&%tjj1d{2v$a z765^)8}}ld9C{RZ=YN1=9giqKGMCu zB2LI004?yi_i1p`ZBnoIU6lk>iPL%L^B~YZn6v6~hxF0;VDLzKeHMBD2qm8FI8zsj z@2OCe5V}4w@jlPjW?B#K&ALTwM)-YJSXel~VAyC7^kC^i4v7QjAKy_-Y{RCs1wm^C zegjARSyD^GzUn$t5cY8a^r+-zd_H1RL%KQ$)hN$xW{3 zYQeRg<}KOQAVhxEyGbcFE^Fgl+7X69)XW~qn2u&7(?(KP(YZm&-uDR#dK}U?-8k@f zj4_VZD+r>`j?0cO9pDJXAt(L0wyCW|L5yIS@@u=<=ttpE$LU7s{=1^Od0KanU#GrV z3S*4@$C}+mx!1BTjsz_Id6CDC`z{H}LmG44VX>=a?fzkH2%zmB{*L;>#Mm~INjxVD z_};BQNDEO0EoN&y#&T%;yi{}SF0?t!rfA=3+5afAs_DW}d25gtD6)q7aGh=;{Ob9e zS|2&_ufOo~HLcw*!d*auyUM6!PazQzkVA| zILyFXct7>Pp8G)_*Rh-v6$`{B`e~X{?;q}a2v4SJ)mJeD92Pq(xfu{j0v&<~1z;_u z**jGJ9ilYTP??q;x+vu2i10{rsl(c)b!R1yUg*UfxD~&I1p4i%{9nR{RW@pTsei4| zT7NrGlgqG0@=E`cYw7n)?BxYEfaqz)q$2+;wpgk?f2gP{FTK!t1`QhE&YF;+x zN-1q-!4nS1yZ-$9r{ej|*pI+`bMzNpQrHOgR)sKJN1*aBYQa2 zu9-~dt3NIReBjTE$1TtLJ=?q{viGbQq^4a4ic_;-Fd1OCIyqUrfZVywp^n_( zUQc&k-Io#1SnlJvK(D)*(GQ&l)Ahd>{NQ)eEUD>?0xz2Y^ERvDbPn)(h6M+31sE0L zY8c8(`cv|`?Bp*@Ut>=FrX2L2CNj9_@x0IWC=Guw{zzNE5IRBa+< z7-(!8l5i3G)MDIR{%6Q-tO|?C*bzh^dbG++q+1LsMNpR;kSU1pQ{g9byt!f}zSPu7 zJQgGRfUZ1o!0s;xZaJGFe%5jB-&_~0*&wT#5*d^B+u-^*EA7WKteNdFCBbSo=IN9f zc5Z)Np!!s*-CBPf-+D$4pu$6w#>0+Jv1jV&?zeOc)p;OYTdVzn%)M-E%)5#tl8}7i z!m4SvLrjtU$xEqNMn#<<}|6$E{P=L7SqTA`i{!P^bBM`5_ zW3+i~2yh5J0O}*KL=`BFJj}_ScaM-!Lr#erZoAZ~aa4tz-gSUK@6pZhdH7Ns_~Q*2 z`yJQ`IP*DuqSj&|L-Su#NogsrkSW|>k!S2?l-h5md~SUs3(3~=Z-(w;D1tN)OB1e` zcm7nrUPV*S$^~}4IvkCLi?F6g6~3*?H)h;3SY#+B3caydu8T-N-yX>rsdW7r6r%)D z#PXIM^<4C;sZsx_U@hi5#ZXNq((r2ZqXz*SOgAsPirwiZzN3b9Ws|h>0np7qXO99q zaFe=WS!Qqqr&qV3o`q}2=K~;|frbr3r}^3sKsn#N`41O{%ibV9#u+UdIi8z>HH`3P z>vdP4X^v%l>lv*1RX~;FKH|}EK#;(}{Seil6?)Nq`S)Z5Tq5u3Vd+Le^bH)?dQv=C z>D4>yWI{F9|EZh&*!3Xbc%T=ldEQF4=8*cTS=)0p0c=sZfO>#IC_;e=k|RS(+=N*+ zA#m-CYb(_YC$R7PGYn_16oMv$68HGeRtCX%#5G4$l|MKW?-&ivCvt(aMRU0G5rhKX zPi@kA9_KSiC*5F8XyL41iu~*|?(G^~?zf}F)uzG90?$4X8GA z_?D|99r5Yhm5$1o?SWynteT-Q`2xIvlQLSFUgi8g11$pd8&Qa)DT8@vKMryzE*`FH z{d2K}ST*zUEqnDsfzsXHPa@bEH%CULW!h9nV!Yl|x=$VZu9V3%uP?9}mOT)$KgJ>% zyBtQx7lD;}^{-?OI`Tae_RJs_z6o%fCe2O1g8^H3_pWz=b?3!gyY~AYyKc9WGr!I5 z!kAm=sWPW~ZtG2-eg3PZw?sWC zWq5|Lw(Y(o@Q(4tVBoc1j{7n1?ftn01|Czr&T-r%RZrY=GICOY8yEp~ zE-loc7&;%1C;7FOxO|Vu52!2oh+&n;@&dAL)r56I`+Pt3Z0TJ^&JcGW4C&XcmE&~<6{qZUh(MT3tHrQFSY>HVVdnVe2(yftdfLlIQ6;TBX zytS%;1RK`H?!z>NM=7WS!AlFIh@4EVxu~C{_9*-3DckOKq(2`?uBoIyn6ccL_p1eN zYk2(^WumYaXikDxZgswlA={PzVwCAFF8$GPWF@qSe#brl0K#F3N~sWL#x6n_-T_;LZ7dZWKxIjCaP z5vyZm(I;5Xy<_(1uCXYg%taby@Q8ll{tDEga~C$B)NPPczLJj|LSjf$XKpqA&^Ogy zH2E-my~Tl-47_?QO+pDqhgR(?bynOXCT5H^^YNUH!RMwElK5W&)YouZ_PyC@lPB}X zji!-osK`P$S)IML)4$8x^obP4<}`}Q8!T1W`qAwjdJ(QXTJ&cT1JOlOv0C!&bxvS- zRKfmPlm{Rvs@Dx3JUiPK;30KP&YxE{T%?B~D)x4~&%ah9mqHNyG2(dhaQ39K|uWM0p9xeQBP*w zEf;~Zk^-dpcluDi3F%{{J(==LJj%R4lY+VReA%{w5ibEzS5Y3AYwBqAMx`32ZJO!c zUX1on$^gM$%O=alc7HtrFzY&XM6trEpdjM<*?fyXmUxwAdXY`me|J6h_IB`&Ici^z zjB-*@;;GtBA2rid;U&!I^lx%wxo}I)ru%_%WYo87M|OAU|11C2+vzDX+ zhWqTR#7!@GBto(lDjz)Z!eR6Q0;$?AH@4~9*n=;f-kXylo*AkZijkv*r|eTigOqg@ z-Si;SOXi#nr z%6}c#btKEYH$$cqW#CHy0&q+ENHd%VVD*1k*X37ZM~el4D(~DoIr$#_!9zKcBQ&h# zfc*do3M{{NCQGeZ=hOU)mw6L9JJl=nfl%KHINxTW+J!^w411>IySbEOVTbmt?^3Xj z2x-HY4Mr^Ky*tHJBRaa6@X$tet;^P+VT4!c!fBmydLb~{bR*2QU-dQi>TxWGw}pdp zOKvA67m|9RfSta+a#>QD|KGpyK;q1~^XazALyeNJA~m_Ar5O#kI%+20NTcwV&I_(- zbJI=bWGM!dY9COAnzz6w{N;&ySg#JWy!>2zmY0fXKyKZnK^0+ZdwxfyT+~vgQl%*^ zV`@Z&ZCf@fQ@(y$x!kSO^MdV{tPvJsgm$w5>7><}D{IY1PWd%8g$d zp_ELO0NeV|l#v;ui$D0kV^f%Ir~5g5`0&!uMF*lfyDo0P8F!i#ju_BYFD8Lq3=|CA}olgRb{Fu6B^h>U>I?WnYnJs znr<|Gc%LCllbru{8b(Q7fIYQat6bNQ@_8e@^fM!n4 z!CJS2`pBe$%F2bb`yZ!Y6!SPE3d zCQbR-^(Mi9IRxmFM|VhLdv5v+8H#GTObLp}i;jGbLl1sp`05kJm{pta%EH2AW^!~U zLi$X_1C8eF=>^lUB8Z+oROs-~!aUW90G`FI#^p6@fZ8Y?wrsIr!-XvoWlooAW^I`} zgAEprHz%vwzh!tRIz>xm^l5V)bWBj}sY}ZR^bl+u$di@-{7)=ommWTHsg?QNJ{tPV zkggqotx_$UShgLMY=TZPLEHjL#gDTz9GQhVm+)!L>XQAeR2AW*F|d*%2wIae|Wi`WdMmd4|YkWMr*Rhp*FVDKTArwP4zu6bC`= zN=0bu*@wrOyHd`sungR3`szxb?C&tc83bwL^8WvE0lA926PaUTKLbgp&6z%*p4qC? znF2--`i(Vwr?xC2pS>CqIyCrD7fqY2*cM3Wg-0lpN>G2dJt71_t@Sl(gcJqZWkH^y zGBt6s%%0_i1g9l?C9CGQ-JXwq$yhTh(Sb%|mUhd5j(Gb-B?{cWjTcy!C2Vas-2c2H zAz=F8l>-lpJ^is zdhb;jqzkBe$0>2j$)IMw#%W_}#JoRPNe(D(>z~L9{4RX$)oHu2++g#>4h|hRu)1=$ zuiN}IV9b&+W~%u4_~iITh1^^}u60W*5YbfCG9`JizC`r1zc=0SMP~)Q`7eI~;1f-o zELm~Q+x+!{Y;`q3sZ}Y;#WN&OO{%J~wq@0$@X{V_q(GZwK|~N>r5g==w!ul`pR{>P z2XYtC!)%hJ;yZveXo5(DpH6()!S;D7S#s(7mOJf}+l+2jRO-1hF;jbMKqv%r+3JGg zzm|-M^gTarb}ve-V7Wsn%-Igy?33Csf5z|3S*;DbWc7N8u?kl0mu$lmmR8baiXQav zL`AugirKz(4^AqSHyd~f2>lP&euk<3Ji#j0$`&ZB7kt63D4I&D#NWZTe^%Z4V{(k$t$xY2T@RyTiei@{3wPucNC z%V$VByUPq!FZ-1$V!(*pEHacT*}j$7K88scgktQ-pql2^M70#rI@g2Tbgr64Ujirm4_V>f@W zBdg}LTWDcUY-j_G>m9^nW^}~C0*A`+zYI=KJ^`nlU|aE;HibNNut{7FYkLf_l*TNo z^;NM2sO*_gE5Cj>5*5$#<{nPUnLd9T#m}$^*%p*^`Z|}M1VbYYV1M2X{-O0ie@1`gzF2*nW#v*P}E8C^38*tTsds4vKa`?{U%P_0D& zmQUM_<#S@B6!%EB~y0G$rF%F_Q)ZiB^FXlQM~imJJ`}8gXRY7 zaU+{5Zo&++bxUWhT5M|qygin&qjMs}eTZbx!h;8Q&TI%DW&BLpNj)`^S-d&oKSO>g zvXwZWq(d|>^^EJg;*?X1AtE<>YzZs;ugywP;paDuf{!-aQyJGauKv=fN8=e-%CIYz zZgEG!Kj8A=yDJLSp;H8>9p*!ADAbx&$}kaI!|1aqPIBmh2SL0lN^;MyeIUO>9kiylhX8O_r4l1 z>^aVz`{s(*mS$#Vh${Znq-PIJE}-Veynn)tT$U6)51fK(qn*?ka3~=1@5_{e?-R7n z+hYI#V7cIb*DOgP1qxbk_`oK+j}0}Zb&UZu0HngUq47wnROsFr@I6f(+gBD^@-s|w zk)r-V8^e$$t;B1TT&(dum{1UIw=V94S83CkpCT(xDB3+_Q~8l?nVA(O;^Be6ACP5G zDNP5GHm%##og|1t4;r`qmL5GVQPth-OZh+_ntJ2eNs~NpykOPXt{`|m>Am&p^pN3J zFJ}h0XXSsV()%h)^=N^PVnmH{Ozntzu~d4hfvWL!-1Tr0(XplREgf*sxw8DPIAYtk zXzD7~Oq(b5`Pv8Jg^R#v^ybwPjk9q*xi@;Qwcc5Q0@ODa;tbX0WRU&m@ZdSet__%v zk5b>y%j^TdN{+Q2rETxXsYh(&LZ!|=^^Yd6ay!c{SPiv0R&Y%{GoP`3mE$Q(%Qlsc zq1xr2#PrBHNGS<8yIDi}TAE0u>SFDodO@ZTC1*X?KipP3g3RtnY|{a z&r3w<0AB1BdNDYM8oDxfc1nTCbmd=l+tLR!0In@t{123RHnO&G>P&<@L+=rDazO{> zH^9lPP;j_vne4>pOC}twV-%*mwU&2NTQps_bS7v&s9(B^i;YOsxG~x;G7j3Oydxu@-K)oG_~w3~UvkBX^6C1^C@lWG|-A zQG*WIX{fX9y|o}bI8kWB`pUo4rVB&Kl!Nd;2IV;QfBlB&Yn3I}P_$-L2KkEgJ|K!J zQY40}GUM7bCCT|uC8v@~!RHHN|GQcJ*yz2$+$4+w;7_DP9n4ldv@~N1j*S^2uc)um z;YUT?zX;{b6TYjUi?mwFU)J9z&Y*rvfMog#PyWpbQIdSc?shPB)}AT}oIDF=)Uqf^ zw8ZwU$)Ya+`tw$Bm=i^P3qOgUHv_HeD$5pyy{SsY`pJ0iA?)uqG1+1zWBbV$wz4{P zdWr_FWSR3<{>f}bhi7Zw(^g>;1M?MB^%VJ1A&8co`*L(P%dxpXA@ftlKs?m`T;;(RU>`1@S$4x+z5+x#v5zuwQ=AfjuY?XcX*^W8y21M(dwf`A?qoT^hbjVClLvzo5~Dc$uam2; zq(ChMp;Ym*YK^UD_L3E533>LR^?fo;12w=J6ZW$hlPRtyv6%Ngprh?6n)M$vCZ`oiU8C z(3-LCZ=W2YEp=nK=}$;S4PRJKA)`)@`e)t!0`Y$kURH2KlP(1^SAkAj0twoPccK4` zPE^sei-4ClaTpyrG?}P%kknG=D^q}w$8poOh(5)`Zx_7G-elEEwrKwR;Y_099|`lO zWO7u=whQhNBVl8d{1RO~5)0k$VZ4eO7Sl&IG|8^HZ*pAv@5$(UG_`5v3)pzaYW7(X z1xnmZKaid)9Crv3Z(k*|i2x0;fr;Y%X*e%P z$x74itTU(j@@GLIH1u7^-@(NO6eC5#e=S-J{eS0T0Gtc^Qzt(VG$kvV*6W=Gvn*p~ z+7;;h8~Fob4`+|OBR#{yS)ywCBAR3HP@)JJns8HD_}TQKniw0G0FKYn4}hHMlK)2i(B1kzHdUqlVvqNdH~I+KE(f(|?sKc7$^oqTTc_?RN-nBJ1} z>rzr_|3M0skAvIIHf0LA$UqqC=%RmatAo#E4AZeCrKsMKxwSTE zM0vi%y4P%RTTZMRw*^!o!kN|07P_&WBUjDU0wIChA|weq5ar8^FM#OZo4R}?R?GXA zTreCzVT}3MG=%Vq+|57f5oQR_NiUXp2>^bm%hj z8II;Y>lJLYn;|VYkO(4+$0DrG@YiU$yX4= z#F{f9L?E<-1a&X9_A1Ffn>eVid^$r246SX)iVoKzz9yd5M31ZBU zSvg}{<)wEdbkE)>nb)L>fR4o0?mK;*l!*0rJ7#by9cU~qHo2s8n4`V*oR6`od}_^G zz|K#G}_6ppBx|;aI{Q* zQ$nOk9!I!hn+Qkw2OosK(`c5PagG5=1nh!yoBTIX3rx_MrzwP4~f>AS@Fn;V$!IIgmXp3&M7+krpA+1!S-!|n9MNT znD@(mli`?vQ`8z#GWE2oT4{q2Adgc{3cNx``hZ0Dd?|As$5S-nGJ%AkVQa_K zR3YiO(zbzv8>IDnqN&S1L3=W{6G1@8g8v^@32(;JTrMxNkSgWCl`&0Sm-vP#ViX?- zCu0Cg5!44?J!`hD2+Z{AX_tliKQ18WU>?_{8S6HTT&Er9XT7lZNmqJuI<<{v0VnE) ziq=S*YqCR++~WC_c~tDc-#T@gVGGtm3DZwxfwu%qzT&av&Bma9+pcG^k0@iGiaWC4hMcVN1V?6#@t@^QAyFNbdX<5B#-u)kQ{%y=vyhrq#P*B@b9&SXQi%DG zG|PSABB4twq9Yk9+#>{g2R$mXn<^gRLEruE+_5oBIbD#57(v8440g)Vnn{vT_^6Zy z5`G4>AYdfAHdJv%+e4Df$xCAMO$4_^xLV)Qh;zivfmSHq7W^CF*LB^fc??YM^Ss#R?7o? z)*joJm|S1ma*HI6-UVoYi%)_S+R8qs5eoDB82`w~T=?kR??*b}Cr$qmW5}F!S3%YL z*u;Y`R>+JS^XSvPosZ3iizMM$JGlCBwD#}gY5)J!6OO)}2q}^}V}RpquQ8uy9Jo1+ zZ38#t))kS8Fq3-VA%rf77%Hrj(fi-Vksw$Zo2C2TA@p9&E@VWfY+V$3sAZ2><2x<6 zI+;l}L7bqJ>mRG+p?#VJu{ZQcO(cgF0l_;nG5ZPSdL)dVMM@4bWLcj@iMMKzLNmkb zTdi8*lI59~PT5OG$%|)Yk)lWRKe5pLT(+)rN9=z57p`&yehMB9uo<_o14VZ{WI#5i zeLd{^-_hc#KhXDfUIF@LodsD)4M3iMxdjt$$1yC@Ree1RXrN?PsrlPw82E;ZD=M8i zIowH{e_Oo6_C-O)GxooMF*+73gP=ET^=Dmkv4>#$T*bU)C#T@s&N)M}qNk6+<&S1#q~2i+KmUVMoaSs{p9XvMjVCGzA=I z(6vg%sE+Ly&z=02t+>Z05WYR5d&g2>LBenh1sTeTU!sz}^y))NA1#PMnrkb#bbcrB zD|?@5sK(fX{*FyTo?4CgcH^-5On(NWP4Ix163lvL&$V;ax_LkeA!wScspX_>+FjWg zHu{P=d8xkDLc4X^q)vBPP5JNjx9DS;1K83afDA|Wk2-4#Qdj}iL!zqF=U^?rpCRxu zkkGVa+s^PTB%;GRp~J92JDlb`VS|Qwgx~@Gs8v5_wPV2eUpU{Qu`;7@Ltt6Iz14oJeErr7rOqXd^Pn`54y7l8mLkH&T4;iz&-Dxw0x^Uz7PocA+ zGQ#U;Qx}%x4-1Cd(-lu@$h7<#etoB_?gUqjqMT+5hm>Uf{uhpve$6YIx`n1esM(Bk zdq|M(d!fwAu7!or-70Mol&>sBB7nht`hRNUV|DLbVi4rX!2n;;kNCfvBm?{ShFi_v zQu(d5o#;<5?zM-HH?J)dECwI2%QtGR7oeL56pbNh<0cap9LF$00eRylhiX{aF`d~XGwg&&1?aJ(_;LPnAFbb! z`Gr0p(L!u{yTVopEU2XF#!!~KEPXB;x}sC0_$5^iXBhb6lA8S6TQ37XQF2~AX6CFF z`{nlcJjT>~5>cTn_KDODj zCxlPkfzec$Qagsscfdl(!2eep?Llo`jUIGK6po{$Y*=z)Ym^_t*zj^exJ?~#P1xG5 z^zAyZAwyBsX;e~@quuzj)6yeWi{^dDNlftAe^b+(MHw9*z$Tllu$gI)qCt-wP86Yq zsRJJ&!$Qu9C|@$kywM3srzVp@g{KrEgMOp21r0fHrOOvqNQ#o9HsNwCaz_P2)N{yZ z%8dl1Iidi)30{!#Kj)_q?0ls;o0c1aT7gAt8f&xWP9)qLYGQVE(cd}Vp zOnqMx-Ij#eEyP^4Bx*_$v?ju>Vmj?Q3E+RyViFD4HS|h_?U?+Czi#)hhh=5D2Ok6w1{bpckBmtcrNBu_I96P^!S^nBx>+ClyS54x`{;7XQr-atvQ&xj zQqUkGiFOOV(Ya~|R_3p}g74_(t1_xTCRvQRr*H2$zn9kQGaVT!D#6j&`%%7`UX9{Q<7?2`l?dRNx72z?~ zP2;gwZ$r`4UE?YGzsVlo$qZZRIwp!E z!ct7n6Y_u!iaMJb;$#R;LP^DtrhtSjHU3 zF_44Pjr(|eCzH9S*@sdqc~ebzSeApTeL!JN{)=;E2&6-WjpY~@IWmJ)^t$eGh$_$S zb8Meo-_57cr;k~$h{EVat^Y&A?EC|pSF3EybV-H}V4yIk#+^FHlq`kWss<$sqMH1=xKcOYQUztn>`VNLW6@qzLt_HJ(8~DbIpeN zoXUNz#>2$8Lo<-uh#XV%XGC6uUz|PP9~V{2R@SmeO5&{qxWn$yDNL`H8)FyM@We4f zS1`ho#(JP`-cMo*aftGKPtKoE>pdC6sipryT(?R-R_5)&w+%V`91~)5!ufTH*ep?q ztn=>;lVa{?n)|m+(QnYy=}?Kbib$UWal{%NisAXj30no;nR-2uc)Clb`3q?C)yw<~ zlZUluVus*4N{o0zlU z0Fd-V<`V)tg)6!Aqo_f=_~= z!&pt-4{CbqdfLKl*?%M#O*B35FTV5NfTMxFo}kRhYk9wrhzh+=%~p0c zu)<4djw+G1a2~#fO0Bt`>5@g2>Ql4bG-$x$Jkyw?a{BTTIi*&Eo*9xTS{y|}RFB!h zPYm+$aRRqIys(*_DqSZ7l$0FMcZ3-l{wCp4T(bL97%5O7z2zgSL1j@9?eFNy$C)3I zDZv9@#<(nYf37Bjh?P(-KfcFA!{=G;V%~ZRcqoQauOFsrsFf{t@8z^M|IJ11y8U*m zzh^|;J8ZRRDnS~w8`6R<&9bn6;K!Bg(cRl4K6TY7EGxeM!wnm&wtrX6I%-$*WCTb* z;8=jPJSm++wryNPmM{)H(aF$}sCt0e?JQp*%*f0DHa+~p|C-7RHX#Dqv(|xW1p?|> z=0C&Fj&p>0smqr?Yh4jwf|y!$PM48H%Ep;ZN6tee)O7^5OYRV%%HSi1_bubGQ)$A| zj~vR?*eD}_D%cIZFC9N$sB!B}BV9iC#nSX!_=;C(5E?Qn%V_zKofk_GW3J0FZ;DZd z`-)gc59LS1(avfeoGI-`eoF!Gp~aVJr_dhHD7=&G z_SOj%Te_&Dx;^!SmS8`KDs?w^_l;p#a>lqep`xlCq)>4-mBR(h=2ijX@svde3YDL$ z{trupL`CRCr3W@kr2atx>)*InO}Nq-aV)^MO-*70AK4lJDnh)JI|Vlca3efm{#W;R zB6so@`esiZaO==z5#^7)Bs!bxT>}R_`SgUWMuu8CS|U*wQww5B&w-#{g1(j}mD+D6 zt^32^7`Ed$<^N*=iJyZAqDJ?#y@Z28FIrRz7Z%nZADt)zt9(OeRr)ZbWrrh%i%8oq zMmj#Z{Ogj+PVEqbZfnYR&Y4)U8~czU-Pg{3GNQqpXa1lwKQ~d%^JV0+mR0?JQYxC> zWOLR+x4lK}bWT`i<(A3}$K-P*+Owl7WZmXkZ@V~;scXr~G(bH5Xm(x#FCHIM;u?39 z?WGUqFlI}wT#{H|Bn`3!6cRgJ%5uP%UDhmM=i~MXZzPqdu_*b!KKKRkzdA`bgsYGV zHKV^xOYeqSNJ_3w3F+xz`!|>6!lUK~s9h}l=n?@4qR5d&0+gTO1}qj_nT=E|@W#;^ zND&IBFxk|XeX`KQmFpPyT;n}@n%z2~WdBI5a<}`44{6{do3hGO3WN-dJ5+mOji5gt znOF6FW&)b9!u&0=bSQN_yN*++$qXKYWQy>Um&~l~tj-vU*at(7!rSEd8cv)aJxbHE zQ5oYY`f;VG5vN#PcFxN~VsgCyD(0iovsi{_FdinE>xmVfp{v%b#fYV_;%$AY*jdI+ zj+J)dyJSzFM#zCP?z&(2MvhrHj)e^EcuEeu-0%Nm7vUx<$QqT@RzUZTV-+6AlrUm? z4;Y!=!9LvG^3X7$_r*I5nXsKY^A!v6HDaM-DR~M9O--tPBB-XqIS5UBpu8jNt4bKC zr@N!Qx?{5t@YjIq9Gofl0unUd&bdl4>s`msIGPagnn9)wd;-&qFqF|`IeY$^9c$+^ z^W(;LL( z;2@BHMvPXRC^XE3l#@~KFCm~MvmOH^K_?AW6IQL9oUXjZi6Pe0Q`uuOw?$R1$%%ml zmJwYItHiMZW)DFxZwBFWr}FW~Dh%^kyl5M*T+BOZ+s3T}s-A@a-=T@2k=b>2wGg1Po$DJa|s1`e;M4qa!6inj? z01`#5|0`EJk?Y>3Jn2$MubDBrKQ~y|zRdrHX>PfliI}HfUQ9L#JW``GZ2|Q?PCw!S z&NaSJTQEA>DU}4A0r}H>-D_6gBOnJIsgeOVPW(U!{i~`00NeUWVmDb6So5JAEs%{M z{OQ+n-p%Um0l^s|Sv)+}GT{UA;9)g!R;YrgL~KogZDaY*66xfIt1YF>_gC57?FOsv z?*}Qzb{%@DYoO#4W>nSoY89LuSMfE30@J_t8n+vn#SRNJ4FL7A6bzU=f8j8;aPjo} zMjmKh3%260vba{VUZlhRwOJ)YsW>!jLUUXgOkRjLk4V}QyHzR#=3VV|6G$D)eynII ze%n+oZvtg=c8;ra?M>-#S1zsYR@3{@LWps3yS&6lqsS(X0|Y1JP@xx+%k!SB3e|cTrU-6eSdM?^;ISbOLDp&rH?S6J~t$zY>_Db zy1@rs{UX3_7DF_T)>5!3S&)vkw_#YtKzhZb#ki9twFuuwQFWG8BDTv)UnX>J0fn=t zAH{?(OzTRo6SrK!Qkv6<6tmO(08HsP`@Vw{E)o+_q7Tbwo6L#P!H$=r0m9fjH(F$F zzZ{dU=iETmrz{W8d7?FWG@@Rq-^)1zUQRAtZqJ$D6&zQNvNg-4(ZDvb@Vg8@mT~@9 zNK^b*NUtT+L_UC`lR^r4Qic)}xM?VVRC{*!RCRR~m*ZpMo|5E&i4-JjKEU=LJGYbV zl}&4)Q02y$O2jf-SJ*pkOdByUndm9oFCqo<6GVrI-bECW-Y0|`Nsgm3npmdB_iy0_ zNkDgQ^>`eV*o=#b;PmZQ41B!&Myyu2wTCWtyD^+O(stC}H#Y_0m**R;@m%2|KktrK zjZn^E-JD4+m+|m~_O88TSjfe`(~wp4rOVjMm@N4)(VGD;{#Q%D+Qh|XzE)~>?kRGd zA=_PA35Y}`oyY|6NgoknBAvY}MjSbHBD8@uo%WynvqUw>eNG;#c;7ZvphwQQ-B-O< zgNiw#8=pdO#(^g1g&zm@ha$<5i@5le}ny(X*R|L+r_4+G?B@Mn`F+G?A(?G(fgJ7JQWGHf;?RY zKYFAJHU!>X2FDuPB7?5=4J>IN6J{x6Erp?hCc=oJ_OZTZ&20kZnGhNEb8^@ylvOho zF-b_avhX7-a^?oOlU^dW{OQ&E*8SBSf&@MOHL;RB+f%p_xD;v_OoML#VQ^6?8(2`4 zuhIA*BSlMKZQ43ub@{BRN`t-_lRky%qxfD3&hfFn(~&EpYZH+3{3tH9uqY8INJBL4 z%|ObP38})d-P7VA+;C-gYbMy_r&M@Y9EG29L?`*0LHM>^@^}vVVxAFjjNwsaw;cEs zOyj3Cp}dIPg`c6!dKa}|)oAKWr`|>>{u@1ts&(NwkOsJUd0N^&umSh|f7G6_Jg6w8 zXpkN$cY4Cf?nlXr%XpcRJ_uKyY@$)q&aJ0tCDNZxoqTD1;lbNVHF7@4GlXH)q{ZRG z1!|yS%c%;J5Bs~r0UcngX@EEc@#N)WvI;?lQUr?K&X~4?J7_Mi4`NX*nba_U!8=tYL4d(D4kmZA%w%aH<*3*~G<>rnEhZEn4m|MZ| zX;|_~T-3Am8)Wan+!RZmMJ5$+TN{c=F)xXwmow#@=Ni>YFYsa2;1Cz3#=5VM+kcjW zp=Zn4P+3TeIx;;d(!&5zzw(9sbEu*JlG&q&kHudgWgzLlkq$DM(2EAIrH?|ZBn#DE z3IXzh;F!V9W>dO{voN9mAG`<=0-KKG5~|20E4S5dV&+qNB}KCMMU#=Y76}zG^A$Qk zh&!tWq8=GC(^u0S3CZ+Gm43Ry@K&i-uQcLwkQNT6PLyW*GlpCRL%SjW(P6SuLeqAq zqq@-=aTIzAq)*$;oNC7Z=pE}6ser)@;BQ457Q~-wx9C^)NNPJ zLZ(E-Zk!CZLdokdr&VkxXLzdj2u(;DXVbzDG#K9b(itvxChisBisbY|ycblwYRwcU zQykjHZCu$Ifbu54D~GWiO_kgimZ5Pt>-Qb%?EF8tv=jP2r)ktCBF_AVpcj}vI9F|& zxewp$*U$5A4wapoBLhzc^pk@!I3irrXxX44PDW3FoSgE82&atG*H~8y46d~G6Eck8 zCJ@+4mr6w?kCHMZY-X&EZ+!zR>W2|&^)o;y z@19CLy^Jj}v|O#?ZUWIlv8=ZGyvnzKG{_h)`mj>pJ!Lzuwyf&I8nU3DsE=rkh(<`5 zD527oDqf0wc+qy^D(@Mt|1p}I`0ojq9Q#z;+05Mt6HonD@`aKPXW>WLL$g4wt3kjv zQrkWcuSC^agzRN&CH6!jcrgs8&%sQjF$@6Pfkluy{r67A^@}uTsyyia$o%3J&~IUM zmZ`$rgN-1$KoHXgINiu9pJxQVzwH~IQ^Erz(qam$sp)Qw@`eDz^8)!;9L#pP)dUfs}>6bPMPueBtzF8<=I$)#D6 z1i)OJ2oT@ zq`J(1pEXQKoZeDr`>rLg1lDM^*sSe^lAv9a*Fib-f z*Y9vf_gm(Rx7F!Yr)?^Fl&xn)x8?J{ zPN_buW}AYpqdYW9`mI&~)#vWGCqhEN1iEnE0?>Uj`2+)qw$nd+pN)paLWkX)q=HG6 z8Z7+#IH#Ztk+c25{gP2}tR_Pl4zlf}LayV<+sCJS)XekE zCkqFOqN(3RE#)34M&p&nZ0bb>J|haGbrockLat+RyQKU;EHbj3w^UGdRx_`o@-TBp z{g5*H%(mqm0IP~SWvJiU=!YK#TdmR9ZKi{Jwt{HHxw`Db$KKtjP@ST?6Y>%wnC3;A zv*%S07DSCR^bGpkfB%;Cg9pQM{!H%5Y2`SYa~mg@CW6#+ueS2;)}4hIE$(B~xC!D9 zk^kW(T>(}-U$?ei??Tq`DRR`5w^PP+W9vQ~3Pt|mO zJjK{VR5#+{L7zX~7lW4*3NBdo?o)lw|TLUpW5~5!^{`JRl3EHPZ}+?;`M(AmG*9aJ{Yl$zY6qk zCi^0h!RNI~KFCOU2I-Mu{P!i<(Ek--CQ5Oa3hnh=`ZhLI_Q^Qn70fgx7Q5~OOw2ch zOLyZ*A;{zSbxw|H+tko9Tqd-APx zI-FTqt1*oKj|He*V$n&&T3K2s;j~Ma!U%pev+!VIO^jJzPMT>^z-w}aB zuuwcm96)+dpvAX|scEro9SVV4-_B0;>temX+g1&$yCrdoI7gcyM%BYYPGho@X;DOz zVh4`Zdc=zad!a>dNC|q%y?M)b z@E=QobPnR*=l_6HKutkQa2@`vGRa-!R#`ykbBfY@D&eb-q;i8`mPsrFDbF6E4v+W^3$9|6S?YDB z(JE-2gxhc+#ug^zaRJ%Zjb}FrQaJOH00jhtW$((s`g5dWzL0FME<(MzsVS+hldUUQ zW_Vx^?`M8~`pm3`d$O;qZ^+;g6SL}KtrN>0I7$q(t602HR|PA$-$VhV1~yHN$up7@ zrZ%?{*~0*Y8GIg^jPIBLc+w9MMxR>jp9+8MAXRq(*!ZY_t%iq&Jk9akwBoH; z-=bS+@L#NtUMI%_yla~p?CB9MMJlp(8zveKf3FCZSz9!$+q|cQocnnpSa{i|Nmmku zL+f{GKp@~oi@EFRNt&xY{`|Dj5;Q2_LnOuXJ1&`j49P7M8I+$a6wv05e}A+3Ij>3Xt)f?-@<6C0W)-bm*<4q}M;MF~1x*q&3Pq`p z3zuXC%84r%iqw=|+sKQ-pm9B7aU=F4CP75#ht^vVg16(=Pb)06(H&<4q{;H&2{M+a@U z5LohbNnTGAb43>&gmdI(Xeqp%3_?$$+tcu`XpsOo6OL>$$X2b$9VendD;ZXeb@Wtq zJdGlG>Tht1KlJR0tHLpl-BcSZr%uapwz0zyprk>IaiMP1B}-Vd0S1A)Z)XoL@`PLi zmR#sm)e=Y~sjyG3LOKr4eA%?$J!!U}UtlYC1WB2FidKYWHT-&|{*`Mh|FIunah3Jf z7f1s>r>Ea*>)CO1B4EL2sTjzuU2w6KbhHK<$|mZc3GA`AndcGr??PhFhb9NRV&|7Z zZ}W(j{&if*o#lD`qs8ztqhsGjKPt7b>@NBtCTjK^D7^vu07m)gFBOH*xeh!$8p9&0 zA`CdH)*Mlbg1d93=n0M$Z)3_%^aFU6LzahnbEi$v%buVlv3MC(5Z|Ze7F}t)3=FVO zuOoNHCSP{-D;rU1<9Y>D00?lOQR!oeiaR5n9f=~@P}0mj@djOT@!Vz*X7X9{hGjCE zMNSi-{`jGWn8WYjiB?35zYEdFFbT5Nu)sWx9ghM`&HKU+?_WGOoQ*gqFcB6<7T@Rk9 z-CyE^`4@6COQsJ^^}ns5z1xzi)@x$L2h&aCXbM_<9xqF<3CZkz$$MI*Py=>|&nsEW zm;LeFTw-EBaZg$?1|iS7rG=#x(74MmXau?#0ZKy%KhpH1)V%^r&;6os9d6t4Gehw= zARWqpG|emFCkiYOk8;#c^`kV>vc9W<4I^f#pgthcyGjNo6amByTeAeqk)~G~DMmIt zTb=?}vC{)_k}zoa+~gk7>eqjltt3p({%LAQlixmuDm|g=y-7j7SZsj$AJa_*{U2Xu zObO=OCZH!lw>LdH@~y3HZG1H8KzWrjQ}q!4QQB@Q#lV+vNuuDSAr6~9zp!b;fxl&H z=PsG&wRh>yKJNJK8Eh0#1GZ}3bHGE*7FwkJT)rvy^|#(x9==?A_ZGC>jSRClMyf?3 zs4#kXpC58W1{+~0P@b_GCIc#gdNp=tcI3|~9va_am-hFVruB(FTLoE@^W)r}KNoAZ zIveIUW_ZaO*5RYC+wz*l3rbjn6-{w;@evg=JOreFlS0iZhYeUbIJwL+F@(_0A3duT zkZe)31E>|9ziBKhX_~X8Yw-jYpJwQOs5Ude6*AIZHem~#+&aNeQ+j*C2j*cXdbOw- zgAB9x=8NFi9jj~(33-ZN1BkdgXZkH%r!7kFEUv+T{)QI$ABO?i@Wmb`C<|SMHggNL z#EgxVLk`nDGJA*e!n+cCs(im5uZ#!1HNtRpi8X#9p^I3BMohH{$Gg`;?i2GkXLTL;}YxH=u>i^ufN?w};3KR81y%Rm^8f0*ayp zcL|W|{>b)y?!;<}hanRqS}kq>PF@Jzf49-$IH>>V=%{B~r(a2MY4OmhbN8w@w=(An z)S%(uA>Lj(40R3j$F^>?GXn={V$cgN9+|c++l6@CD(|6Tl14G(ZOE%U){hAZwo5p7 z^onDHh^e=A*uoL1s-(RK;V_rY)a|dbbyPcA=?st4LK1oZaZwU&7duVIZkTZmb@(*d zPFa;Ek)nf@%+Akn9KS-ep86$?Y6j*iA=8myQIq|}@#yI2JS$<76!oOgKwSLj`0m~HX%Pc$d2sDBvX$jdW(C6x-0be?|;}#6a*AJmS{>Oe?8FHk9d0WsK z%2L+=H;n|4ve96jU2bYlD8YZCrW9xVcz_k3DGr&xJQDvdCm9OR9J~JOp&arwF~!_q z?bZkh3HI9ayn!j~g6J9_L@4?<{mwyDOCaHpQdJ=p6IG$VrZHbgi7Q~xkZk;mK`ST1 zk{xKi4|($LmS)l^4(%7{_^NY;9~6=CJEbIj=1g;fXOAlsTaOtlEJafxQ1s`s2f5*z zNGJ|8i;HM~-0b;uCCcGw5rQ4|aOp3t5-Oub1Omp=;zfbnhmb?TJxYOC18%&K_!C}2 z|E1)lnOV~6*X7^%OZI+B)*J?Z27XI;A~ZFw0%3)Rzd-u3$?cC-|D3IUiB4jz1I{<3 zy8qGA@C8k%!&;tv11;L%qKA!0*#X)DNi16M)T*VwKODVcwep4ym#GN3QadRL|MP7~ zYdP3*Pb&aAkUUPR znvt~KAq{wah*aemr@~4H4o3Hm7Rxkmq{hYfjiU!2H>mXBJnR=#?dUHXlx|K~R6VN& z_BrAwu6<+ATEF)30w@2_W6^V`TDO=}KAX)>(iTj;!#Oax#Ce*Tt6)==?AJ!nrDF5} zH{zd>!#V)t{)@ma#GZnKeAioR-}MiGb?DIT7s{K(+(X3(3?P;A97fbA8CH354{CvU zgGFSd9c#1-$m7(WnwJ_fV4*wEd^1FNH^QK&5W$)*kow*fZgIAI3d|6alDd|n zC}M0@|I?Yj}$i(CP??F^QLalx-*a0JmTW@TDf@XpAW|N7}feH8nVsi zG`%|Na4|l5zB8vsjTF>R%o~519y+(3afJ;zX`n-qXDvvRqcXOm=QND^M4?ZyX~;`f z+zljk2YgpBG0%*mFD39U`U(y*-CJzH{)*0Skj`ip_Yxo^#QL=N;GkW8k+iG5dzt2} z)*c;5m0;#~o-gsZp_tbXqtLkRTLnlBY?`G`9}1yI{iJroc=luL5nr`KKpOe z{io;{jC8QxVD{788b7ETuVhQpV!9Dsm%1Dv~rl z6&$o)^KNzR5WfsQpQO}2xI^x!^!+~;;N7rZL>pILl-b_a#1{$L^6buZ+x7VVEGR4W z@mJ6@1Qlc2pq0XsQZHhR)*MOTGhuYg46`jqyJ4k-v5IvRsJeKn=96kUw)khow*lv#>6$AO_ofu$-C2rJD$8edTOX_p!$`8*{hEHtu9}zv8d`kdkLI?`6 zt5A(#$-dWLAMcluNTEvYOJ?8mqD{TnV7vhfUd*`YErNJ`lQ%)=!NOCC@3hpvYYgKb zb!P0Q=m2{DTP;->EH<=W8KBoXbJ?0I5KvQMpbDK#$5F-B=-+GNXDE-H<(SGWtSQGl z@Xmm|lsM0)fcHJpBkD4$*1$ylv6nAEtLBkDdmbA+cxRv-iC{B_@e~OAowL=Pt*y#< zswh)h2Ks2?<87M9?JSP?0Ddo0a60$mk$ExqsSJbjJwN?{08n>9so?aa4f`b@s+Ui0T1ww}`DlajoSCz9bK(w>CKqr+-+|e~1H5Yh zfH3fYPY?=((fYE#Z02jIsR0EH%G_cnf3zCg(&8LX#efm(`Kj}-Q~nWcPD2@)ncNv7 zujJT5BTzIefvmrIjTA$7KYHvv5j}qZ0m^7Yg@Y9F1p(rZ2iR`#QDBg?j96goVZ;>5 zS3fSFV+7aHwi!z5Rh&*vog(-1>aX(QT(&7b@gR_t#T;xC(doMpbRVG@PPl}iz~ddN3A(E5tGqudl44FA}pQ&{s0k-&~iQkH-T zGJv9AHERXSG5bF6K4AeEViSlFf4|RhxA5Jd&+UdNk_j6XuH9nlN`>-7ENXqpT;hvpD4LJ&QeMN<{24+!tAC|>m`xA3{K3ZO8gEKE_@z83{}Vy#>;42E%e== zr@t=SXeVh##dLMr|5o+QPf$D;o*g8)_)Zs(r@zd^aJnzJVNx*o)6Rp;B76dw<%$Kf zwm~ovWXy9IMu=C!fTO@$RY)bz3qn0crgpVtBr>GOUglSn&-u{i<|Ly2yes{|8 zuAjO34gvypnn}qyMwlpS>3^d&e*Dv3DCaE>pQHFW5?p_8=_Lo?|C^f*7A6 z3$}jE^0b**gFk?KPH;&|NeGqNzjyQv?jMy;0@|k~P?)$+z7%|VSn!PnokA4fLuKE@ zXm8Cs;>{zWyo*u8+Qqx0Rmb_R)ve`4awOzQ9J*ILo zqshoNauXN=7##hqaIyBKq9h}@O`E#xV2uy+{qz0_I~m*$=c@IXgk@WCZk{SiReH@@UG5+v z;I%=;nbERynt9%=e9G8prKI{BM?JOMzcfVEQ^^S{S$QXV7 z4q#dsfq=#~E?%5284@30Yct@;-eQUkR$EbE?D3!uv0H?E?vP;#8!RHV-sR*xJ^6a9 z|0>YtD!ZUAC~jL(W`C2;=@>-CsCX?mUqG6~TQvAA8(SR)B$`cg3U<#Y$=zT{TQ8|a zek}%{0<1zD4eI*gwe(iA? zDW_5N*43%XGtPH1C1#MuIJiZD$VJoRAilTX==hn*OoAGk(LgzvKQeWjz}YB68C}yN zW!Wl?xo^z)mB|NYCQmIw@g~3f`HR8vls3npXHZY>8P^}BM%V(?(%GCZ<|M*WFI_pC zpZu5M{EC<%@q**#O@bRyXLM8$IS5d){>+sWK3Eu7sGR=HTfOcc;V4fa6XCmuTi{Or z4TYb@Kr}YDQIbHbCqM{^J{Lo8pSvq z6EE1cf4oBOSu-SVs3Cbz=baKpWL!%uhs@f#aHwMX>MlYm_}RmI;;gL*3owub=h_bfrQrt}17pQN| zFY7OX!+>IWt<{(-RWXMT%GsAAAwwWT`c5xFyNw603`X1O#j^C%SFih#Umqn#-LXqy zt8?TiG^(`fO~xC}lw;6#UiIyoX#=V@=2J%?yM^tWK~MqyY+9|k{%=2-z7N)LadFT^ zSWV0GkXCQXc2eq6(3I z5q04zgh%5=5oNC^M03z}z)~#0$RC>e7QVlEyJ)XK{?~iF41Sj_l$I{V(uJv+YPy^O zu)pCjq*%a+d~&n0DEr(WDhzAgoKl5c^C+c`8j%kcVcW#I>C(#uEpZs1k38n_y1*t5 zA0r3AKT&pS_EH#M!a_FNJ@bSVcdGQ}8~D_uX<83mZg3Tgd-&V%FqEm2BE#H79PQP5 z292LpY@ck(Oj`u?i?KIID3%VdeSa2sxETb}rjQcXAjzn7)EjEEwS+&$PYG-nVU4kW zWtWrvF00{RuJ#ErX`>t~ccN@(%I_0IuUi#;uR=)45JyLnIK;9Y&7x^<_*9s_t*9>4 zlgGctgU0~}P@+jNIiw=Ukm4b)6Fu!yhbd5COh-?Hs+X_o67!K!3ZmneV!KwE6BQ`Q z$Hm+)9(-c6N2TYJunvkXYC+YJ*% z@*w73M7aPX6(z=t;q7xacX0x%`@DNsxALG>BPGUoNXIlfT4?6L$FCP@JFU9t7`u5_ ztZvsnPhZ7Mv+w@+<%AC#GfvAhPEVI7-CQKb#(v_ryVAQqp4y^reHTPBh?d|wmu)~# zRVW3YKgLT@xJeWXFq+LJG$jRK%{5`f*Gbyt2E<~-=m}Gk%$zAN6^bcWZ77GH;R1X( zA>n^?gLnm!DuXsrTV2YwiiYBHkFXkytPQZ1l(-7Tgp&g6j3&P7xLAd%p;Z&P^2&Z* zn8Ybc=5Au)sGJKQy)~ClDm-@svJ`EkWNwA6&8c6gR-MWob0>&-tT|f1F^#q}eBWaI z`jL(pM2c5!DOKi-3e*D6uU;1v0C;CSFV#o#VXaDo5c>@+q4Qov&a4riK1u&vxG0T%$y#qbS@ zebRNn~P zUKmWqWgO7@_u(#{Yh8oY!yzqL_lixU7&7|Ipv)r^Hb0o;ns)||J zae;C>kveb}eHn^G7lihM^owCjTE05m#q(yKz|wbhQ-K*T5Gds5q9;(?iT+Q7T1}BS z?#{4WiBPado9BCp+WzWXz6`ydUdHQV&1~`cu#P}AypHq^!*tb6=G_Zo=ek`Hy1O02 zZXa{}As^|AJEI~|nlHI%!7{niifh_Wytq)=gU3OnRsou9!CyW~tg;!_kCd#X`vVPJ zTv#3mkQ9p#!YOOf4~#-@+sBg7TVRBOAyEDF3SqklN)t+>$kLC|VoQfj8h-9N&!2J< zRL`4m;YH>3^&9H7x=v4!_&KblUH9k|2&~|yytHVcL7RBDi8LYI0y) zb}i)%Q;5G-PKSPNc(ZGM@p=E2Y~8H0S6Wh^ z@3=BkHtOhEy*yCCG3SuDf(8kkEv>-+^o{F4qd*u1n>k1vi_IzABtX$tz6SF|FzNY4 zkF6+dG>A?CE?p`L5&DeNs#(U={Uozb5PWg(dT$;n?s1lKlCDKIJ!%B_YcZ{&gv{T% zcS>nktBewPz2vAf_x?5gS)a-g3|*P^kP?!8V$lz`vbcKa%o{BMHOZF!6~1sBK9I}p z!df`*GnAlASw)_L-IFZUr($Gmw9r6i!Pj2{%wMI8p<5@X%li$E=_I2`$XN0I9}56= znpMw&t5mEfs;WVdGoeZd70O}ZFcOXIG@A+@Y@mOn87f6C+A@vGQ*bE~fl@e&Z!je2}axax})i0^3FhThp z>wFF8ALgy=)oA4n5bIh2>VHx{@B-TZCsREHZz%UW=p%8(3|rCCGuzwQ5r|k13>`qk zfGwNMsvVnzN;yGm560>pr9Cu%&APwGPJCjjY-`W)ur-WWj%CyF*%hsD8G%(~$i578LPf44>XQpU2C@~F(6s||k%)*Z@2|2});OKL|j~+0BRJYid;^SbHjr~n#-4UuxKf+fPuBWwoQS}k?IeVzKZQt3uF<0w!? zf2v*&Yqz&(4>j_WJ%(hpt5|94sdGzJMX=wLRq1@yPKznjcvIb~4OcU0OQ|voP(h7E^l*8 z$(mLm+Zl!F@Rk+>@xDBgW?Zb_ZV!m~=B3ao+U)x6WVj%liUdJiMZNIk%y~J>s!aQ7 zg@;azlKj@2o?U?`Fb6 z95&ukhvet27_ewqMHpPVT&?$+q+0ceyNX{CV#9zVbbbaXmbA+DU;#uqKU7l|%N70Xb?#GP}kU3Valq`|W7kQ$y8{cyeCh12#yEg{= z#qquu^=egN=2_CjCuP;p<|`$Zda#SL*K7H$Jp3!R zn*JyIzXJ9cvem@(wV@hInoIczm8n!6z+?+fq_E809#kT)o*#ZVlNK zkM=lx&fbT7XJ=42g4!*e7oJZ8ORRDlu$>7qsHv^K?&+F0F;TaXRb{6|<*^_ixk|Q@kb^o{VndC#lw`U(}Jzi%Qml^g(UQ#INe)tFqF_k1Kgd_&-1nJ2nllj9P z=?2DJB838%QvS7um;!IXb&MnaADH4VVvz_5cw#J=9}v*-Gr>bM?G+vSe_t!iygc`3 zre>IMBA>;L=X7;-_m(OLUc0WndUJ8Za4Tat@bb|D+7a^$0}#B_)3)dmr}LN(B2>Zi z!f~@FlwaIlyf;X`nFQL-Gk^NiC9!^#cJ^Z-{<-T})QYF(*I2zK3GY)1^ZSxL6Jw4& zH-AI|ar6_zA@&qV&^dFE`BLe+mq4F5wTqNXHDqq$sRp6} zG&r@On_H#yZY|RS){Z^{@C6GM_?qp@pioqg9Js!Xf$r4) zxA38b0kTHSy$TI!i%KC3Epan4?p4QbSo|Qy%$lin1-VG+2Ak^?14F|DxldlHjjY0t zLoZj~KHqRI3v&zSe-o?i|IK~^wZjEWP-|^=I zift|kC1LbQIJDmTsq(*l!H%UL%I2+%R&lX|!RrupdxZXUt%0?Nz5RDq{cKkW+(eO& z0xQFazC7Xr1mOzX!My3ZM!Svk7q=GSBt4N&kv;jfen-}xMu_&uOxe5+G~9xb!yas< zTP<$b4OtNp5oeTSh<*RodSf91J8K$L55)Oc*e+W&i?J!!s9DJ_RZbaxe*}>8dHZ6T zuD@FpDtF_Jg*&f!_*XjP4JqBiG~~Zpk(PWtDvY8$jROZV+tmy&JuDa>)Qij1NuP@&sZJ6ktf1reNOG?L9$S zjyG+o7vnle@R3`yPB$=osH1`JgxVde+Gu`5ssc<1d0-#5Y`WK>-*O_E9*o#YZ)-R; z;_-HU#0FTpLvh$yXkYBA>a8=RlJ;*cC)xgwsCSC5`}>}UV<$~xCyi~hv2EM7ZL6_u zJ85j&I&sptv7gi5=lg#>ce%;UKJUHPtXZ>WNa7IF8oY=F*p$Wao$<9($Cq5{Eh9!D z#ES@nv=keFk2{KsPkS14Vcq^(cRKIZb&DxiwDIR72S4?smgk=tzuwo2d4pGo_;K^A*Yc&MXq^LD{hV}{V{oL|tnA;JfJhP_8@_Mea-V*m`(;SyZm=tiJ zsEhuz|C$GRL{TW4-v+S>5FVLRFmCu93el#`?)KPQ;V}}Nvty@ouv^6>=Hu3N3BIjH5h=gr? ztc{a@C`KqzzV8`CoQEc*nQE%4>h-B=a8*v+qa~z6OmFZQx?9PhLx46HQNn4saIPVO zSZ9VAllxNc#F5DEZ8e<)vj6c{vJJBY~C$e8%zICi_4Tu#{jw8#=`k#Jp zTZebJ$O2_l13I2)aKyQ@HUcWd(xKge+VYQr^~-?+=00ztSrh6=_MWEXn_mhCLSylcb71hj5` zO0j-O0yOg+_~jfJvHF@3#!MveSI*hxF@|_jbkRLv5%*29@zZG#V#tnYd6W|-G@0l# zykc5(%?5L!HLm)4B|C|zf)JC4v1X{fh#hWv2z8h^7Oo)tzkT52jcQ{s1q$+C%}@k_ z2ROc~vv#h+ggMjanGcN3W~K0QgV`ayirUVju@^Ly!zRHHCU^5&X23j=-^;K^cZO_Y zx0J7*uZxscpDLmNhF{EcefDPw{V$9ec;D1Z8+eiNKmQoa4avv+z)sH}X4_&Ih?W}I zt2TzVt3TNgyeSMlAFS~$pCV&Z1B{yv>+5LhtXw6KX95px!~4Ip>!gmY0RVSo*Jd!l z)gnJ8`#h$SZ`fDcy5R|AhhSv*Uw^mticsEpo3DTKCEvD5h9Z~na0bts`icPasLxz0H4 z0>m6h5GZS;F8Wcz%^Yb<|Nb`67Bj&P^bV^UeF2kmP{KvxjZ=ace3La_xN~-1EvEom z;a(F%3w3~MfvfT@E2<{bgc1r~bAKIF;f8Dtf9TfyV<6N!oW}bhru*X!PKSHu54EIN z=${+g~ke1GDxJz|AkYxMP!A-V=0M0>GbX%)1Y z*Ab+Jc$~Fk$T}ihddH|xIzIeA(wM*Q%@;dIvbnDdqv6z>zKo#c^MQ{w*ME=oA1idt zegw&`Omqmp=#HL{R9Vzd((19qmbdp>9`uBOsT%ZjGX{^-?@y@Ty7b>`jk$e@GcxOt zYSSCXcE%v$d}hSD3oMxwQz3H0bz!NG=|}0~3PU|f6KBz(gv?PPjPJ3YMtgI4_4ZtU zw4WoV$vW!4)FQI8i;U*Mw4^S%bWD5^(iP{IqMgI_RIpg~I~p zRYO~5p0*z9qlA7pj;$V}*tNu?y)WzI9;tIf6UavJlPdxA*&pNfikxx$IU6J8yrFyF z@r8UGL$g|k`Zd{5%wN23GYj)b{f5j7AP(Dn#6ifeilS2HXfUv7CzU84oUU=Y+rn}F?#mfeK+yu(SPe$ zqgB+|4tS9-TP)Bl{?&o*d69rqW)PdE=k9uc>t!4n36IJgwYR8g9jcfGgfi(Lih;0j zV2^V>_D}9BL%kXg(UutHTzbrS_oYa?sOLi&uj4iT@Jd0Gh;|oGYB;wb&p#%FR6-V~RJ#%gqb^6C)u)FXWR^XoTPH$KVFT zr%k^x0E}hV*Nj-r$M^qn0XIGRBzsoQux{BI7;-p|J?mE>EE;_|Ftin4#;V<$?JHI?YFO^?a^2gRsG@eTeRFyA73 zjlseiK0==;QEE*Y1ci}FwmB;sJqjO;ZQn6n!(`!Nh24JS5x|zsi>HmwHO1BSqwt zd{4&Vb1cN~mt+Ry!&B@-AJfD@(RZFNPV#47W1gVps^XwwF^9iN;RF4idk_yIV8c6S zHoQ%kr9VLG_3Dz-P2<45)l^m2yq=G8S?W)`9QkJM1{^qH%0T1YAhH33N?=*}7iI#2 zu~zI^xN!xc6&|u@n7*t`p+E-+p53aw(bKwh{lntOIIpFbn4X1Tl4z4dmd-%2^D(qG z*sIJ(gHaZV$X(o3<_sB~)%n4t^C#}hR?E!K8N{P<92OoG72I1a!JO}8-&M6J&?CiT zyEV%OqrkG{C0W{jANOdB=$BWux2B{*XM#d?O1cW&`(`gn>a^th83HGOnG z4m0dFPx-@-Fe?yOhdM1Ymb~2QZ_FG#$l~(a0>p^@;$BDJOZQ1TfdS}&@8RN%Gv=85 z@gG4gii0*o{oa%5>dUCyU|~X!xVE_)LeJcb@@4U=j7gIPZ3usA)ron)@)65l2`|$0 z=60niJC-ZEA1;XbLwKVprsEG=m-d7NL{>;~wr`%rg;b}fs_PENET?TM)Iol4s{wvR zYuf;&dJER?n7@s5Ts4FI;Hli4V12eLE-JJ3`kJ4k{RKYaKlP6D-gS<9y3qM%c%6+( zoRT!8z|Q#A}qE`Z(fIxzit6q7}x2)!h!g_@}o2=#lL+7)Gs+=o{#s>%5n7Pw{ zwmC0UtTOCHBgLhvL^i3CMZD2hKLF?BTKEumyjfph!FOtOk(gSjyoe4p}{{`IP!&2RiK8FERF$DN+qeL0tY< z_v_syyOeHiWxv3h$)4LsM}2=Fb>+fe?5X}DW7n2yvc@oTWyhf=JfrYqe2n&IP?Q)? zqGY1yNWr}7)viWsuj6e6+fC|Q>C$f4M;z-u5A7jMV5BF!qFN$ba(1bKFue3_kl>8! ztr@dHuJ7`D&c8`MBbD-7Bh<;Xj_3#ZAa6hKy>ExUPJ^|#vief`|6y0BnwCA}=)sGM zmlkqLBxghRd3}da=)vzj0}$SsVTu?G@dsD?l%2HU#{g=kR&KWw;+~ma+Jx50x2pG-2gT?IY_s!N9Kq4G^5{ zeQ@M?r!(ekETqmVVUsdvUYiKp6{8B;TL}vE6s!kkU2#S8jWB%E6V>*AgAG?yqZ|Rk z`f0%TImGmC<}%BHQh(y)g^1Lkbes%^VPR8HN#?#L&&)DE)-u%TNk^_CYE6>U-e zA4<+piA49*Al%G6=d^^RP$u&$5~s(BCz+Hi?HjQa9S{GWpPrFD8HRwYo)Q0vOM?rK zHEL|LFt;lD_23>2#2omF#5T2J*H*qL`IgKn2-BN2#2?ihfsQwkBFs+iqJsB6-^Rj| zYgVn2&msIk+Mq=n)}If{A@Llfcr*0UyCDc_UDLhHI}B~tCo5tYR!Z#i#inw z^zbQ2lA`8k+Ld+Xq&$cSMGv#%z2hy|ckt?he&j-ZT=8Pwu1JN#97-N>Y<<;!-8|iW z^gLa)k$cNNy;szYtzajVwNynehqB_TtKF!tw)=lEC<{CtRGcaCmK>1bZ!>H;cY)xU z$fPkcf&v%`o6q~>s!iJCeH}~kAl;|hy(tg%_)>)YRhaj89;E}lSFL3R%F9pNCCzK~ znGKnlc=EcR;@Z-jN3Yu7QleNcy`dNoTJ4wj{f3I5+;u{F4vyMS03hz+Ll2??Y3|A5 zpTI`Oi%GQT=Ra1;R^2j(t~7P4TXWv~rc=#hM>0f7#Qq!ILk84@; zs8lwey$*ZchoKWmpa+a1H6d-vmjC@{-aePW3|%0OW-3kOti`83^rRWgWXN|TYNq4q zCm6yvV$1dekG{iFU)8CwQs<)iv6H)>TXYgs2++22VZlHzfaU?im(0`*B_RI3bMM~2 zvSEjpcX}V(bD*4L{yj1L5=w_;d?*>u`{+~EA$ah#Rp?(nh2Q&O(KP?Q5X6EqAm6mHn4NO3pWrJ9tfkyiW~QqUxwhUXz)O(n_J zOkf~0G9)f#HET1(kLJkf^drbG#~S&J8&00gaZ1}>x#yvKkHNR`E_pOI82FFsJ=+y? zqoCG~PU8%{9G`uB#K6A}jnLqAgmiGyjx^#;eb>4`QB^-q&0jb(Fp(b+nTqj*u9o+s zDgj#gSs<#btoBJ;jYSIWD<bLc2YydC5e5mm|(4dWtLgkOJqX{U>0K2Hz6 zZnbmdy31($pb65|Js|cv1ck01UZta@5zx`mm!m2EM|LUEkEafrq@)z)kFF=1k#6f?xsdS1!c{Wb<4?fAyZhg#^`AaJ%r5>F*9_e0tMGrwc zm-RlDF!-L~3)~B9o)q%+f+FEl)j5F8vZynFkZR2i8-YS}4O`{7;g;^3@RO!<)oXzE ztp&m0nXmuZS7Od_;;ZBNp3t#+euVBROJBm4WNPVjabIkccGmf#;kEm&lLCux%<0|2 zE@RAS8` zT)_(Tlh96_GLZfGLB2T~0ATC*+ux_t{aQQ*P)!AsiLYuh*pAIq(CDl5s>G>&?K?hi zy;_*y@=THVijuMZoobN1o>Gnux2HLHN!i6b$t8nFkCc&LlQGE_;$68`x-S8CWIsgy zqtCRXj23#nC=LjpD$ktPCfli?Nir|)^|jw!aZEUk$51kutQEDr z18h%nwX*$le^6FQ6&l3atQr$|06>kdPoX*R^0_vZOhfTCiBhH-;Q&C(T^By2dmL~&N%*ZxL)mwqriRR@!dC6eX}8OlesjWC zC=Vyrzuw!XeblK*K&FLnKdD$e1qEk|2M05X9q*E=^Hd}Ox) z{dWto)=r!#YuRq$tPXqXzL4NC2jZtH)@?s5Ih)^F1z8ge-=tC(b!R3ww=7BAf1?v~Rgbr!61 zWIQ^QNerxjJL={EJ+;Pll|wybOqa56JSY&pEqikoOE?UXON&7K#Q^<3?x2y_SSr5Z z+8g(T4cfwMSKDmo9qyK9*BKCHV@|}XRXCqzVx^B|ZDqPfE(!LFUqP357#ReEGgZ}; zDkaAWkA%Fz#)gmvGkBglyvd$f-y>-DFgioVjI~jYA;)m5JJJ9lpE@-v(!@!2VprAs z`T=fu|B%umTah+963$6^Io%Qq7+~6(GgCaC{$~<#*Mi7}IK#pgV$GrjTBe>0mnL9P ztQ)iwx9wQ4a-MST;T%(%>|%J`a~A|@!R^9_lJUlDJiCob!9s@uwbwC)OtZD_7Xqv< zhzwSy_xW;R|E!_|fK4B`5#bB{q;LHiy701aS?4*OfjTR2fBQ|#?Peed{u(n)fu(2l z0 zf&)2Ea{f$1^U-;`&5(1Yf;jrOUh+PyS`)jiqtx>BD~L^YJ`LEsg`xb{Q_xs@YMAx4 zr)3==;mVhY#94<1v$*HamlHx9uv1#Hh21u*wMa=SFFW8jksLXdUY56gzKsezeDZvw z9h@>^c~03SoMr%F?jmvaxVT#FJ?Pi&d3dmQ;n~ia`H)UtV&>$*00F1LMbu=?nKzim zgb!odvO%~R!wH-tmo-o*#8ICA9S0@_o(t+7psI-0Z~X8&@oR%1@FWN${u{LQxUAwb zT@WKU$*7nbAlV5VA}GqS`CNVG$QkOVTmANwur-8Dt*SMrQ~9=CUHH`HWHSuVmm7;& zTf3bYWU?`&vAa+|Z9J)c<$U{ZgrDMuxqMEC+r2c(T5%HZsPCUeeL1Q@!#h^4zc<4n z8C4i=(K+RTlr&2rCfsFYO4VB?=_{$d8ZAWv&(bH8@oK@7B^=FIo4%SS^VrFZb{Gka ze^@ZUq#T_ufJm8DGOrplZ#H_6|n^dd5vqY8gcJft@n0@_$W6 zE$y0S+~ngl!2NUUCDc8t&x;%M|8W7G6TH4wHxCpUgKax01QYT6uZQU4iw%g1#IQDb zo_|?J?L5yj?av=Z(No&QZveX+9~2TLGb@CCleB*UJ#+?4N0vXkgpTI# z%6b5#Vn|7cXk)p=saFJ)Kpj;d(?_nyRp@+*g!k&gz7}=LpQpD|fPq8+N9ndXD$beM z?R&mGqdG!Za{a-u=d=5B3L)}61GWja6) z#mnTCMxk4TNqgbVjDd+k^$P4$w(AQ)Wc~qQI^Ak0@zOPU-i!Am5vlmMw$yHZ?4J&0iv_szu10t;Y6-FoSahi}q-X z)&+kap!u=Vi%I@K16cI{iy1CW^IHz^0j!&?#+hb1RdRZuQR%#n z2k7yjz(E}2Vm(JK1{i)ZtK)pdD1rHhX*#IAEUfEXanCVSLj1vsGDWcuQ$sGr*+8@% zF|s1h5`9oJX6oxD2(MsZprhC2^lsg>%=5Cl?|}v;sBGV~`M-*uyhNv7(-BIVyOI-N z&2ut_S+x{Wfq#cRP}&dD3XA;Hyx#1bUxzV!`%m~WCo$I?8&2Ag@k2+ZX_BeikNPvPHyP?X!_w5#$82n9X83e&I{s)H z^+3{PGe?{hBq8i6vz&EE{J2S@T8*Vb_S6S1ZkdNNB?ic{u{wsd)R9uxHukrW5kq-d z`DwjO-Ch!(P!eotXL|Js1A%P7D`+a`tqs4rcC5L2oeX(!G`!GSHIIRHftrc4CTBL$ zW=*hDAUNozcAYY9nn#>4mwYYw;@jN3@aiu=$M<%))3CJ`Y<`S!X|&aaGa2TbC~nBP zv*6F7@>+rnlj{$^bcCHUPQbLKgw8Bw$5W{O5bmJEj*IY^>Po_B- zW=}&knYQlR#qh|=LH1}EqlI(qkKesxWJXiIZ4rbY>J9~5C~IMea(aLLUe#gqbM@g0 z6|0{Odo(QAak3zV=~XBC4jj`d^QrRDw0Ag^!3Hh73Ki>$^1Xz^j#5;}rJ)QTz2+wr zW3ri6z*d#d?icovnn=w-lytc1!;L$>$rfRR)KH}m*od%drco56s1L|Z{ zi9zhxv$SJU8B(QqXX$Kmsj>a=;cB+6$ObzPwTGB=J zoWTPPNUM{sL7nTL&$|8(Dm z#cZUXVA{HwQFbaVD*CX1f)6J9@1pzmA-+`#??@GnZk9Y}sZ< zjp|WZ4)W&`X`6VkmKubb90_WpPfTM9U=e1}nfoTid~X-F_K)VneB*G%aK(9CqXk#v zE)$k|J_Jo*TvDhj@34lb>ZABB#j|j0{fs^W+K*@uB+3e@3skF{i{3a}})HZsPXgN-^&r-l9k* zIT%=#h8)av5CnvyF+V8X|W8H-Z|6DnPu7R$O)36ba6(Nzh`z7Xpn;EI2{DB39%*#7?4*DoTRrqfo4JED_9k zioTp2A6y(WWQW$;2So1r=SdVV6W;R8AzB_=7m^ff6jh{)%+;41Q)OhCs#!0-&C@(x zJ^8^POf`}k?cQ$gq)vZ~H_7``-vsNm?Vcl1eEkiVyF*rRV$lo_~v(jrxg;8F_iD`|-@EjYQFpMcuvhwn6o0p&D z%H~BW(+bHfA!aXdLB-BwFTjY~VG>VLr*b@ivp<+CT z?d}gLF5SpbkO+w1M;CR9-B(e#EOkGay`hJGMu-T>m$9^Ka2`3d(^~O&>c8fwLF04a zrV?-qDhMd%yv7x|gz+vu{*HxT$4i`_agF=#xsRTw{}(ZOU3Z_Xl5JoZuIrLkkg}YK zKW+B+$;oF5>5)uua0)ZMI4#XK=8#to%&cK3L{81QR%_5JVI|KPxBC{?Wx z3v;q0Vfl;i-jB)2JR)uLln1zo$57@g!;EdDX| z`kI)LJ=@rO3wx0FQ4UG->`n37`&AND2^sw9AbUy_Kxosa!riiWIO|rWI%aOvkK$vj z(M~%!(B4F+yqLU3C?jtcG$|%lmNd>HV`!MDF)=g-sf!604G-T~7_&mg8Z}|N@Ci&c ziL(tZC0Cz3a;99P^8$U8qO@IgZd$kLNw8Y zjKAM84!TBj|MtGK$Nn_#g$xd$o=a6Y>~iX1yhOKX)l%qf!8zDO}#QA z8Z)v8W>AickcKy5_1n zcvwr`eMA3iW!)va*ddrUE99*dboO6;W%!SgSk*}mbD&Q<;e3qx-T8SkiG_g6coyqV z0Ll*g`D>C~xx%cw!4+X|47rEX8md)?syseBruVJQxYqicm+oqY?=r4GK-vG6jFoEj z9Zm+U(oy#t#!hA(t+5B-@uI%#;yzBS6lL*s+fC^gtuy6#C6|sV0nW#-S!rgWWviQf!aX-01fb&A3#Vg=-I_M?I=2cSvDB-?=&qu-_+j6K|En zEi&xEf67K+>R(91CTs= z2>!TXK<+76eANCd1v&{5)#Krs8}a6$ihTdTd_E7;tp34EuaPQBIQDncVWn?HzRb2V z$+RgF9c>luSRtdv)nbJP*eYB`fieE0fF6{`s#Fz;`^}WjnoTn|$JcQ!tr7In_51!B zb(r9Y0li)K*%?&DNj7K7prVMY8a$YDoPCe&d*0KNC{Ojudc9bkexYIFQgC*5!e&V~ zuH@I8MZyFGc2(Hy^|!4HJR-HGPtq-Si|0xBT_}Hi8oYRbtx0W*@u#rVxO6SY$ys^r zx|DSbZs>t170qM$%a}9Qn4unW=@p)HXAkm8dNun7o)&J&dF-J<1=}Ki6YCYG#ULrl zS2Yy|NhNg@=7oV4DBCNI8w?0!|3<)tu+xrlk2~Bo09z~QSZ_h!aM8hpV7;N^UThi3 zN)Pv{@4(1-56!nQ8Eh3f+-$?pRH9Q=|ZiIm4GUji346y3T2-DQ!4oZ(nlzfvc7ufo~1+PlSF;)WsSq=o`2~ zz%q&t?Dl~?g$hGlYciK*^e&jY;CDcwB#YXvxgw)53)Y` zPkp~i9sj;d6=7Tg;*z0x#b)Rn;*8hXSiJXWMubqJ`N@+dSrd6KXYQw5eo;oOoxSKh zWJ30AXw~-?9*S*FA%q0cVMJ5*iFG3_`dzMwG^kJ)x)(LgOvDij47$bbi|X>c4tJP1 zb8V7K8m*$8aoS_RkY~)CR>a^eegx=Rvu_9pOfPNNi?Kk%iOqHi zrpc(Lv|{%1KiR&=noD%37@d6U1%0QyDDYgv< z0!zCMmrR;(qmV|JtIsm9>lIurEf?}f+VXo$sM*A<+k+CRR+PZ5G9zSI%{+1C{Aw4Q6klxJ;m1; zVwC$BcW4>Dhw>HZOB$eMsFI1Hjg9F&%O6Lq&-e_Cp^NVw3_yZ94>PUZ#LjDjF4Dre zwTS&C|6Xqtf<4$j`$M}>1___*LJ#~^bkF+s8AR=)(?dNH%iDqbcA`jt=e1f%`2TSM zHM{*CLD{&Fc#C-{d3AnUR(wu>-jUs|&9Za{y92(a#YB|1Ex`7plxU-tpQe=adK^Bj zGgA?;$}}k|CjXY`u%~go44^(*V8xkBYNoan-O?%wWDeS>5{_i^jvUC4k3;MvQjK2V z_|(5UMAICG9dU*IQws9zYx2)107nLj-5y|=1(z_npg5omrkX|?$5D}PC^%VBO(@qf z)%{~geSIKU0th@IsU?P3`4u9}p!K&t_F0zR291Cdpl&Jx9vs63no-$k!UK*Fi6uv_ zA1Vgw)(j;ZX8|vOJ!9fMrwz)!GurfbfTJ%6aVm2ye$TBd*_`o~I)Q#9PN=cfH-|OY zeEi?0^>#pfh?NUV5fPL#;#kn%Ij^xkwhx7zT1E|+t;$e>@yZ)_EpFWKTTZ;;zPDziY@6hL(!+D~9e?yiS<9`e}f5)jfM{4;(76CVwdeY(6g)eh~ zLXpLADB0X(20z&_)1V!H&GoVW-1L%NSsAP4>tw?|A&@MaHgWWJ#h1hYoTILm#As)T zy+5w%bGqfbKh|A$Ej^1ZdhTGotaNG^D2i#m<8_0Vb8XXSe2a;Fb6NT;E$*OWJDPCB znXM6~qde0H)pHFv3(-Sxwm^bz5fKvAS1a2VyN0S@%s?2u^MJc#RewAlMMhR2k7t~P z9qzETUc zkR?)KzFxWbwh-jMDd8Txa&&?Y*$VV^l!GLi&&hAOMr9poW*}`4@DOer?lBUZp=+0y}#~`0^^5mGWzK5Ko zJB8J_#{5}ONHuNmw+I$Py&L$IOU|CcfYHabNmvBK_UFNcJeEURT3Qjw(4hXZ&$-p9 zL#GCaqvO@tV+EBAFXqE=%^ioBzx|T+%mt4MGoA`PKnIlwRgy#_&1QJt5o!P-+$6a? zCc^nf3j_AZlL1SWA~`!dbIE=7{q#v81GVkeUm&r1oW3=kMvhiQF_^$GJ#C^ytEP3t z9{P3U;KhQwwf)6vq&wkzemF%`wrA4hK zNte{+xo_M=DG8l0hRv|BRQ}_Pb=tS<61R5IE+$`4VP~BTeeN%CUDDt$Ermqp7eGkCz_vjYXj)* zs!Ulxfl`#%GHr>!se*<^hi^{cH8f146@h!TMSN*e0F)!v{#Qs`NvN4a0zCr>`;cPA zn4xpM^M@RNd_fuMaJ~+{FIm6;NuHOVqh}S1vFsRefPos6Ct~HpC!)Dc4Od*EOg#{* zDnbs1g(sc(o)%VB-DpuA2aD7mst+o)DVyYsg{UKmGCs7_F824^I)0*l;2_7&aKGmE zMNAmRTL*=+`}SKD&sERK^3$#WPDc%B5sE`pV7mciQGv`wPDt&D5y-*VC!+H^lKR4z z35$iQKCge`ddg30aq7*&-3FJG78^5{l7baV>|ZP%D51eZxLKE#nNp-QeH*7FRD5({ z%I8mNr5j)_=&I~tW+F2V%SKGy8kx*t-DX8`h&xViHQv&_KUaYA<4K4$Ogunv_%{qG zQBl+H{-**W^v#&txOIbP~xAdH)@9{M|qsRI6^>&u`c0c%K z-`7=`i4@`i=)b;n)v~>Bl_9SHsue><6=_Rdcx+t^DwxpGU9+QcpU4?*fLt!bcaI;P zs<$;vtP0qqk`pFP?OnE`NI~6>7T!>>;Bjw5-||#iO zZwO>QukwT#_*!FcD^2fBf7zbn>ldGX$K=}O13VOlsFT#uA~D1{m|)3wlcahR5ptYC z#JJJqieg;yTEhX5-nVESAXS!et3<}k#a`RkJsIqIE3AkHH~}dLG*s-ufbTC1HBw9I zVB|h$X6ik&^J_x&yaKyBvrnJ)0^b~VoMBkX#1}^%_9r`M}H!XSVNgn z0<~q2Jdj4-*DwZA6j1_)PA?)-i<>M3k!48$kdJG0;SCjh#k)oo9yen@mZDt9gEK*} z#HKBlaHH?w>sJilih4wmaaRx4b=*M-y4B1R7V!nXF@-Z`=K**F)PG1tu*jO4CxZD7AR>hecJpQyy%LC z_LXwBIhUEErfIK5A_q~N=>Sg$91uT8!^HKDzJ~CLs<_yvckn^Qfjf#cZdz}}6tBP3 zSy-1g?J`sBq%cA~3DBevfqy*rGwlsSRn4S0N2M{3M+KW2J6N9!I@_~vs0dc~VG(&R zxOW<~kr2abCj4hY&d!)=Gi~9I!_+N1c7A(jfw?)9;+WjlKby{#J@@p);$i(n^O zI1Dyp=A>z*v+TGvW5cn>P~gF77-`IwACKDbB$F4dS-tP@gRM=j#>wkL(gE>w;7HuMywB z7u4bPi%)wd&<*)|RV_j-o0n20Mwx0YT8jscf`H9w_D5p4pi0Vhp`LA%Q@o>PNp?U-D4920=%@(2B!o!+WPoYb8O$)d; z?vCm&0!y>6d6P<*2>g3>wtHdo9U0R7DC&4ntb6PD)UF>6E13j?;~n$``gltNI^m`! z_Y#aemt~ z#2-|8c3Mf=6x8ljth836t!Qg3HRICWy5lSn#_zXXaQI^;aAQ`XyFTB82WG$O!A6Ym z>XP6H+T~vl(`e(2A>%9`)L}+jZ7-TNXycO6_ZViGON@YF8|lY$t`|LI{y_WKq!cA5 z(ohLMFTwXyOw@ROYf@EaT4tO&&gQWxK5`iwYfUw@3!Kf>drpQM=QJ~61k~AVeikf* zdLi*$P4qH+3;mHVe&E@gL@7$OxKbU4jyF1E!hETQ`>U#a;x!ST zNDqabzn|gUr6K#v7CAmu}zlWQ@DPGCtq}=1p`bJ z^IdQqMqV#qfSW)=u&Ha`M+_VjepskRE81rj{_aCZYAHFxuMLs~yUzSIC)M%r=_@IR zdJJrqt5uZz1FrD8CYgn_8cr zF()#R^hXIjJ4TIjEcSOIX#y@$yvK0mMACQ!-VDScJA2f6Uv7*hVkG}ftgn>%9Z4A# z>|i_Z;Y&e4N`x)hi}kvka{41XvHYNuJUI=ir+rb53Evz2rahmZ zH4f00ORcxp<8`PB!5D>SUIj@`5!g`LD`6N$IyGKbV`$b02xYqj|P&++0!eVvuUw47Gf6h^`wNN2d}CT zi)O_RmYVj(95Fgb+mAqP>tIFL(^1PM+|vdtHf@bE1f|dcGU?g9?zZ|lB|$a{+Lb>S&=_a zKPNhJB;@7Vi&24JMqnP-w&zifsjPfZ$?f;pZ8O)sK9%ZS8?y;$GF4=<(4Pjz1T{M` zGr8FhvRO8WK0Ie6fkS{Nq}Sj<@o(U-mD1AnOoo(u79_8+hvJPNY?4V0Ev5t*WH-(t z!;Pd=jWOL6Y_-`3K57$6DF2TOP-wSJb{q@lpNw%BF+xr!GEU84%E<*Ro6H-p&sPp; zq>|Qe_V;Sgg6qZZxNq|P^w3##;O&;nE0&T<+j&oY+IiaGfCH0R@jwnp( z{~BIr(PxYOTtdjG;4R%f>XyD~Q!ZWCY~r~Qrp}QvXXzvu^>eghT-c13-gYb7y$&v4 z)~N1mZ}yuYlgc8c-{%Z~ekEE;kGjl%1KM4)ZAm};T{0P9)I4Te=TR9c3aDt6w4hO` znf8m|!MTTrx@zXpU`duNinEoNoI0wuCtdnoS^$L<4n+X!qQeSgJK50x8(KXmB{3NR zWJgSh(DAebHl3uIVipdyS72;q6&DV?aF(7v1a26R2t8G~mDAV#pK98Ak9gvJlBy?| zA0q(B?eZoFn*^$j{oCOkxAYs}pWZFnv3qmP7c)eCaW4BtNsg0=g@Q2#0LtC?6Uc{D zE|wHm8x|P85u%$%dGjg5RcCxEw*b^bkH_?DZM@l5ul|xu)L61OOABFWB6@NJy69sD z2?V_86~=N*n{1g913CJs>F7eR^mxk*5#qXGmfXpHC8%@Rxc&!8T6#bYA|b3F`A-~X znZYo4n@zBDD=FkF34kXkjdbuHANct%a$K;z?S--KVkue8sq4J)8zEcP&f+RkJKJ_s zas=LJp`F)1$Zs5M!*T!?JPW+y(qS~w9d|%a-e-ot0Gl%|mCuT~W@1R83I6UV=nxV? z&%^1hx;xK_?xv1#Zz6;uQ;ga@!LuT53KBj)Ym-0WluyD}zCg8Vm2;ExU6ZpS3e_)) zR1Vx}M-(dZOyE{6&}D}i3U}=?p_<(Q9j(|jvqmi%;+y-KhYGcsf1)z!ao{TD*IV5$ zzh`{+iKzt^3Vd0^TNwu(LVU{hNp@U_cx|W_`V_gMG`M91Rmbcfmcj*{NnLIh5{#5sk7l*Vt#M*5+nFQ5 zeEVCwX2#eNj7U2PlBF9P0jnFJt)3=P?R*S6_q}dmzDG3o6E4R}sy59r5tj2ekRb8q ze*y#N-|2ou^+~hp>CW0e3F>gIaAfqIbP$y^SjiA(l;TN`E-HVFWTDxa7=OO+_G5p zy;f49x#lSzM!gk5;W72}w6N#y=S;$po^0$f`+)@#?zl>`^LJ3-byh=7)UgrNiveub z7(5Rik!8l&Q^0^slV1JG=P)bpx;5*Z1F2Jvbjhj21uz%rRDv)%^({!QD;6neJv!}Y zFJ<#svbbTAk0Qv1nKc=Sql_$hl9!P9kD{MH2P#yog1p0@FAqH{P=J))3tF0NJ#6f< zaW!d@&)6?1J*0tU22+Rmj`+`W53>GByr(|f!BP2kc*Nb?F%pT?!vNUU^seZr@Jdmq zEJy?|6BE(6KYA*o`_vUGjYCG@2RJR+?3SE|gIDz)LKKNe7Vf~udRZ?TDe(WG?%DWV zTV}#|1F2ZmN#cdbWVFu6QHESuFu7Sl_K9dZok)wE!}a(5kIx5VdOc|KV*i(~$*m7x zxIr%&gYG-OWB-p++gP1{Msi;e2+_RIa`yq20=BBNb6fb6QI#Gj6JP4Z^{y+pONN(q-Zb5g{vs9u1BvRr$fjloS~@gPx1cIXlFu6Tnw&{#SR0KAiPN>zQFdh@fvH-*^#^%vxg2OOI%yijfN3%U~IUA zUj;F;1X&|;{+Owv9dp$(C-F3TljJhismmlq&x8ky&UF`#7+LT0)_u29?^Y8Rh$fJ` z6>S+h7{F?#p)xOVJ6bbi07_tRCcg7$hbfkWp-_BN{VhK#3RIR~X8ez5xr>Cv$Q09- zM{Dd5o-b6pvuV1O)d(<`&tr}yc>jq4;6R4^iI0RZWy$_#1*U%#4tD)5Mlz#3YsThEwNF*U=? zb|w@UigI#X(;r|WL-h;JVyV&!!S34>aXteQtjb6**kp;{Y~o=tZF0r$9zqcoKXlP! z&J||$5v3p!RkowGuoK%91ggd^ilU#=wf;|iUZmJrjoAl-iNMf}S%2zUU_%+s#9bnl zwp5>IFo%23-D(Q#a1L9EZW;@RT={aQ`?i709~O(|&e)==W0A!ZoVkwmTMYyr=K|EJ zcEQupxsaG<48QdOu7Hj>khj$*O}%=>^f(eDe95>cFKLphOorpKC)Ek%VGWsnKm2eO zL_0gTTE$>tq<1&NNE#_2MHg?@kWIvu8X46>*qyb^pH4(24<p|$x7vB)L5`u^BzY3JO5>HSUGBx?xr`9qFpzC{s?SU& zIbqi1fwYE%j|mi&v_Sv&a=SKB!SI4GZcW0U5cH1<_C|5&`#+PkSRo>+#j$wio;UN{ zm?$~wt+L6D_g(mG`o3GoG5>h&9AYG6P&bnJUCtn|F>@$==2Icy;`O15s5;OW2D+LU ziM579wn)`?X>e-Mm^F?0GoDo1ftLTYzlyc;5`=D>k)HLMo8SiP@p|bV4VfcvHnCW| zRJHb3Y`;Vj%OtAB0h1!yz{`kW4`)sJtzB8m@h>vTVAyWgLLyCKaGp^@ab9hvi|cb7 zaanJ(Lz&oY;V|FTIc~ExlDIb~b~}lDW#~hJMv#FIb2&Ec8ukfb3X>}jK8cA3ibE!a zVfmi&(cfVqOX@6XOgJvS3TvhmObGz3Vie!aioTKN|Hsr>u*J14-5Pfb?gVJu-92~% zA-KD{yIb%8!5xCTJB_=B;O_2j;p=_QKKHplp?j{iX4R-M-a^bfZ5Q843OeRus9_XqRtB*MU4Wg4 zN?8vgHv~lLOfP{cD==bCCPT&mqkHptt8amtcc%GCGO0|$tlb|$0>U>HZ7u6~f=|?{ zx(7N+E2VBrPvN#5g3R8m16a@`rp{?KvBe5tDWaY5|9R@{nPo9;=hK~dcj{i=%h_xt z^FwA=u;2XE8@|Y<0G>BHGXqYAY_Qg$!W9=WK_8PHQ@MP|GUmx|Mb8&)92PTI_r4B- zw4X4Drr=3?L9nj-x6j&DQ?OI#!#9>IP@m-IURNil9&`Bl;;6HMR7S^!ew~qhVu#4E z_|@JNvzL#PId128U8<^8-uEQrey|9|IqkHgyk;ea{X5;5TO=f07*Pr}asO$= zsT#QXJf`uUMdp|@zCy{$$`OPD$&p}#FGI&bso4*8`Myl{F=q~q7mdQEHg5_X|8nXo za4Q|xRj$fsoE}1x`y}``ydyl!eqvG>F9bPTHs4Thk*Lpt45e^Dc_JBR9Ph3|W+G1Y z6lR{=ZO>j?tscC>@p?F?my!G3=^LkOt7oZs|2B6^_~HH{>|-=1q4hXE+{nsv5mFRQLXV-DZlr^_F-x=Q^Rmn>g@dff62JB|!Do+RZ(;^U9jYll)5$(^oD-TB|4G%+_U z%X|XP_L$0QF$cc1eY`W_aB}kKd5)5;h3n|-=pfi{!1M2Hj}I2x zwuPA;kQ3WVj$1(2*7{W*a?aRELFw;gQkQ|}Fx=`hnE{FK;j#mp=?KOm{*affL0V=+ z5IE~Qg9!^4@@VMRHwZgp?!4@&dwK6&rplfm2kuDIs)u(F6~FHzJ&)ZrvT+s&cDGlz zWGH|ihh|h&l%$2Bx+Hl#7OEsO>Z5@jHcwDo;re?N*<53x=A;|MgCJHd;7y}v&G{8w zpZD<(*aheBTblT*YWk-D9p`r%ogN)|jMNV33=;hxP+}CO<6*Nwj`cuK6!FHY&k#pL z#sq5e(VZAlG$GOB(h8SiB1i5ssGl5lF>p;V;5H~KVDjIQKXelPxK&%(Zg{fir3)E9 zryNqxC)0y;@EvFIeI+6zMay{KFm&pkFEv<`{G;n>oOFe8 z{C=tEyCozWo=l)<43�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 literal 0 HcmV?d00001 diff --git a/tests/test_service_split.py b/tests/test_service_split.py index 328ab83..4f83f78 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -514,6 +514,89 @@ def stats(self) -> dict[str, object]: } +def test_backend_icon_proxy_returns_valid_image(monkeypatch) -> None: + """Backend icon proxy should return image bytes through the same origin.""" + app = create_backend_app(BackendState(persistence=object(), crawler=StubCrawler(), search=StubSearch())) + client = TestClient(app) + + class FakeStreamResponse: + status_code = 200 + headers = {"content-type": "image/png", "content-length": "8"} + url = "https://icons.example.com/favicon.png" + + def __enter__(self) -> "FakeStreamResponse": + return self + + def __exit__(self, *args: object) -> None: + return None + + def raise_for_status(self) -> None: + return None + + def iter_bytes(self): + yield b"png-bytes" + + def fake_stream(method: str, url: str, **kwargs: object) -> FakeStreamResponse: + assert method == "GET" + assert url == "https://icons.example.com/favicon.png" + assert kwargs["follow_redirects"] is False + assert kwargs["timeout"] == 8.0 + return FakeStreamResponse() + + monkeypatch.setattr("backend.main._is_private_icon_proxy_host", lambda hostname: False) + monkeypatch.setattr("backend.main.httpx.stream", fake_stream) + + response = client.get("/api/icons/proxy", params={"url": "https://icons.example.com/favicon.png"}) + + assert response.status_code == 200 + assert response.content == b"png-bytes" + assert response.headers["content-type"].startswith("image/png") + assert response.headers["cache-control"] == "public, max-age=86400" + + +def test_backend_icon_proxy_rejects_unsafe_urls() -> None: + """Backend icon proxy should reject unsupported or private URL targets.""" + app = create_backend_app(BackendState(persistence=object(), crawler=StubCrawler(), search=StubSearch())) + client = TestClient(app) + + unsupported = client.get("/api/icons/proxy", params={"url": "file:///etc/passwd"}) + loopback = client.get("/api/icons/proxy", params={"url": "http://127.0.0.1/favicon.ico"}) + + assert unsupported.status_code == 422 + assert unsupported.json()["detail"] == "unsupported_icon_url" + assert loopback.status_code == 422 + assert loopback.json()["detail"] == "unsafe_icon_url" + + +def test_backend_icon_proxy_rejects_private_redirects(monkeypatch) -> None: + """Backend icon proxy should re-check redirect targets before fetching them.""" + app = create_backend_app(BackendState(persistence=object(), crawler=StubCrawler(), search=StubSearch())) + client = TestClient(app) + + class RedirectResponse: + status_code = 302 + headers = {"location": "http://127.0.0.1/favicon.ico"} + url = "https://icons.example.com/favicon.png" + + def __enter__(self) -> "RedirectResponse": + return self + + def __exit__(self, *args: object) -> None: + return None + + def fake_stream(method: str, url: str, **kwargs: object) -> RedirectResponse: + del method, url, kwargs + return RedirectResponse() + + monkeypatch.setattr("backend.main._is_private_icon_proxy_host", lambda hostname: hostname == "127.0.0.1") + monkeypatch.setattr("backend.main.httpx.stream", fake_stream) + + response = client.get("/api/icons/proxy", params={"url": "https://icons.example.com/favicon.png"}) + + assert response.status_code == 422 + assert response.json()["detail"] == "unsafe_icon_url" + + def test_persistence_service_exposes_blog_labeling_endpoints(tmp_path: Path) -> None: """Persistence service should expose multi-tag candidate listing and label management.""" settings = Settings( @@ -2419,6 +2502,48 @@ def fake_get(url: str, timeout: float) -> OkResponse: assert health.json()["status"] == "ok" +def test_frontend_api_proxy_preserves_cache_control(tmp_path: Path, monkeypatch) -> None: + """Frontend API proxy should keep cache headers for proxied icon images.""" + + class AsyncClientStub: + def __init__(self, timeout: float) -> None: + self.timeout = timeout + + async def __aenter__(self) -> "AsyncClientStub": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def request(self, method: str, target: str, **kwargs: object) -> httpx.Response: + assert method == "GET" + assert target == "http://backend:8000/api/icons/proxy" + assert self.timeout == 60.0 + return httpx.Response( + 200, + content=b"icon", + headers={"content-type": "image/png", "cache-control": "public, max-age=86400"}, + request=httpx.Request(method, target), + ) + + monkeypatch.setattr("frontend.server.httpx.AsyncClient", AsyncClientStub) + settings = Settings( + db_path=tmp_path / "heyblog.sqlite", + seed_path=tmp_path / "seed.csv", + export_dir=tmp_path / "exports", + backend_base_url="http://backend:8000", + ) + app = create_frontend_app(settings) + client = TestClient(app) + + response = client.get("/api/icons/proxy", params={"url": "https://icons.example.com/favicon.png"}) + + assert response.status_code == 200 + assert response.content == b"icon" + assert response.headers["content-type"].startswith("image/png") + assert response.headers["cache-control"] == "public, max-age=86400" + + def test_frontend_root_serves_spa_entry(tmp_path: Path) -> None: """Frontend root should serve the SPA entry instead of redirecting.""" settings = Settings( From 40704e86a691fa82eb066b1f25f34a4d8e64dfca Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Wed, 3 Jun 2026 15:48:26 +0100 Subject: [PATCH 05/35] =?UTF-8?q?=F0=9F=93=83=20docs:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- readme.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 6730dcc..d54162d 100644 --- a/readme.md +++ b/readme.md @@ -19,7 +19,9 @@ 2026年5月29日,有了第一个fork,也是人生中第一次fork,开心 -2026年6月1日,摆脱oyyt为本项目画了一个虚拟形象,开心 +2026年6月1日,拜托oyyt为本项目画了一个虚拟形象,开心 + +2026年6月3日,一觉醒来从 9 star变成了11 star,突破两位数,开心 ## 文档导航 From bd022baabfcd65ce9fee39139f16c9625ebb4311 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Thu, 4 Jun 2026 17:20:10 +0100 Subject: [PATCH 06/35] =?UTF-8?q?=F0=9F=90=9E=20fix:=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E4=BA=86=E6=9C=89=E4=BA=9B=E5=8D=9A=E5=AE=A2=E4=B9=8B=E9=97=B4?= =?UTF-8?q?=E7=9A=84=E8=BE=B9=E6=B2=A1=E6=9C=89=E6=AD=A3=E5=B8=B8=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/crawling/orchestrator.py | 42 +++++++++++ doc/api-docs.md | 18 +++++ .../components/GraphVisualization.test.tsx | 6 +- .../src/components/GraphVisualization.tsx | 6 +- persistence_api/main.py | 5 ++ persistence_api/repository.py | 32 +++++++++ shared/http_clients/persistence_http.py | 7 ++ tests/test_pipeline.py | 71 +++++++++++++++++++ tests/test_repository.py | 25 +++++++ 9 files changed, 206 insertions(+), 6 deletions(-) diff --git a/crawler/crawling/orchestrator.py b/crawler/crawling/orchestrator.py index f1d6b04..bda782f 100644 --- a/crawler/crawling/orchestrator.py +++ b/crawler/crawling/orchestrator.py @@ -238,6 +238,12 @@ def _store_page_links( ) raw_record_id = int(raw_record["id"]) if raw_record["status"] == "rule:duplicate_url": + stored_count += self._store_duplicate_target_edge( + blog=blog, + normalized_url=normalized.normalized_url, + link=link, + seen_normalized=seen_normalized, + ) continue decision = self._evaluate_link(blog, normalized.normalized_url, link, deadline=deadline) status = str(decision.status or "success") @@ -278,6 +284,42 @@ def _store_page_links( return stored_count + def _store_duplicate_target_edge( + self, + *, + blog: BlogNode, + normalized_url: str, + link: ExtractedLink, + seen_normalized: set[str], + ) -> int: + """Persist an edge for a duplicate raw URL that already maps to a blog. + + Args: + blog: Source blog currently being crawled. + normalized_url: Normalized target URL already seen by an earlier + raw discovery. + link: Extracted source-page link carrying raw URL and text. + seen_normalized: Per-source crawl de-duplication set for targets. + + Returns: + ``1`` when an edge write was attempted for an existing target blog, + otherwise ``0``. + """ + + if normalized_url in seen_normalized: + return 0 + existing_blog_id = self.repository.find_blog_id_by_normalized_url(normalized_url=normalized_url) + if existing_blog_id is None: + return 0 + seen_normalized.add(normalized_url) + self.repository.add_edge( + from_blog_id=blog.id, + to_blog_id=existing_blog_id, + link_url_raw=link.url, + link_text=link.text, + ) + return 1 + def _evaluate_link( self, blog: BlogNode, diff --git a/doc/api-docs.md b/doc/api-docs.md index 64ec33f..3bbd498 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -1509,6 +1509,24 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 命中顺序固定为 `identity_key -> normalized_url -> empty` - `match_reason` 只允许 `identity_key`、`normalized_url` 或 `null` +### `GET /internal/blogs/by-normalized-url?normalized_url=...` + +用途:为 crawler 在遇到重复 raw URL 时解析已存在的目标 blog id。 + +响应: + +```json +{ + "id": 1 +} +``` + +补充说明: + +- 未找到已接受 blog 时返回 `{ "id": null }` +- 该接口不改变 raw URL 去重语义;crawler 仍可把重复 URL 标记为 `rule:duplicate_url`,但会用这里返回的 id 补写新的源博客到目标博客的边 +- 主要用于保留 A->C 已存在后,B 后续发现 C 时的 B->C 关系 + ### `GET /internal/queue/next` 用途:取出下一个待处理 blog,并立即将其状态更新为 `PROCESSING`。 diff --git a/frontend/src/components/GraphVisualization.test.tsx b/frontend/src/components/GraphVisualization.test.tsx index 3cfdfb1..481d357 100644 --- a/frontend/src/components/GraphVisualization.test.tsx +++ b/frontend/src/components/GraphVisualization.test.tsx @@ -319,10 +319,10 @@ describe("GraphVisualization", () => { tuneNaturalClusterForces(graph as never); expect(forceCalls).toContainEqual(["center", null]); - expect(chargeForce.strength).toHaveBeenCalledWith(-95); - expect(chargeForce.distanceMax).toHaveBeenCalledWith(920); + expect(chargeForce.strength).toHaveBeenCalledWith(-118); + expect(chargeForce.distanceMax).toHaveBeenCalledWith(820); expect(linkForce.distance).toHaveBeenCalledWith(72); - expect(linkForce.strength).toHaveBeenCalledWith(0.34); + expect(linkForce.strength).toHaveBeenCalledWith(0.42); expect(d3ReheatSimulation).toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/GraphVisualization.tsx b/frontend/src/components/GraphVisualization.tsx index 47d4809..5f5359f 100644 --- a/frontend/src/components/GraphVisualization.tsx +++ b/frontend/src/components/GraphVisualization.tsx @@ -7,9 +7,9 @@ import type { GraphData, GraphEdge, GraphNode } from "../types/graph"; export const GRAPH_RENDER_COOLDOWN_TICKS = 120; const GRAPH_LINK_DISTANCE = 72; -const GRAPH_LINK_STRENGTH = 0.34; -const GRAPH_CHARGE_STRENGTH = -95; -const GRAPH_CHARGE_DISTANCE_MAX = 920; +const GRAPH_LINK_STRENGTH = 0.42; +const GRAPH_CHARGE_STRENGTH = -118; +const GRAPH_CHARGE_DISTANCE_MAX = 820; interface GraphVisualizationProps { data: GraphData; diff --git a/persistence_api/main.py b/persistence_api/main.py index ad9700d..c2001a5 100644 --- a/persistence_api/main.py +++ b/persistence_api/main.py @@ -567,6 +567,11 @@ def upsert_blog(payload: UpsertBlogRequest) -> dict[str, Any]: blog_id, inserted = get_state().repository.upsert_blog(**payload.model_dump()) return {"id": blog_id, "inserted": inserted} + @app.get("/internal/blogs/by-normalized-url") + def find_blog_by_normalized_url(normalized_url: str) -> dict[str, int | None]: + """Return the existing blog id for one normalized URL.""" + return {"id": get_state().repository.find_blog_id_by_normalized_url(normalized_url=normalized_url)} + @app.post("/internal/blogs/{blog_id}/result") def mark_blog_result(blog_id: int, payload: BlogResultRequest) -> dict[str, bool]: return _run_action_and_return_ok( diff --git a/persistence_api/repository.py b/persistence_api/repository.py index bc3f17b..5b8b48d 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -1713,6 +1713,8 @@ def list_priority_ingestion_requests(self, *, limit: int = INGESTION_PRIORITY_LI def lookup_blog_candidates(self, *, url: str) -> dict[str, Any]: ... + def find_blog_id_by_normalized_url(self, *, normalized_url: str) -> int | None: ... + def mark_ingestion_request_crawling(self, *, blog_id: int) -> None: ... def mark_blog_result( @@ -3196,6 +3198,36 @@ def create_raw_discovered_url_record( session.flush() return {"id": int(record.id), "status": str(record.status)} + def find_blog_id_by_normalized_url(self, *, normalized_url: str) -> int | None: + """Return the persisted blog id for one normalized URL when it exists. + + Args: + normalized_url: Canonical URL value used by crawler discovery and + blog upsert identity checks. + + Returns: + Business ``blog_id`` for the matching blog, or ``None`` when the + URL has not yet been accepted as a blog. + """ + + identity = resolve_blog_identity(normalized_url) + with session_scope(self.session_factory) as session: + blog_id = session.scalar( + select(BlogModel.blog_id) + .where(BlogModel.normalized_url == normalized_url) + .order_by(BlogModel.blog_id.asc(), BlogModel.id.asc()) + .limit(1) + ) + if blog_id is not None: + return int(blog_id) + blog_id = session.scalar( + select(BlogModel.blog_id) + .where(BlogModel.identity_key == identity.identity_key) + .order_by(BlogModel.blog_id.asc(), BlogModel.id.asc()) + .limit(1) + ) + return int(blog_id) if blog_id is not None else None + def update_raw_discovered_url_status( self, *, diff --git a/shared/http_clients/persistence_http.py b/shared/http_clients/persistence_http.py index c5bc533..ef6629f 100644 --- a/shared/http_clients/persistence_http.py +++ b/shared/http_clients/persistence_http.py @@ -257,6 +257,13 @@ def list_priority_ingestion_requests(self) -> list[dict[str, Any]]: def lookup_blog_candidates(self, *, url: str) -> dict[str, Any]: return self._get("/internal/blogs/lookup", {"url": url}) + def find_blog_id_by_normalized_url(self, *, normalized_url: str) -> int | None: + """Fetch the persisted blog id for one normalized URL.""" + + payload = self._get("/internal/blogs/by-normalized-url", {"normalized_url": normalized_url}) + blog_id = payload.get("id") + return int(blog_id) if blog_id is not None else None + def create_blog_dedup_scan_run(self, *, crawler_was_running: bool = False) -> dict[str, Any]: return self._create_maintenance_run( "/internal/blog-dedup-scans/runs", diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 819ec7e..78507d0 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -187,6 +187,77 @@ def test_pipeline_persists_only_valid_friend_links(tmp_path: Path) -> None: assert "depth" not in child_blog +def test_pipeline_persists_edges_for_duplicate_target_urls(tmp_path: Path) -> None: + """Repeated target URL discoveries should still preserve new source edges.""" + pipeline, repository = build_pipeline(tmp_path) + alpha = seed_blog(repository) + beta_id, _ = repository.upsert_blog( + url="https://beta.example/", + normalized_url="https://beta.example/", + domain="beta.example", + ) + beta = repository.get_blog(beta_id) + assert beta is not None + + homepage_html = '

' + alpha_friend_page_html = """ + + """ + beta_friend_page_html = """ + + """ + pipeline.fetcher = FakeFetcher( + { + "https://blog.example.com/": FetchResult( + url="https://blog.example.com/", + status_code=200, + text=homepage_html, + ), + "https://blog.example.com/friends": FetchResult( + url="https://blog.example.com/friends", + status_code=200, + text=alpha_friend_page_html, + ), + "https://beta.example/": FetchResult( + url="https://beta.example/", + status_code=200, + text=homepage_html, + ), + "https://beta.example/friends": FetchResult( + url="https://beta.example/friends", + status_code=200, + text=beta_friend_page_html, + ), + } + ) + + assert pipeline._crawl_blog(alpha) == 1 + assert pipeline._crawl_blog(beta) == 1 + + common_blog = next(blog for blog in repository.list_blogs() if blog["domain"] == "common.example") + edges = repository.list_edges() + assert {(edge["from_blog_id"], edge["to_blog_id"]) for edge in edges} == { + (alpha["blog_id"], common_blog["id"]), + (beta["blog_id"], common_blog["id"]), + } + + with session_scope(repository.session_factory) as session: + raw_rows = [ + (row.source_blog_id, row.normalized_url, row.status) + for row in session.scalars(select(RawDiscoveredUrlModel).order_by(RawDiscoveredUrlModel.id.asc())) + ] + + assert raw_rows == [ + (alpha["blog_id"], "https://common.example/", "success"), + (beta["blog_id"], "https://common.example/", "rule:duplicate_url"), + ] + + def test_pipeline_stores_feed_url_when_friend_link_exposes_rss(tmp_path: Path) -> None: """A friend link whose homepage exposes a valid feed should persist its feed URL.""" pipeline, repository = build_pipeline(tmp_path) diff --git a/tests/test_repository.py b/tests/test_repository.py index e11dd0f..ec3ee3a 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -513,6 +513,31 @@ def test_repository_marks_duplicate_raw_urls_before_filter_chain(tmp_path: Path) assert first["id"] < duplicate["id"] +def test_repository_finds_blog_id_by_normalized_url(tmp_path: Path) -> None: + """Duplicate discovery repair should resolve accepted target blogs by URL.""" + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + blog_id, _ = repository.upsert_blog( + url="https://friend.example/", + normalized_url="https://friend.example/", + domain="friend.example", + ) + + assert repository.find_blog_id_by_normalized_url(normalized_url="https://friend.example/") == blog_id + assert repository.find_blog_id_by_normalized_url(normalized_url="https://missing.example/") is None + + +def test_repository_finds_blog_id_by_normalized_url_identity_fallback(tmp_path: Path) -> None: + """Duplicate edge repair should survive blog identity canonicalization.""" + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + blog_id, _ = repository.upsert_blog( + url="https://zhuruilei.66law.cn/", + normalized_url="https://zhuruilei.66law.cn/", + domain="zhuruilei.66law.cn", + ) + + assert repository.find_blog_id_by_normalized_url(normalized_url="https://zhuruilei.66law.cn/") == blog_id + + def test_retired_label_assignment_migration_reports_single_table_rows(tmp_path: Path) -> None: """Retired label-assignment migration should leave the single label table intact.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") From ed9d044b408f01c5a98db0d25b0e5c4770671dde Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Fri, 5 Jun 2026 20:21:31 +0100 Subject: [PATCH 07/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BA=86=E5=8F=AF=E8=A7=86=E5=8C=96=E5=9B=BE=E8=B0=B1=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../benchmarks/blog-community-graph.json | 4529 +++++++++++++++++ frontend/src/App.test.tsx | 61 + frontend/src/App.tsx | 1 + .../components/GraphVisualization.test.tsx | 25 +- .../src/components/GraphVisualization.tsx | 32 +- frontend/src/lib/benchmarkGraph.ts | 128 + frontend/src/pages/AboutPage.tsx | 6 +- frontend/src/pages/VisualizationPage.tsx | 59 +- frontend/src/types/graph.ts | 1 + graph-icons-debug.png | Bin 144868 -> 0 bytes scripts/generate_visualization_benchmark.py | 343 ++ scripts/run_visualization_benchmark.sh | 18 + tests/test_visualization_benchmark.py | 34 + 13 files changed, 5212 insertions(+), 25 deletions(-) create mode 100644 frontend/public/benchmarks/blog-community-graph.json create mode 100644 frontend/src/lib/benchmarkGraph.ts delete mode 100644 graph-icons-debug.png create mode 100644 scripts/generate_visualization_benchmark.py create mode 100755 scripts/run_visualization_benchmark.sh create mode 100644 tests/test_visualization_benchmark.py diff --git a/frontend/public/benchmarks/blog-community-graph.json b/frontend/public/benchmarks/blog-community-graph.json new file mode 100644 index 0000000..fe0059d --- /dev/null +++ b/frontend/public/benchmarks/blog-community-graph.json @@ -0,0 +1,4529 @@ +{ + "nodes": [ + { + "id": 1, + "url": "https://benchmark.heyblog.local/indie-web-01/", + "domain": "indie-web-01.benchmark.heyblog.local", + "title": "Indie Web Notes 01", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 7, + "degree": 13, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -485.248, + "y": -260.0, + "z": -27.653 + }, + { + "id": 2, + "url": "https://benchmark.heyblog.local/indie-web-02/", + "domain": "indie-web-02.benchmark.heyblog.local", + "title": "Indie Web Notes 02", + "icon_url": null, + "incoming_count": 7, + "outgoing_count": 5, + "degree": 12, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -561.694, + "y": -221.805, + "z": 9.57 + }, + { + "id": 3, + "url": "https://benchmark.heyblog.local/indie-web-03/", + "domain": "indie-web-03.benchmark.heyblog.local", + "title": "Indie Web Notes 03", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 4, + "degree": 9, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -514.041, + "y": -327.899, + "z": -4.206 + }, + { + "id": 4, + "url": "https://benchmark.heyblog.local/indie-web-04/", + "domain": "indie-web-04.benchmark.heyblog.local", + "title": "Indie Web Notes 04", + "icon_url": null, + "incoming_count": 9, + "outgoing_count": 5, + "degree": 14, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -471.039, + "y": -196.138, + "z": 18.91 + }, + { + "id": 5, + "url": "https://benchmark.heyblog.local/indie-web-05/", + "domain": "indie-web-05.benchmark.heyblog.local", + "title": "Indie Web Notes 05", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -626.876, + "y": -278.905, + "z": -4.065 + }, + { + "id": 6, + "url": "https://benchmark.heyblog.local/indie-web-06/", + "domain": "indie-web-06.benchmark.heyblog.local", + "title": "Indie Web Notes 06", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 6, + "degree": 12, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -482.029, + "y": -284.154, + "z": -23.273 + }, + { + "id": 7, + "url": "https://benchmark.heyblog.local/indie-web-07/", + "domain": "indie-web-07.benchmark.heyblog.local", + "title": "Indie Web Notes 07", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -534.637, + "y": -205.551, + "z": -7.773 + }, + { + "id": 8, + "url": "https://benchmark.heyblog.local/indie-web-08/", + "domain": "indie-web-08.benchmark.heyblog.local", + "title": "Indie Web Notes 08", + "icon_url": null, + "incoming_count": 8, + "outgoing_count": 4, + "degree": 12, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -554.081, + "y": -325.621, + "z": -1.666 + }, + { + "id": 9, + "url": "https://benchmark.heyblog.local/indie-web-09/", + "domain": "indie-web-09.benchmark.heyblog.local", + "title": "Indie Web Notes 09", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -432.629, + "y": -228.092, + "z": -5.715 + }, + { + "id": 10, + "url": "https://benchmark.heyblog.local/indie-web-10/", + "domain": "indie-web-10.benchmark.heyblog.local", + "title": "Indie Web Notes 10", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 7, + "degree": 10, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -614.579, + "y": -220.959, + "z": -15.78 + }, + { + "id": 11, + "url": "https://benchmark.heyblog.local/indie-web-11/", + "domain": "indie-web-11.benchmark.heyblog.local", + "title": "Indie Web Notes 11", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 6, + "degree": 11, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -504.745, + "y": -292.6, + "z": -23.746 + }, + { + "id": 12, + "url": "https://benchmark.heyblog.local/indie-web-12/", + "domain": "indie-web-12.benchmark.heyblog.local", + "title": "Indie Web Notes 12", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 4, + "degree": 7, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -502.776, + "y": -205.087, + "z": -2.887 + }, + { + "id": 13, + "url": "https://benchmark.heyblog.local/indie-web-13/", + "domain": "indie-web-13.benchmark.heyblog.local", + "title": "Indie Web Notes 13", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 5, + "degree": 10, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -585.047, + "y": -297.696, + "z": 35.821 + }, + { + "id": 14, + "url": "https://benchmark.heyblog.local/indie-web-14/", + "domain": "indie-web-14.benchmark.heyblog.local", + "title": "Indie Web Notes 14", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 5, + "degree": 9, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -438.853, + "y": -277.84, + "z": -26.944 + }, + { + "id": 15, + "url": "https://benchmark.heyblog.local/indie-web-15/", + "domain": "indie-web-15.benchmark.heyblog.local", + "title": "Indie Web Notes 15", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 7, + "degree": 11, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -578.153, + "y": -177.283, + "z": 1.251 + }, + { + "id": 16, + "url": "https://benchmark.heyblog.local/indie-web-16/", + "domain": "indie-web-16.benchmark.heyblog.local", + "title": "Indie Web Notes 16", + "icon_url": null, + "incoming_count": 1, + "outgoing_count": 5, + "degree": 6, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -526.023, + "y": -306.476, + "z": 1.643 + }, + { + "id": 17, + "url": "https://benchmark.heyblog.local/indie-web-17/", + "domain": "indie-web-17.benchmark.heyblog.local", + "title": "Indie Web Notes 17", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 5, + "degree": 10, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -470.825, + "y": -218.555, + "z": -34.816 + }, + { + "id": 18, + "url": "https://benchmark.heyblog.local/indie-web-18/", + "domain": "indie-web-18.benchmark.heyblog.local", + "title": "Indie Web Notes 18", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 5, + "degree": 11, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -591.377, + "y": -257.048, + "z": 28.352 + }, + { + "id": 19, + "url": "https://benchmark.heyblog.local/indie-web-19/", + "domain": "indie-web-19.benchmark.heyblog.local", + "title": "Indie Web Notes 19", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 6, + "degree": 9, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -463.993, + "y": -315.734, + "z": -7.653 + }, + { + "id": 20, + "url": "https://benchmark.heyblog.local/indie-web-20/", + "domain": "indie-web-20.benchmark.heyblog.local", + "title": "Indie Web Notes 20", + "icon_url": null, + "incoming_count": 7, + "outgoing_count": 2, + "degree": 9, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -524.976, + "y": -152.383, + "z": -23.053 + }, + { + "id": 21, + "url": "https://benchmark.heyblog.local/indie-web-21/", + "domain": "indie-web-21.benchmark.heyblog.local", + "title": "Indie Web Notes 21", + "icon_url": null, + "incoming_count": 9, + "outgoing_count": 4, + "degree": 13, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -543.562, + "y": -288.235, + "z": 3.581 + }, + { + "id": 22, + "url": "https://benchmark.heyblog.local/indie-web-22/", + "domain": "indie-web-22.benchmark.heyblog.local", + "title": "Indie Web Notes 22", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 8, + "degree": 11, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -471.124, + "y": -253.424, + "z": 30.062 + }, + { + "id": 23, + "url": "https://benchmark.heyblog.local/indie-web-23/", + "domain": "indie-web-23.benchmark.heyblog.local", + "title": "Indie Web Notes 23", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -579.691, + "y": -218.469, + "z": 20.868 + }, + { + "id": 24, + "url": "https://benchmark.heyblog.local/indie-web-24/", + "domain": "indie-web-24.benchmark.heyblog.local", + "title": "Indie Web Notes 24", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 5, + "degree": 11, + "component_id": "indie-web", + "benchmark_community_label": "Indie Web", + "x": -502.561, + "y": -337.519, + "z": -32.523 + }, + { + "id": 25, + "url": "https://benchmark.heyblog.local/engineering-01/", + "domain": "engineering-01.benchmark.heyblog.local", + "title": "Engineering Notes 01", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 7, + "degree": 9, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 573.139, + "y": -167.265, + "z": -2.006 + }, + { + "id": 26, + "url": "https://benchmark.heyblog.local/engineering-02/", + "domain": "engineering-02.benchmark.heyblog.local", + "title": "Engineering Notes 02", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 1, + "degree": 5, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 472.691, + "y": -275.093, + "z": 28.025 + }, + { + "id": 27, + "url": "https://benchmark.heyblog.local/engineering-03/", + "domain": "engineering-03.benchmark.heyblog.local", + "title": "Engineering Notes 03", + "icon_url": null, + "incoming_count": 8, + "outgoing_count": 4, + "degree": 12, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 570.488, + "y": -283.327, + "z": 16.877 + }, + { + "id": 28, + "url": "https://benchmark.heyblog.local/engineering-04/", + "domain": "engineering-04.benchmark.heyblog.local", + "title": "Engineering Notes 04", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 3, + "degree": 8, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 490.999, + "y": -190.703, + "z": 12.287 + }, + { + "id": 29, + "url": "https://benchmark.heyblog.local/engineering-05/", + "domain": "engineering-05.benchmark.heyblog.local", + "title": "Engineering Notes 05", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 6, + "degree": 10, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 492.317, + "y": -336.967, + "z": 27.342 + }, + { + "id": 30, + "url": "https://benchmark.heyblog.local/engineering-06/", + "domain": "engineering-06.benchmark.heyblog.local", + "title": "Engineering Notes 06", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 4, + "degree": 7, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 614.342, + "y": -210.416, + "z": -20.321 + }, + { + "id": 31, + "url": "https://benchmark.heyblog.local/engineering-07/", + "domain": "engineering-07.benchmark.heyblog.local", + "title": "Engineering Notes 07", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 3, + "degree": 9, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 485.672, + "y": -250.951, + "z": 12.808 + }, + { + "id": 32, + "url": "https://benchmark.heyblog.local/engineering-08/", + "domain": "engineering-08.benchmark.heyblog.local", + "title": "Engineering Notes 08", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 6, + "degree": 10, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 553.668, + "y": -312.362, + "z": -12.726 + }, + { + "id": 33, + "url": "https://benchmark.heyblog.local/engineering-09/", + "domain": "engineering-09.benchmark.heyblog.local", + "title": "Engineering Notes 09", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 532.13, + "y": -189.422, + "z": 13.888 + }, + { + "id": 34, + "url": "https://benchmark.heyblog.local/engineering-10/", + "domain": "engineering-10.benchmark.heyblog.local", + "title": "Engineering Notes 10", + "icon_url": null, + "incoming_count": 8, + "outgoing_count": 3, + "degree": 11, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 446.316, + "y": -317.065, + "z": -8.99 + }, + { + "id": 35, + "url": "https://benchmark.heyblog.local/engineering-11/", + "domain": "engineering-11.benchmark.heyblog.local", + "title": "Engineering Notes 11", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 3, + "degree": 9, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 628.742, + "y": -269.009, + "z": 17.532 + }, + { + "id": 36, + "url": "https://benchmark.heyblog.local/engineering-12/", + "domain": "engineering-12.benchmark.heyblog.local", + "title": "Engineering Notes 12", + "icon_url": null, + "incoming_count": 8, + "outgoing_count": 8, + "degree": 16, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 492.373, + "y": -230.136, + "z": 30.217 + }, + { + "id": 37, + "url": "https://benchmark.heyblog.local/engineering-13/", + "domain": "engineering-13.benchmark.heyblog.local", + "title": "Engineering Notes 13", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 520.305, + "y": -322.624, + "z": -27.247 + }, + { + "id": 38, + "url": "https://benchmark.heyblog.local/engineering-14/", + "domain": "engineering-14.benchmark.heyblog.local", + "title": "Engineering Notes 14", + "icon_url": null, + "incoming_count": 1, + "outgoing_count": 4, + "degree": 5, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 565.794, + "y": -209.519, + "z": -18.772 + }, + { + "id": 39, + "url": "https://benchmark.heyblog.local/engineering-15/", + "domain": "engineering-15.benchmark.heyblog.local", + "title": "Engineering Notes 15", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 7, + "degree": 12, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 431.124, + "y": -268.237, + "z": 9.782 + }, + { + "id": 40, + "url": "https://benchmark.heyblog.local/engineering-16/", + "domain": "engineering-16.benchmark.heyblog.local", + "title": "Engineering Notes 16", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 6, + "degree": 9, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 595.39, + "y": -317.218, + "z": 6.472 + }, + { + "id": 41, + "url": "https://benchmark.heyblog.local/engineering-17/", + "domain": "engineering-17.benchmark.heyblog.local", + "title": "Engineering Notes 17", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 511.479, + "y": -213.161, + "z": -23.505 + }, + { + "id": 42, + "url": "https://benchmark.heyblog.local/engineering-18/", + "domain": "engineering-18.benchmark.heyblog.local", + "title": "Engineering Notes 18", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 5, + "degree": 7, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 486.51, + "y": -313.218, + "z": 21.707 + }, + { + "id": 43, + "url": "https://benchmark.heyblog.local/engineering-19/", + "domain": "engineering-19.benchmark.heyblog.local", + "title": "Engineering Notes 19", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 6, + "degree": 11, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 591.907, + "y": -240.293, + "z": 32.612 + }, + { + "id": 44, + "url": "https://benchmark.heyblog.local/engineering-20/", + "domain": "engineering-20.benchmark.heyblog.local", + "title": "Engineering Notes 20", + "icon_url": null, + "incoming_count": 8, + "outgoing_count": 7, + "degree": 15, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 443.241, + "y": -220.609, + "z": -6.126 + }, + { + "id": 45, + "url": "https://benchmark.heyblog.local/engineering-21/", + "domain": "engineering-21.benchmark.heyblog.local", + "title": "Engineering Notes 21", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 7, + "degree": 11, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 554.187, + "y": -352.213, + "z": -23.958 + }, + { + "id": 46, + "url": "https://benchmark.heyblog.local/engineering-22/", + "domain": "engineering-22.benchmark.heyblog.local", + "title": "Engineering Notes 22", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 4, + "degree": 9, + "component_id": "engineering", + "benchmark_community_label": "Engineering", + "x": 536.488, + "y": -219.498, + "z": -10.669 + }, + { + "id": 47, + "url": "https://benchmark.heyblog.local/design-01/", + "domain": "design-01.benchmark.heyblog.local", + "title": "Design Notes 01", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 2, + "degree": 5, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -570.698, + "y": 275.973, + "z": -15.539 + }, + { + "id": 48, + "url": "https://benchmark.heyblog.local/design-02/", + "domain": "design-02.benchmark.heyblog.local", + "title": "Design Notes 02", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 2, + "degree": 6, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -453.52, + "y": 279.503, + "z": -12.521 + }, + { + "id": 49, + "url": "https://benchmark.heyblog.local/design-03/", + "domain": "design-03.benchmark.heyblog.local", + "title": "Design Notes 03", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -561.402, + "y": 370.645, + "z": -6.008 + }, + { + "id": 50, + "url": "https://benchmark.heyblog.local/design-04/", + "domain": "design-04.benchmark.heyblog.local", + "title": "Design Notes 04", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 3, + "degree": 5, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -540.85, + "y": 202.907, + "z": 9.596 + }, + { + "id": 51, + "url": "https://benchmark.heyblog.local/design-05/", + "domain": "design-05.benchmark.heyblog.local", + "title": "Design Notes 05", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 3, + "degree": 5, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -482.696, + "y": 326.499, + "z": 10.269 + }, + { + "id": 52, + "url": "https://benchmark.heyblog.local/design-06/", + "domain": "design-06.benchmark.heyblog.local", + "title": "Design Notes 06", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 5, + "degree": 7, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -574.155, + "y": 306.749, + "z": -2.115 + }, + { + "id": 53, + "url": "https://benchmark.heyblog.local/design-07/", + "domain": "design-07.benchmark.heyblog.local", + "title": "Design Notes 07", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 3, + "degree": 7, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -471.145, + "y": 242.603, + "z": -1.46 + }, + { + "id": 54, + "url": "https://benchmark.heyblog.local/design-08/", + "domain": "design-08.benchmark.heyblog.local", + "title": "Design Notes 08", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 4, + "degree": 6, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -516.807, + "y": 387.548, + "z": 34.33 + }, + { + "id": 55, + "url": "https://benchmark.heyblog.local/design-09/", + "domain": "design-09.benchmark.heyblog.local", + "title": "Design Notes 09", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 2, + "degree": 5, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -586.781, + "y": 232.235, + "z": -21.918 + }, + { + "id": 56, + "url": "https://benchmark.heyblog.local/design-10/", + "domain": "design-10.benchmark.heyblog.local", + "title": "Design Notes 10", + "icon_url": null, + "incoming_count": 1, + "outgoing_count": 3, + "degree": 4, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -482.005, + "y": 301.943, + "z": 4.554 + }, + { + "id": 57, + "url": "https://benchmark.heyblog.local/design-11/", + "domain": "design-11.benchmark.heyblog.local", + "title": "Design Notes 11", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -560.363, + "y": 333.35, + "z": 29.597 + }, + { + "id": 58, + "url": "https://benchmark.heyblog.local/design-12/", + "domain": "design-12.benchmark.heyblog.local", + "title": "Design Notes 12", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 2, + "degree": 6, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -509.899, + "y": 227.603, + "z": -3.746 + }, + { + "id": 59, + "url": "https://benchmark.heyblog.local/design-13/", + "domain": "design-13.benchmark.heyblog.local", + "title": "Design Notes 13", + "icon_url": null, + "incoming_count": 7, + "outgoing_count": 4, + "degree": 11, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -474.537, + "y": 366.026, + "z": -35.333 + }, + { + "id": 60, + "url": "https://benchmark.heyblog.local/design-14/", + "domain": "design-14.benchmark.heyblog.local", + "title": "Design Notes 14", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 7, + "degree": 9, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -614.325, + "y": 278.296, + "z": 32.354 + }, + { + "id": 61, + "url": "https://benchmark.heyblog.local/design-15/", + "domain": "design-15.benchmark.heyblog.local", + "title": "Design Notes 15", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -485.328, + "y": 280.356, + "z": 27.902 + }, + { + "id": 62, + "url": "https://benchmark.heyblog.local/design-16/", + "domain": "design-16.benchmark.heyblog.local", + "title": "Design Notes 16", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 3, + "degree": 9, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -537.501, + "y": 353.946, + "z": 8.124 + }, + { + "id": 63, + "url": "https://benchmark.heyblog.local/design-17/", + "domain": "design-17.benchmark.heyblog.local", + "title": "Design Notes 17", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 3, + "degree": 9, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -551.293, + "y": 231.393, + "z": 31.6 + }, + { + "id": 64, + "url": "https://benchmark.heyblog.local/design-18/", + "domain": "design-18.benchmark.heyblog.local", + "title": "Design Notes 18", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 4, + "degree": 6, + "component_id": "design", + "benchmark_community_label": "Design", + "x": -441.014, + "y": 333.51, + "z": 15.436 + }, + { + "id": 65, + "url": "https://benchmark.heyblog.local/data-ai-01/", + "domain": "data-ai-01.benchmark.heyblog.local", + "title": "Data & AI Notes 01", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 5, + "degree": 11, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 422.243, + "y": 334.624, + "z": -11.591 + }, + { + "id": 66, + "url": "https://benchmark.heyblog.local/data-ai-02/", + "domain": "data-ai-02.benchmark.heyblog.local", + "title": "Data & AI Notes 02", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 5, + "degree": 10, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 536.171, + "y": 269.594, + "z": 1.955 + }, + { + "id": 67, + "url": "https://benchmark.heyblog.local/data-ai-03/", + "domain": "data-ai-03.benchmark.heyblog.local", + "title": "Data & AI Notes 03", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 6, + "degree": 9, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 533.193, + "y": 351.058, + "z": 9.399 + }, + { + "id": 68, + "url": "https://benchmark.heyblog.local/data-ai-04/", + "domain": "data-ai-04.benchmark.heyblog.local", + "title": "Data & AI Notes 04", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 6, + "degree": 10, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 458.789, + "y": 260.219, + "z": -29.844 + }, + { + "id": 69, + "url": "https://benchmark.heyblog.local/data-ai-05/", + "domain": "data-ai-05.benchmark.heyblog.local", + "title": "Data & AI Notes 05", + "icon_url": null, + "incoming_count": 9, + "outgoing_count": 6, + "degree": 15, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 611.447, + "y": 284.743, + "z": -20.898 + }, + { + "id": 70, + "url": "https://benchmark.heyblog.local/data-ai-06/", + "domain": "data-ai-06.benchmark.heyblog.local", + "title": "Data & AI Notes 06", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 7, + "degree": 11, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 461.87, + "y": 374.307, + "z": -11.953 + }, + { + "id": 71, + "url": "https://benchmark.heyblog.local/data-ai-07/", + "domain": "data-ai-07.benchmark.heyblog.local", + "title": "Data & AI Notes 07", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 4, + "degree": 8, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 516.969, + "y": 261.116, + "z": 6.55 + }, + { + "id": 72, + "url": "https://benchmark.heyblog.local/data-ai-08/", + "domain": "data-ai-08.benchmark.heyblog.local", + "title": "Data & AI Notes 08", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 4, + "degree": 7, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 567.287, + "y": 344.175, + "z": -24.876 + }, + { + "id": 73, + "url": "https://benchmark.heyblog.local/data-ai-09/", + "domain": "data-ai-09.benchmark.heyblog.local", + "title": "Data & AI Notes 09", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 3, + "degree": 7, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 451.144, + "y": 299.328, + "z": 33.496 + }, + { + "id": 74, + "url": "https://benchmark.heyblog.local/data-ai-10/", + "domain": "data-ai-10.benchmark.heyblog.local", + "title": "Data & AI Notes 10", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 5, + "degree": 10, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 588.21, + "y": 238.727, + "z": -15.243 + }, + { + "id": 75, + "url": "https://benchmark.heyblog.local/data-ai-11/", + "domain": "data-ai-11.benchmark.heyblog.local", + "title": "Data & AI Notes 11", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 2, + "degree": 7, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 510.386, + "y": 398.503, + "z": 17.685 + }, + { + "id": 76, + "url": "https://benchmark.heyblog.local/data-ai-12/", + "domain": "data-ai-12.benchmark.heyblog.local", + "title": "Data & AI Notes 12", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 3, + "degree": 7, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 497.126, + "y": 269.555, + "z": -14.242 + }, + { + "id": 77, + "url": "https://benchmark.heyblog.local/data-ai-13/", + "domain": "data-ai-13.benchmark.heyblog.local", + "title": "Data & AI Notes 13", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 4, + "degree": 9, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 572.02, + "y": 309.726, + "z": 23.079 + }, + { + "id": 78, + "url": "https://benchmark.heyblog.local/data-ai-14/", + "domain": "data-ai-14.benchmark.heyblog.local", + "title": "Data & AI Notes 14", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 5, + "degree": 9, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 456.631, + "y": 339.447, + "z": -4.464 + }, + { + "id": 79, + "url": "https://benchmark.heyblog.local/data-ai-15/", + "domain": "data-ai-15.benchmark.heyblog.local", + "title": "Data & AI Notes 15", + "icon_url": null, + "incoming_count": 7, + "outgoing_count": 5, + "degree": 12, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 541.957, + "y": 221.388, + "z": -13.295 + }, + { + "id": 80, + "url": "https://benchmark.heyblog.local/data-ai-16/", + "domain": "data-ai-16.benchmark.heyblog.local", + "title": "Data & AI Notes 16", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 4, + "degree": 7, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 567.816, + "y": 394.305, + "z": -35.145 + }, + { + "id": 81, + "url": "https://benchmark.heyblog.local/data-ai-17/", + "domain": "data-ai-17.benchmark.heyblog.local", + "title": "Data & AI Notes 17", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 5, + "degree": 11, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 479.297, + "y": 284.684, + "z": -0.06 + }, + { + "id": 82, + "url": "https://benchmark.heyblog.local/data-ai-18/", + "domain": "data-ai-18.benchmark.heyblog.local", + "title": "Data & AI Notes 18", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 3, + "degree": 6, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 568.43, + "y": 280.559, + "z": 3.997 + }, + { + "id": 83, + "url": "https://benchmark.heyblog.local/data-ai-19/", + "domain": "data-ai-19.benchmark.heyblog.local", + "title": "Data & AI Notes 19", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 6, + "degree": 10, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 487.855, + "y": 366.981, + "z": 33.056 + }, + { + "id": 84, + "url": "https://benchmark.heyblog.local/data-ai-20/", + "domain": "data-ai-20.benchmark.heyblog.local", + "title": "Data & AI Notes 20", + "icon_url": null, + "incoming_count": 6, + "outgoing_count": 3, + "degree": 9, + "component_id": "data-ai", + "benchmark_community_label": "Data & AI", + "x": 493.239, + "y": 211.673, + "z": 14.359 + }, + { + "id": 85, + "url": "https://benchmark.heyblog.local/culture-01/", + "domain": "culture-01.benchmark.heyblog.local", + "title": "Culture Notes 01", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 4, + "degree": 7, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": 89.757, + "y": 93.193, + "z": 513.975 + }, + { + "id": 86, + "url": "https://benchmark.heyblog.local/culture-02/", + "domain": "culture-02.benchmark.heyblog.local", + "title": "Culture Notes 02", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 2, + "degree": 5, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": -40.174, + "y": 48.422, + "z": 530.82 + }, + { + "id": 87, + "url": "https://benchmark.heyblog.local/culture-03/", + "domain": "culture-03.benchmark.heyblog.local", + "title": "Culture Notes 03", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 3, + "degree": 6, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": 31.055, + "y": -3.269, + "z": 521.511 + }, + { + "id": 88, + "url": "https://benchmark.heyblog.local/culture-04/", + "domain": "culture-04.benchmark.heyblog.local", + "title": "Culture Notes 04", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 1, + "degree": 3, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": 8.33, + "y": 109.602, + "z": 535.363 + }, + { + "id": 89, + "url": "https://benchmark.heyblog.local/culture-05/", + "domain": "culture-05.benchmark.heyblog.local", + "title": "Culture Notes 05", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 3, + "degree": 7, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": -64.94, + "y": -15.824, + "z": 551.158 + }, + { + "id": 90, + "url": "https://benchmark.heyblog.local/culture-06/", + "domain": "culture-06.benchmark.heyblog.local", + "title": "Culture Notes 06", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 2, + "degree": 5, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": 94.81, + "y": 37.006, + "z": 545.355 + }, + { + "id": 91, + "url": "https://benchmark.heyblog.local/culture-07/", + "domain": "culture-07.benchmark.heyblog.local", + "title": "Culture Notes 07", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 3, + "degree": 7, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": -26.072, + "y": 65.444, + "z": 494.282 + }, + { + "id": 92, + "url": "https://benchmark.heyblog.local/culture-08/", + "domain": "culture-08.benchmark.heyblog.local", + "title": "Culture Notes 08", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 4, + "degree": 7, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": 3.275, + "y": -18.463, + "z": 497.011 + }, + { + "id": 93, + "url": "https://benchmark.heyblog.local/culture-09/", + "domain": "culture-09.benchmark.heyblog.local", + "title": "Culture Notes 09", + "icon_url": null, + "incoming_count": 4, + "outgoing_count": 3, + "degree": 7, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": 42.82, + "y": 92.343, + "z": 505.261 + }, + { + "id": 94, + "url": "https://benchmark.heyblog.local/culture-10/", + "domain": "culture-10.benchmark.heyblog.local", + "title": "Culture Notes 10", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 3, + "degree": 6, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": -91.729, + "y": 26.746, + "z": 492.576 + }, + { + "id": 95, + "url": "https://benchmark.heyblog.local/culture-11/", + "domain": "culture-11.benchmark.heyblog.local", + "title": "Culture Notes 11", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 5, + "degree": 7, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": 80.365, + "y": -14.76, + "z": 554.686 + }, + { + "id": 96, + "url": "https://benchmark.heyblog.local/culture-12/", + "domain": "culture-12.benchmark.heyblog.local", + "title": "Culture Notes 12", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 5, + "degree": 8, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": -8.027, + "y": 74.124, + "z": 509.271 + }, + { + "id": 97, + "url": "https://benchmark.heyblog.local/culture-13/", + "domain": "culture-13.benchmark.heyblog.local", + "title": "Culture Notes 13", + "icon_url": null, + "incoming_count": 5, + "outgoing_count": 1, + "degree": 6, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": -25.844, + "y": -6.138, + "z": 511.06 + }, + { + "id": 98, + "url": "https://benchmark.heyblog.local/culture-14/", + "domain": "culture-14.benchmark.heyblog.local", + "title": "Culture Notes 14", + "icon_url": null, + "incoming_count": 3, + "outgoing_count": 2, + "degree": 5, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": 70.956, + "y": 63.402, + "z": 532.942 + }, + { + "id": 99, + "url": "https://benchmark.heyblog.local/culture-15/", + "domain": "culture-15.benchmark.heyblog.local", + "title": "Culture Notes 15", + "icon_url": null, + "incoming_count": 1, + "outgoing_count": 4, + "degree": 5, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": -82.37, + "y": 77.086, + "z": 554.04 + }, + { + "id": 100, + "url": "https://benchmark.heyblog.local/culture-16/", + "domain": "culture-16.benchmark.heyblog.local", + "title": "Culture Notes 16", + "icon_url": null, + "incoming_count": 2, + "outgoing_count": 3, + "degree": 5, + "component_id": "culture", + "benchmark_community_label": "Culture", + "x": 37.575, + "y": -47.38, + "z": 518.86 + } + ], + "edges": [ + { + "from_blog_id": 1, + "to_blog_id": 2, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-02/", + "id": "benchmark-edge-001" + }, + { + "from_blog_id": 1, + "to_blog_id": 3, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-03/", + "id": "benchmark-edge-002" + }, + { + "from_blog_id": 1, + "to_blog_id": 7, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-07/", + "id": "benchmark-edge-003" + }, + { + "from_blog_id": 1, + "to_blog_id": 8, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-08/", + "id": "benchmark-edge-004" + }, + { + "from_blog_id": 1, + "to_blog_id": 10, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-10/", + "id": "benchmark-edge-005" + }, + { + "from_blog_id": 1, + "to_blog_id": 19, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-19/", + "id": "benchmark-edge-006" + }, + { + "from_blog_id": 1, + "to_blog_id": 79, + "link_text": "community bridge", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-15/", + "id": "benchmark-edge-007" + }, + { + "from_blog_id": 2, + "to_blog_id": 3, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-03/", + "id": "benchmark-edge-008" + }, + { + "from_blog_id": 2, + "to_blog_id": 14, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-14/", + "id": "benchmark-edge-009" + }, + { + "from_blog_id": 2, + "to_blog_id": 18, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-18/", + "id": "benchmark-edge-010" + }, + { + "from_blog_id": 2, + "to_blog_id": 21, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-21/", + "id": "benchmark-edge-011" + }, + { + "from_blog_id": 2, + "to_blog_id": 24, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-24/", + "id": "benchmark-edge-012" + }, + { + "from_blog_id": 3, + "to_blog_id": 4, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-04/", + "id": "benchmark-edge-013" + }, + { + "from_blog_id": 3, + "to_blog_id": 5, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-05/", + "id": "benchmark-edge-014" + }, + { + "from_blog_id": 3, + "to_blog_id": 14, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-14/", + "id": "benchmark-edge-015" + }, + { + "from_blog_id": 3, + "to_blog_id": 17, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-17/", + "id": "benchmark-edge-016" + }, + { + "from_blog_id": 4, + "to_blog_id": 1, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-01/", + "id": "benchmark-edge-017" + }, + { + "from_blog_id": 4, + "to_blog_id": 5, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-05/", + "id": "benchmark-edge-018" + }, + { + "from_blog_id": 4, + "to_blog_id": 20, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-20/", + "id": "benchmark-edge-019" + }, + { + "from_blog_id": 4, + "to_blog_id": 21, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-21/", + "id": "benchmark-edge-020" + }, + { + "from_blog_id": 4, + "to_blog_id": 69, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-05/", + "id": "benchmark-edge-021" + }, + { + "from_blog_id": 5, + "to_blog_id": 2, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-02/", + "id": "benchmark-edge-022" + }, + { + "from_blog_id": 5, + "to_blog_id": 6, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-06/", + "id": "benchmark-edge-023" + }, + { + "from_blog_id": 5, + "to_blog_id": 8, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-08/", + "id": "benchmark-edge-024" + }, + { + "from_blog_id": 5, + "to_blog_id": 11, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-11/", + "id": "benchmark-edge-025" + }, + { + "from_blog_id": 6, + "to_blog_id": 2, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-02/", + "id": "benchmark-edge-026" + }, + { + "from_blog_id": 6, + "to_blog_id": 5, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-05/", + "id": "benchmark-edge-027" + }, + { + "from_blog_id": 6, + "to_blog_id": 7, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-07/", + "id": "benchmark-edge-028" + }, + { + "from_blog_id": 6, + "to_blog_id": 8, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-08/", + "id": "benchmark-edge-029" + }, + { + "from_blog_id": 6, + "to_blog_id": 9, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-09/", + "id": "benchmark-edge-030" + }, + { + "from_blog_id": 6, + "to_blog_id": 18, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-18/", + "id": "benchmark-edge-031" + }, + { + "from_blog_id": 7, + "to_blog_id": 8, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-08/", + "id": "benchmark-edge-032" + }, + { + "from_blog_id": 7, + "to_blog_id": 12, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-12/", + "id": "benchmark-edge-033" + }, + { + "from_blog_id": 7, + "to_blog_id": 13, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-13/", + "id": "benchmark-edge-034" + }, + { + "from_blog_id": 7, + "to_blog_id": 14, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-14/", + "id": "benchmark-edge-035" + }, + { + "from_blog_id": 8, + "to_blog_id": 2, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-02/", + "id": "benchmark-edge-036" + }, + { + "from_blog_id": 8, + "to_blog_id": 9, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-09/", + "id": "benchmark-edge-037" + }, + { + "from_blog_id": 8, + "to_blog_id": 10, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-10/", + "id": "benchmark-edge-038" + }, + { + "from_blog_id": 8, + "to_blog_id": 18, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-18/", + "id": "benchmark-edge-039" + }, + { + "from_blog_id": 9, + "to_blog_id": 4, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-04/", + "id": "benchmark-edge-040" + }, + { + "from_blog_id": 9, + "to_blog_id": 5, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-05/", + "id": "benchmark-edge-041" + }, + { + "from_blog_id": 9, + "to_blog_id": 10, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-10/", + "id": "benchmark-edge-042" + }, + { + "from_blog_id": 9, + "to_blog_id": 22, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-22/", + "id": "benchmark-edge-043" + }, + { + "from_blog_id": 10, + "to_blog_id": 2, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-02/", + "id": "benchmark-edge-044" + }, + { + "from_blog_id": 10, + "to_blog_id": 3, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-03/", + "id": "benchmark-edge-045" + }, + { + "from_blog_id": 10, + "to_blog_id": 4, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-04/", + "id": "benchmark-edge-046" + }, + { + "from_blog_id": 10, + "to_blog_id": 11, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-11/", + "id": "benchmark-edge-047" + }, + { + "from_blog_id": 10, + "to_blog_id": 18, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-18/", + "id": "benchmark-edge-048" + }, + { + "from_blog_id": 10, + "to_blog_id": 21, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-21/", + "id": "benchmark-edge-049" + }, + { + "from_blog_id": 10, + "to_blog_id": 23, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-23/", + "id": "benchmark-edge-050" + }, + { + "from_blog_id": 11, + "to_blog_id": 12, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-12/", + "id": "benchmark-edge-051" + }, + { + "from_blog_id": 11, + "to_blog_id": 15, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-15/", + "id": "benchmark-edge-052" + }, + { + "from_blog_id": 11, + "to_blog_id": 18, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-18/", + "id": "benchmark-edge-053" + }, + { + "from_blog_id": 11, + "to_blog_id": 20, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-20/", + "id": "benchmark-edge-054" + }, + { + "from_blog_id": 11, + "to_blog_id": 23, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-23/", + "id": "benchmark-edge-055" + }, + { + "from_blog_id": 11, + "to_blog_id": 24, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-24/", + "id": "benchmark-edge-056" + }, + { + "from_blog_id": 12, + "to_blog_id": 6, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-06/", + "id": "benchmark-edge-057" + }, + { + "from_blog_id": 12, + "to_blog_id": 9, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-09/", + "id": "benchmark-edge-058" + }, + { + "from_blog_id": 12, + "to_blog_id": 13, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-13/", + "id": "benchmark-edge-059" + }, + { + "from_blog_id": 12, + "to_blog_id": 17, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-17/", + "id": "benchmark-edge-060" + }, + { + "from_blog_id": 13, + "to_blog_id": 1, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-01/", + "id": "benchmark-edge-061" + }, + { + "from_blog_id": 13, + "to_blog_id": 3, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-03/", + "id": "benchmark-edge-062" + }, + { + "from_blog_id": 13, + "to_blog_id": 14, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-14/", + "id": "benchmark-edge-063" + }, + { + "from_blog_id": 13, + "to_blog_id": 19, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-19/", + "id": "benchmark-edge-064" + }, + { + "from_blog_id": 13, + "to_blog_id": 20, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-20/", + "id": "benchmark-edge-065" + }, + { + "from_blog_id": 14, + "to_blog_id": 4, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-04/", + "id": "benchmark-edge-066" + }, + { + "from_blog_id": 14, + "to_blog_id": 6, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-06/", + "id": "benchmark-edge-067" + }, + { + "from_blog_id": 14, + "to_blog_id": 11, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-11/", + "id": "benchmark-edge-068" + }, + { + "from_blog_id": 14, + "to_blog_id": 15, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-15/", + "id": "benchmark-edge-069" + }, + { + "from_blog_id": 14, + "to_blog_id": 21, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-21/", + "id": "benchmark-edge-070" + }, + { + "from_blog_id": 15, + "to_blog_id": 1, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-01/", + "id": "benchmark-edge-071" + }, + { + "from_blog_id": 15, + "to_blog_id": 2, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-02/", + "id": "benchmark-edge-072" + }, + { + "from_blog_id": 15, + "to_blog_id": 4, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-04/", + "id": "benchmark-edge-073" + }, + { + "from_blog_id": 15, + "to_blog_id": 8, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-08/", + "id": "benchmark-edge-074" + }, + { + "from_blog_id": 15, + "to_blog_id": 13, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-13/", + "id": "benchmark-edge-075" + }, + { + "from_blog_id": 15, + "to_blog_id": 16, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-16/", + "id": "benchmark-edge-076" + }, + { + "from_blog_id": 15, + "to_blog_id": 21, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-21/", + "id": "benchmark-edge-077" + }, + { + "from_blog_id": 16, + "to_blog_id": 6, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-06/", + "id": "benchmark-edge-078" + }, + { + "from_blog_id": 16, + "to_blog_id": 7, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-07/", + "id": "benchmark-edge-079" + }, + { + "from_blog_id": 16, + "to_blog_id": 12, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-12/", + "id": "benchmark-edge-080" + }, + { + "from_blog_id": 16, + "to_blog_id": 13, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-13/", + "id": "benchmark-edge-081" + }, + { + "from_blog_id": 16, + "to_blog_id": 17, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-17/", + "id": "benchmark-edge-082" + }, + { + "from_blog_id": 17, + "to_blog_id": 4, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-04/", + "id": "benchmark-edge-083" + }, + { + "from_blog_id": 17, + "to_blog_id": 11, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-11/", + "id": "benchmark-edge-084" + }, + { + "from_blog_id": 17, + "to_blog_id": 15, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-15/", + "id": "benchmark-edge-085" + }, + { + "from_blog_id": 17, + "to_blog_id": 18, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-18/", + "id": "benchmark-edge-086" + }, + { + "from_blog_id": 17, + "to_blog_id": 20, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-20/", + "id": "benchmark-edge-087" + }, + { + "from_blog_id": 18, + "to_blog_id": 1, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-01/", + "id": "benchmark-edge-088" + }, + { + "from_blog_id": 18, + "to_blog_id": 19, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-19/", + "id": "benchmark-edge-089" + }, + { + "from_blog_id": 18, + "to_blog_id": 21, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-21/", + "id": "benchmark-edge-090" + }, + { + "from_blog_id": 18, + "to_blog_id": 22, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-22/", + "id": "benchmark-edge-091" + }, + { + "from_blog_id": 18, + "to_blog_id": 23, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-23/", + "id": "benchmark-edge-092" + }, + { + "from_blog_id": 19, + "to_blog_id": 3, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-03/", + "id": "benchmark-edge-093" + }, + { + "from_blog_id": 19, + "to_blog_id": 4, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-04/", + "id": "benchmark-edge-094" + }, + { + "from_blog_id": 19, + "to_blog_id": 6, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-06/", + "id": "benchmark-edge-095" + }, + { + "from_blog_id": 19, + "to_blog_id": 7, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-07/", + "id": "benchmark-edge-096" + }, + { + "from_blog_id": 19, + "to_blog_id": 8, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-08/", + "id": "benchmark-edge-097" + }, + { + "from_blog_id": 19, + "to_blog_id": 20, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-20/", + "id": "benchmark-edge-098" + }, + { + "from_blog_id": 20, + "to_blog_id": 1, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-01/", + "id": "benchmark-edge-099" + }, + { + "from_blog_id": 20, + "to_blog_id": 21, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-21/", + "id": "benchmark-edge-100" + }, + { + "from_blog_id": 21, + "to_blog_id": 8, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-08/", + "id": "benchmark-edge-101" + }, + { + "from_blog_id": 21, + "to_blog_id": 17, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-17/", + "id": "benchmark-edge-102" + }, + { + "from_blog_id": 21, + "to_blog_id": 22, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-22/", + "id": "benchmark-edge-103" + }, + { + "from_blog_id": 21, + "to_blog_id": 24, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-24/", + "id": "benchmark-edge-104" + }, + { + "from_blog_id": 22, + "to_blog_id": 2, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-02/", + "id": "benchmark-edge-105" + }, + { + "from_blog_id": 22, + "to_blog_id": 8, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-08/", + "id": "benchmark-edge-106" + }, + { + "from_blog_id": 22, + "to_blog_id": 11, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-11/", + "id": "benchmark-edge-107" + }, + { + "from_blog_id": 22, + "to_blog_id": 13, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-13/", + "id": "benchmark-edge-108" + }, + { + "from_blog_id": 22, + "to_blog_id": 17, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-17/", + "id": "benchmark-edge-109" + }, + { + "from_blog_id": 22, + "to_blog_id": 21, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-21/", + "id": "benchmark-edge-110" + }, + { + "from_blog_id": 22, + "to_blog_id": 23, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-23/", + "id": "benchmark-edge-111" + }, + { + "from_blog_id": 22, + "to_blog_id": 24, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-24/", + "id": "benchmark-edge-112" + }, + { + "from_blog_id": 23, + "to_blog_id": 6, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-06/", + "id": "benchmark-edge-113" + }, + { + "from_blog_id": 23, + "to_blog_id": 20, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-20/", + "id": "benchmark-edge-114" + }, + { + "from_blog_id": 23, + "to_blog_id": 21, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-21/", + "id": "benchmark-edge-115" + }, + { + "from_blog_id": 23, + "to_blog_id": 24, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-24/", + "id": "benchmark-edge-116" + }, + { + "from_blog_id": 24, + "to_blog_id": 1, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-01/", + "id": "benchmark-edge-117" + }, + { + "from_blog_id": 24, + "to_blog_id": 4, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-04/", + "id": "benchmark-edge-118" + }, + { + "from_blog_id": 24, + "to_blog_id": 9, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-09/", + "id": "benchmark-edge-119" + }, + { + "from_blog_id": 24, + "to_blog_id": 15, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-15/", + "id": "benchmark-edge-120" + }, + { + "from_blog_id": 24, + "to_blog_id": 20, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-20/", + "id": "benchmark-edge-121" + }, + { + "from_blog_id": 25, + "to_blog_id": 24, + "link_text": "community bridge", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-24/", + "id": "benchmark-edge-122" + }, + { + "from_blog_id": 25, + "to_blog_id": 26, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-02/", + "id": "benchmark-edge-123" + }, + { + "from_blog_id": 25, + "to_blog_id": 28, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-04/", + "id": "benchmark-edge-124" + }, + { + "from_blog_id": 25, + "to_blog_id": 39, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-15/", + "id": "benchmark-edge-125" + }, + { + "from_blog_id": 25, + "to_blog_id": 43, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-19/", + "id": "benchmark-edge-126" + }, + { + "from_blog_id": 25, + "to_blog_id": 44, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-20/", + "id": "benchmark-edge-127" + }, + { + "from_blog_id": 25, + "to_blog_id": 46, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-22/", + "id": "benchmark-edge-128" + }, + { + "from_blog_id": 26, + "to_blog_id": 27, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-03/", + "id": "benchmark-edge-129" + }, + { + "from_blog_id": 27, + "to_blog_id": 28, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-04/", + "id": "benchmark-edge-130" + }, + { + "from_blog_id": 27, + "to_blog_id": 33, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-09/", + "id": "benchmark-edge-131" + }, + { + "from_blog_id": 27, + "to_blog_id": 36, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-12/", + "id": "benchmark-edge-132" + }, + { + "from_blog_id": 27, + "to_blog_id": 45, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-21/", + "id": "benchmark-edge-133" + }, + { + "from_blog_id": 28, + "to_blog_id": 29, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-05/", + "id": "benchmark-edge-134" + }, + { + "from_blog_id": 28, + "to_blog_id": 30, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-06/", + "id": "benchmark-edge-135" + }, + { + "from_blog_id": 28, + "to_blog_id": 34, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-10/", + "id": "benchmark-edge-136" + }, + { + "from_blog_id": 29, + "to_blog_id": 27, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-03/", + "id": "benchmark-edge-137" + }, + { + "from_blog_id": 29, + "to_blog_id": 30, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-06/", + "id": "benchmark-edge-138" + }, + { + "from_blog_id": 29, + "to_blog_id": 36, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-12/", + "id": "benchmark-edge-139" + }, + { + "from_blog_id": 29, + "to_blog_id": 37, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-13/", + "id": "benchmark-edge-140" + }, + { + "from_blog_id": 29, + "to_blog_id": 44, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-20/", + "id": "benchmark-edge-141" + }, + { + "from_blog_id": 29, + "to_blog_id": 69, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-05/", + "id": "benchmark-edge-142" + }, + { + "from_blog_id": 30, + "to_blog_id": 26, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-02/", + "id": "benchmark-edge-143" + }, + { + "from_blog_id": 30, + "to_blog_id": 31, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-07/", + "id": "benchmark-edge-144" + }, + { + "from_blog_id": 30, + "to_blog_id": 36, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-12/", + "id": "benchmark-edge-145" + }, + { + "from_blog_id": 30, + "to_blog_id": 37, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-13/", + "id": "benchmark-edge-146" + }, + { + "from_blog_id": 31, + "to_blog_id": 32, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-08/", + "id": "benchmark-edge-147" + }, + { + "from_blog_id": 31, + "to_blog_id": 40, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-16/", + "id": "benchmark-edge-148" + }, + { + "from_blog_id": 31, + "to_blog_id": 45, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-21/", + "id": "benchmark-edge-149" + }, + { + "from_blog_id": 32, + "to_blog_id": 27, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-03/", + "id": "benchmark-edge-150" + }, + { + "from_blog_id": 32, + "to_blog_id": 31, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-07/", + "id": "benchmark-edge-151" + }, + { + "from_blog_id": 32, + "to_blog_id": 33, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-09/", + "id": "benchmark-edge-152" + }, + { + "from_blog_id": 32, + "to_blog_id": 34, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-10/", + "id": "benchmark-edge-153" + }, + { + "from_blog_id": 32, + "to_blog_id": 36, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-12/", + "id": "benchmark-edge-154" + }, + { + "from_blog_id": 32, + "to_blog_id": 44, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-20/", + "id": "benchmark-edge-155" + }, + { + "from_blog_id": 33, + "to_blog_id": 34, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-10/", + "id": "benchmark-edge-156" + }, + { + "from_blog_id": 33, + "to_blog_id": 35, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-11/", + "id": "benchmark-edge-157" + }, + { + "from_blog_id": 33, + "to_blog_id": 37, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-13/", + "id": "benchmark-edge-158" + }, + { + "from_blog_id": 33, + "to_blog_id": 46, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-22/", + "id": "benchmark-edge-159" + }, + { + "from_blog_id": 34, + "to_blog_id": 27, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-03/", + "id": "benchmark-edge-160" + }, + { + "from_blog_id": 34, + "to_blog_id": 31, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-07/", + "id": "benchmark-edge-161" + }, + { + "from_blog_id": 34, + "to_blog_id": 35, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-11/", + "id": "benchmark-edge-162" + }, + { + "from_blog_id": 35, + "to_blog_id": 29, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-05/", + "id": "benchmark-edge-163" + }, + { + "from_blog_id": 35, + "to_blog_id": 36, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-12/", + "id": "benchmark-edge-164" + }, + { + "from_blog_id": 35, + "to_blog_id": 59, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-13/", + "id": "benchmark-edge-165" + }, + { + "from_blog_id": 36, + "to_blog_id": 25, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-01/", + "id": "benchmark-edge-166" + }, + { + "from_blog_id": 36, + "to_blog_id": 31, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-07/", + "id": "benchmark-edge-167" + }, + { + "from_blog_id": 36, + "to_blog_id": 34, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-10/", + "id": "benchmark-edge-168" + }, + { + "from_blog_id": 36, + "to_blog_id": 37, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-13/", + "id": "benchmark-edge-169" + }, + { + "from_blog_id": 36, + "to_blog_id": 39, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-15/", + "id": "benchmark-edge-170" + }, + { + "from_blog_id": 36, + "to_blog_id": 43, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-19/", + "id": "benchmark-edge-171" + }, + { + "from_blog_id": 36, + "to_blog_id": 44, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-20/", + "id": "benchmark-edge-172" + }, + { + "from_blog_id": 36, + "to_blog_id": 46, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-22/", + "id": "benchmark-edge-173" + }, + { + "from_blog_id": 37, + "to_blog_id": 27, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-03/", + "id": "benchmark-edge-174" + }, + { + "from_blog_id": 37, + "to_blog_id": 36, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-12/", + "id": "benchmark-edge-175" + }, + { + "from_blog_id": 37, + "to_blog_id": 38, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-14/", + "id": "benchmark-edge-176" + }, + { + "from_blog_id": 37, + "to_blog_id": 40, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-16/", + "id": "benchmark-edge-177" + }, + { + "from_blog_id": 38, + "to_blog_id": 34, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-10/", + "id": "benchmark-edge-178" + }, + { + "from_blog_id": 38, + "to_blog_id": 39, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-15/", + "id": "benchmark-edge-179" + }, + { + "from_blog_id": 38, + "to_blog_id": 41, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-17/", + "id": "benchmark-edge-180" + }, + { + "from_blog_id": 38, + "to_blog_id": 44, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-20/", + "id": "benchmark-edge-181" + }, + { + "from_blog_id": 39, + "to_blog_id": 26, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-02/", + "id": "benchmark-edge-182" + }, + { + "from_blog_id": 39, + "to_blog_id": 27, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-03/", + "id": "benchmark-edge-183" + }, + { + "from_blog_id": 39, + "to_blog_id": 28, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-04/", + "id": "benchmark-edge-184" + }, + { + "from_blog_id": 39, + "to_blog_id": 29, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-05/", + "id": "benchmark-edge-185" + }, + { + "from_blog_id": 39, + "to_blog_id": 34, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-10/", + "id": "benchmark-edge-186" + }, + { + "from_blog_id": 39, + "to_blog_id": 40, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-16/", + "id": "benchmark-edge-187" + }, + { + "from_blog_id": 39, + "to_blog_id": 46, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-22/", + "id": "benchmark-edge-188" + }, + { + "from_blog_id": 40, + "to_blog_id": 27, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-03/", + "id": "benchmark-edge-189" + }, + { + "from_blog_id": 40, + "to_blog_id": 32, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-08/", + "id": "benchmark-edge-190" + }, + { + "from_blog_id": 40, + "to_blog_id": 33, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-09/", + "id": "benchmark-edge-191" + }, + { + "from_blog_id": 40, + "to_blog_id": 35, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-11/", + "id": "benchmark-edge-192" + }, + { + "from_blog_id": 40, + "to_blog_id": 41, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-17/", + "id": "benchmark-edge-193" + }, + { + "from_blog_id": 40, + "to_blog_id": 43, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-19/", + "id": "benchmark-edge-194" + }, + { + "from_blog_id": 41, + "to_blog_id": 36, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-12/", + "id": "benchmark-edge-195" + }, + { + "from_blog_id": 41, + "to_blog_id": 39, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-15/", + "id": "benchmark-edge-196" + }, + { + "from_blog_id": 41, + "to_blog_id": 42, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-18/", + "id": "benchmark-edge-197" + }, + { + "from_blog_id": 41, + "to_blog_id": 44, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-20/", + "id": "benchmark-edge-198" + }, + { + "from_blog_id": 42, + "to_blog_id": 28, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-04/", + "id": "benchmark-edge-199" + }, + { + "from_blog_id": 42, + "to_blog_id": 31, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-07/", + "id": "benchmark-edge-200" + }, + { + "from_blog_id": 42, + "to_blog_id": 41, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-17/", + "id": "benchmark-edge-201" + }, + { + "from_blog_id": 42, + "to_blog_id": 43, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-19/", + "id": "benchmark-edge-202" + }, + { + "from_blog_id": 42, + "to_blog_id": 44, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-20/", + "id": "benchmark-edge-203" + }, + { + "from_blog_id": 43, + "to_blog_id": 27, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-03/", + "id": "benchmark-edge-204" + }, + { + "from_blog_id": 43, + "to_blog_id": 32, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-08/", + "id": "benchmark-edge-205" + }, + { + "from_blog_id": 43, + "to_blog_id": 35, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-11/", + "id": "benchmark-edge-206" + }, + { + "from_blog_id": 43, + "to_blog_id": 44, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-20/", + "id": "benchmark-edge-207" + }, + { + "from_blog_id": 43, + "to_blog_id": 45, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-21/", + "id": "benchmark-edge-208" + }, + { + "from_blog_id": 43, + "to_blog_id": 77, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-13/", + "id": "benchmark-edge-209" + }, + { + "from_blog_id": 44, + "to_blog_id": 4, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/indie-web-04/", + "id": "benchmark-edge-210" + }, + { + "from_blog_id": 44, + "to_blog_id": 28, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-04/", + "id": "benchmark-edge-211" + }, + { + "from_blog_id": 44, + "to_blog_id": 31, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-07/", + "id": "benchmark-edge-212" + }, + { + "from_blog_id": 44, + "to_blog_id": 34, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-10/", + "id": "benchmark-edge-213" + }, + { + "from_blog_id": 44, + "to_blog_id": 39, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-15/", + "id": "benchmark-edge-214" + }, + { + "from_blog_id": 44, + "to_blog_id": 43, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-19/", + "id": "benchmark-edge-215" + }, + { + "from_blog_id": 44, + "to_blog_id": 45, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-21/", + "id": "benchmark-edge-216" + }, + { + "from_blog_id": 45, + "to_blog_id": 26, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-02/", + "id": "benchmark-edge-217" + }, + { + "from_blog_id": 45, + "to_blog_id": 30, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-06/", + "id": "benchmark-edge-218" + }, + { + "from_blog_id": 45, + "to_blog_id": 32, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-08/", + "id": "benchmark-edge-219" + }, + { + "from_blog_id": 45, + "to_blog_id": 33, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-09/", + "id": "benchmark-edge-220" + }, + { + "from_blog_id": 45, + "to_blog_id": 35, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-11/", + "id": "benchmark-edge-221" + }, + { + "from_blog_id": 45, + "to_blog_id": 36, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-12/", + "id": "benchmark-edge-222" + }, + { + "from_blog_id": 45, + "to_blog_id": 46, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-22/", + "id": "benchmark-edge-223" + }, + { + "from_blog_id": 46, + "to_blog_id": 25, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/engineering-01/", + "id": "benchmark-edge-224" + }, + { + "from_blog_id": 46, + "to_blog_id": 35, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-11/", + "id": "benchmark-edge-225" + }, + { + "from_blog_id": 46, + "to_blog_id": 41, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-17/", + "id": "benchmark-edge-226" + }, + { + "from_blog_id": 46, + "to_blog_id": 42, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-18/", + "id": "benchmark-edge-227" + }, + { + "from_blog_id": 47, + "to_blog_id": 48, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-02/", + "id": "benchmark-edge-228" + }, + { + "from_blog_id": 47, + "to_blog_id": 77, + "link_text": "community bridge", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-13/", + "id": "benchmark-edge-229" + }, + { + "from_blog_id": 48, + "to_blog_id": 49, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-03/", + "id": "benchmark-edge-230" + }, + { + "from_blog_id": 48, + "to_blog_id": 61, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-15/", + "id": "benchmark-edge-231" + }, + { + "from_blog_id": 49, + "to_blog_id": 50, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-04/", + "id": "benchmark-edge-232" + }, + { + "from_blog_id": 49, + "to_blog_id": 55, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-09/", + "id": "benchmark-edge-233" + }, + { + "from_blog_id": 49, + "to_blog_id": 58, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-12/", + "id": "benchmark-edge-234" + }, + { + "from_blog_id": 49, + "to_blog_id": 62, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-16/", + "id": "benchmark-edge-235" + }, + { + "from_blog_id": 50, + "to_blog_id": 29, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-05/", + "id": "benchmark-edge-236" + }, + { + "from_blog_id": 50, + "to_blog_id": 51, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-05/", + "id": "benchmark-edge-237" + }, + { + "from_blog_id": 50, + "to_blog_id": 57, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-11/", + "id": "benchmark-edge-238" + }, + { + "from_blog_id": 51, + "to_blog_id": 52, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-06/", + "id": "benchmark-edge-239" + }, + { + "from_blog_id": 51, + "to_blog_id": 60, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-14/", + "id": "benchmark-edge-240" + }, + { + "from_blog_id": 51, + "to_blog_id": 62, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-16/", + "id": "benchmark-edge-241" + }, + { + "from_blog_id": 52, + "to_blog_id": 48, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-02/", + "id": "benchmark-edge-242" + }, + { + "from_blog_id": 52, + "to_blog_id": 53, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-07/", + "id": "benchmark-edge-243" + }, + { + "from_blog_id": 52, + "to_blog_id": 58, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-12/", + "id": "benchmark-edge-244" + }, + { + "from_blog_id": 52, + "to_blog_id": 59, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-13/", + "id": "benchmark-edge-245" + }, + { + "from_blog_id": 52, + "to_blog_id": 63, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-17/", + "id": "benchmark-edge-246" + }, + { + "from_blog_id": 53, + "to_blog_id": 47, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-01/", + "id": "benchmark-edge-247" + }, + { + "from_blog_id": 53, + "to_blog_id": 54, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-08/", + "id": "benchmark-edge-248" + }, + { + "from_blog_id": 53, + "to_blog_id": 57, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-11/", + "id": "benchmark-edge-249" + }, + { + "from_blog_id": 54, + "to_blog_id": 52, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-06/", + "id": "benchmark-edge-250" + }, + { + "from_blog_id": 54, + "to_blog_id": 55, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-09/", + "id": "benchmark-edge-251" + }, + { + "from_blog_id": 54, + "to_blog_id": 63, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-17/", + "id": "benchmark-edge-252" + }, + { + "from_blog_id": 54, + "to_blog_id": 70, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-06/", + "id": "benchmark-edge-253" + }, + { + "from_blog_id": 55, + "to_blog_id": 50, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-04/", + "id": "benchmark-edge-254" + }, + { + "from_blog_id": 55, + "to_blog_id": 56, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-10/", + "id": "benchmark-edge-255" + }, + { + "from_blog_id": 56, + "to_blog_id": 49, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-03/", + "id": "benchmark-edge-256" + }, + { + "from_blog_id": 56, + "to_blog_id": 57, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-11/", + "id": "benchmark-edge-257" + }, + { + "from_blog_id": 56, + "to_blog_id": 62, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-16/", + "id": "benchmark-edge-258" + }, + { + "from_blog_id": 57, + "to_blog_id": 58, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-12/", + "id": "benchmark-edge-259" + }, + { + "from_blog_id": 57, + "to_blog_id": 59, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-13/", + "id": "benchmark-edge-260" + }, + { + "from_blog_id": 57, + "to_blog_id": 61, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-15/", + "id": "benchmark-edge-261" + }, + { + "from_blog_id": 57, + "to_blog_id": 62, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-16/", + "id": "benchmark-edge-262" + }, + { + "from_blog_id": 58, + "to_blog_id": 53, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-07/", + "id": "benchmark-edge-263" + }, + { + "from_blog_id": 58, + "to_blog_id": 59, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-13/", + "id": "benchmark-edge-264" + }, + { + "from_blog_id": 59, + "to_blog_id": 48, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-02/", + "id": "benchmark-edge-265" + }, + { + "from_blog_id": 59, + "to_blog_id": 53, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-07/", + "id": "benchmark-edge-266" + }, + { + "from_blog_id": 59, + "to_blog_id": 60, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-14/", + "id": "benchmark-edge-267" + }, + { + "from_blog_id": 59, + "to_blog_id": 63, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-17/", + "id": "benchmark-edge-268" + }, + { + "from_blog_id": 60, + "to_blog_id": 47, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-01/", + "id": "benchmark-edge-269" + }, + { + "from_blog_id": 60, + "to_blog_id": 48, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-02/", + "id": "benchmark-edge-270" + }, + { + "from_blog_id": 60, + "to_blog_id": 49, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-03/", + "id": "benchmark-edge-271" + }, + { + "from_blog_id": 60, + "to_blog_id": 57, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-11/", + "id": "benchmark-edge-272" + }, + { + "from_blog_id": 60, + "to_blog_id": 61, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-15/", + "id": "benchmark-edge-273" + }, + { + "from_blog_id": 60, + "to_blog_id": 62, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-16/", + "id": "benchmark-edge-274" + }, + { + "from_blog_id": 60, + "to_blog_id": 63, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-17/", + "id": "benchmark-edge-275" + }, + { + "from_blog_id": 61, + "to_blog_id": 51, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-05/", + "id": "benchmark-edge-276" + }, + { + "from_blog_id": 61, + "to_blog_id": 54, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-08/", + "id": "benchmark-edge-277" + }, + { + "from_blog_id": 61, + "to_blog_id": 59, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-13/", + "id": "benchmark-edge-278" + }, + { + "from_blog_id": 61, + "to_blog_id": 62, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-16/", + "id": "benchmark-edge-279" + }, + { + "from_blog_id": 62, + "to_blog_id": 53, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-07/", + "id": "benchmark-edge-280" + }, + { + "from_blog_id": 62, + "to_blog_id": 63, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-17/", + "id": "benchmark-edge-281" + }, + { + "from_blog_id": 62, + "to_blog_id": 64, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-18/", + "id": "benchmark-edge-282" + }, + { + "from_blog_id": 63, + "to_blog_id": 34, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/engineering-10/", + "id": "benchmark-edge-283" + }, + { + "from_blog_id": 63, + "to_blog_id": 61, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-15/", + "id": "benchmark-edge-284" + }, + { + "from_blog_id": 63, + "to_blog_id": 64, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-18/", + "id": "benchmark-edge-285" + }, + { + "from_blog_id": 64, + "to_blog_id": 47, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/design-01/", + "id": "benchmark-edge-286" + }, + { + "from_blog_id": 64, + "to_blog_id": 49, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-03/", + "id": "benchmark-edge-287" + }, + { + "from_blog_id": 64, + "to_blog_id": 58, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-12/", + "id": "benchmark-edge-288" + }, + { + "from_blog_id": 64, + "to_blog_id": 59, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-13/", + "id": "benchmark-edge-289" + }, + { + "from_blog_id": 65, + "to_blog_id": 66, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-02/", + "id": "benchmark-edge-290" + }, + { + "from_blog_id": 65, + "to_blog_id": 75, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-11/", + "id": "benchmark-edge-291" + }, + { + "from_blog_id": 65, + "to_blog_id": 77, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-13/", + "id": "benchmark-edge-292" + }, + { + "from_blog_id": 65, + "to_blog_id": 82, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-18/", + "id": "benchmark-edge-293" + }, + { + "from_blog_id": 65, + "to_blog_id": 86, + "link_text": "community bridge", + "link_url_raw": "https://benchmark.heyblog.local/culture-02/", + "id": "benchmark-edge-294" + }, + { + "from_blog_id": 66, + "to_blog_id": 65, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-01/", + "id": "benchmark-edge-295" + }, + { + "from_blog_id": 66, + "to_blog_id": 67, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-03/", + "id": "benchmark-edge-296" + }, + { + "from_blog_id": 66, + "to_blog_id": 74, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-10/", + "id": "benchmark-edge-297" + }, + { + "from_blog_id": 66, + "to_blog_id": 76, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-12/", + "id": "benchmark-edge-298" + }, + { + "from_blog_id": 66, + "to_blog_id": 79, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-15/", + "id": "benchmark-edge-299" + }, + { + "from_blog_id": 67, + "to_blog_id": 65, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-01/", + "id": "benchmark-edge-300" + }, + { + "from_blog_id": 67, + "to_blog_id": 68, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-04/", + "id": "benchmark-edge-301" + }, + { + "from_blog_id": 67, + "to_blog_id": 72, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-08/", + "id": "benchmark-edge-302" + }, + { + "from_blog_id": 67, + "to_blog_id": 75, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-11/", + "id": "benchmark-edge-303" + }, + { + "from_blog_id": 67, + "to_blog_id": 81, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-17/", + "id": "benchmark-edge-304" + }, + { + "from_blog_id": 67, + "to_blog_id": 83, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-19/", + "id": "benchmark-edge-305" + }, + { + "from_blog_id": 68, + "to_blog_id": 65, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-01/", + "id": "benchmark-edge-306" + }, + { + "from_blog_id": 68, + "to_blog_id": 69, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-05/", + "id": "benchmark-edge-307" + }, + { + "from_blog_id": 68, + "to_blog_id": 74, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-10/", + "id": "benchmark-edge-308" + }, + { + "from_blog_id": 68, + "to_blog_id": 79, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-15/", + "id": "benchmark-edge-309" + }, + { + "from_blog_id": 68, + "to_blog_id": 81, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-17/", + "id": "benchmark-edge-310" + }, + { + "from_blog_id": 68, + "to_blog_id": 84, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-20/", + "id": "benchmark-edge-311" + }, + { + "from_blog_id": 69, + "to_blog_id": 66, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-02/", + "id": "benchmark-edge-312" + }, + { + "from_blog_id": 69, + "to_blog_id": 68, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-04/", + "id": "benchmark-edge-313" + }, + { + "from_blog_id": 69, + "to_blog_id": 70, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-06/", + "id": "benchmark-edge-314" + }, + { + "from_blog_id": 69, + "to_blog_id": 73, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-09/", + "id": "benchmark-edge-315" + }, + { + "from_blog_id": 69, + "to_blog_id": 77, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-13/", + "id": "benchmark-edge-316" + }, + { + "from_blog_id": 69, + "to_blog_id": 83, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-19/", + "id": "benchmark-edge-317" + }, + { + "from_blog_id": 70, + "to_blog_id": 65, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-01/", + "id": "benchmark-edge-318" + }, + { + "from_blog_id": 70, + "to_blog_id": 67, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-03/", + "id": "benchmark-edge-319" + }, + { + "from_blog_id": 70, + "to_blog_id": 68, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-04/", + "id": "benchmark-edge-320" + }, + { + "from_blog_id": 70, + "to_blog_id": 69, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-05/", + "id": "benchmark-edge-321" + }, + { + "from_blog_id": 70, + "to_blog_id": 71, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-07/", + "id": "benchmark-edge-322" + }, + { + "from_blog_id": 70, + "to_blog_id": 80, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-16/", + "id": "benchmark-edge-323" + }, + { + "from_blog_id": 70, + "to_blog_id": 83, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-19/", + "id": "benchmark-edge-324" + }, + { + "from_blog_id": 71, + "to_blog_id": 66, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-02/", + "id": "benchmark-edge-325" + }, + { + "from_blog_id": 71, + "to_blog_id": 69, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-05/", + "id": "benchmark-edge-326" + }, + { + "from_blog_id": 71, + "to_blog_id": 72, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-08/", + "id": "benchmark-edge-327" + }, + { + "from_blog_id": 71, + "to_blog_id": 79, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-15/", + "id": "benchmark-edge-328" + }, + { + "from_blog_id": 72, + "to_blog_id": 70, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-06/", + "id": "benchmark-edge-329" + }, + { + "from_blog_id": 72, + "to_blog_id": 73, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-09/", + "id": "benchmark-edge-330" + }, + { + "from_blog_id": 72, + "to_blog_id": 82, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-18/", + "id": "benchmark-edge-331" + }, + { + "from_blog_id": 72, + "to_blog_id": 84, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-20/", + "id": "benchmark-edge-332" + }, + { + "from_blog_id": 73, + "to_blog_id": 68, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-04/", + "id": "benchmark-edge-333" + }, + { + "from_blog_id": 73, + "to_blog_id": 71, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-07/", + "id": "benchmark-edge-334" + }, + { + "from_blog_id": 73, + "to_blog_id": 74, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-10/", + "id": "benchmark-edge-335" + }, + { + "from_blog_id": 74, + "to_blog_id": 67, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-03/", + "id": "benchmark-edge-336" + }, + { + "from_blog_id": 74, + "to_blog_id": 69, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-05/", + "id": "benchmark-edge-337" + }, + { + "from_blog_id": 74, + "to_blog_id": 73, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-09/", + "id": "benchmark-edge-338" + }, + { + "from_blog_id": 74, + "to_blog_id": 75, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-11/", + "id": "benchmark-edge-339" + }, + { + "from_blog_id": 74, + "to_blog_id": 81, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-17/", + "id": "benchmark-edge-340" + }, + { + "from_blog_id": 75, + "to_blog_id": 71, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-07/", + "id": "benchmark-edge-341" + }, + { + "from_blog_id": 75, + "to_blog_id": 76, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-12/", + "id": "benchmark-edge-342" + }, + { + "from_blog_id": 76, + "to_blog_id": 69, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-05/", + "id": "benchmark-edge-343" + }, + { + "from_blog_id": 76, + "to_blog_id": 77, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-13/", + "id": "benchmark-edge-344" + }, + { + "from_blog_id": 76, + "to_blog_id": 84, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-20/", + "id": "benchmark-edge-345" + }, + { + "from_blog_id": 77, + "to_blog_id": 71, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-07/", + "id": "benchmark-edge-346" + }, + { + "from_blog_id": 77, + "to_blog_id": 78, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-14/", + "id": "benchmark-edge-347" + }, + { + "from_blog_id": 77, + "to_blog_id": 81, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-17/", + "id": "benchmark-edge-348" + }, + { + "from_blog_id": 77, + "to_blog_id": 84, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-20/", + "id": "benchmark-edge-349" + }, + { + "from_blog_id": 78, + "to_blog_id": 66, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-02/", + "id": "benchmark-edge-350" + }, + { + "from_blog_id": 78, + "to_blog_id": 69, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-05/", + "id": "benchmark-edge-351" + }, + { + "from_blog_id": 78, + "to_blog_id": 74, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-10/", + "id": "benchmark-edge-352" + }, + { + "from_blog_id": 78, + "to_blog_id": 79, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-15/", + "id": "benchmark-edge-353" + }, + { + "from_blog_id": 78, + "to_blog_id": 92, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-08/", + "id": "benchmark-edge-354" + }, + { + "from_blog_id": 79, + "to_blog_id": 70, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-06/", + "id": "benchmark-edge-355" + }, + { + "from_blog_id": 79, + "to_blog_id": 76, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-12/", + "id": "benchmark-edge-356" + }, + { + "from_blog_id": 79, + "to_blog_id": 80, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-16/", + "id": "benchmark-edge-357" + }, + { + "from_blog_id": 79, + "to_blog_id": 81, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-17/", + "id": "benchmark-edge-358" + }, + { + "from_blog_id": 79, + "to_blog_id": 84, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-20/", + "id": "benchmark-edge-359" + }, + { + "from_blog_id": 80, + "to_blog_id": 63, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-17/", + "id": "benchmark-edge-360" + }, + { + "from_blog_id": 80, + "to_blog_id": 76, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-12/", + "id": "benchmark-edge-361" + }, + { + "from_blog_id": 80, + "to_blog_id": 78, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-14/", + "id": "benchmark-edge-362" + }, + { + "from_blog_id": 80, + "to_blog_id": 81, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-17/", + "id": "benchmark-edge-363" + }, + { + "from_blog_id": 81, + "to_blog_id": 65, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-01/", + "id": "benchmark-edge-364" + }, + { + "from_blog_id": 81, + "to_blog_id": 69, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-05/", + "id": "benchmark-edge-365" + }, + { + "from_blog_id": 81, + "to_blog_id": 78, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-14/", + "id": "benchmark-edge-366" + }, + { + "from_blog_id": 81, + "to_blog_id": 80, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-16/", + "id": "benchmark-edge-367" + }, + { + "from_blog_id": 81, + "to_blog_id": 82, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-18/", + "id": "benchmark-edge-368" + }, + { + "from_blog_id": 82, + "to_blog_id": 75, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-11/", + "id": "benchmark-edge-369" + }, + { + "from_blog_id": 82, + "to_blog_id": 79, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-15/", + "id": "benchmark-edge-370" + }, + { + "from_blog_id": 82, + "to_blog_id": 83, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-19/", + "id": "benchmark-edge-371" + }, + { + "from_blog_id": 83, + "to_blog_id": 72, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-08/", + "id": "benchmark-edge-372" + }, + { + "from_blog_id": 83, + "to_blog_id": 73, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-09/", + "id": "benchmark-edge-373" + }, + { + "from_blog_id": 83, + "to_blog_id": 74, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-10/", + "id": "benchmark-edge-374" + }, + { + "from_blog_id": 83, + "to_blog_id": 75, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-11/", + "id": "benchmark-edge-375" + }, + { + "from_blog_id": 83, + "to_blog_id": 79, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-15/", + "id": "benchmark-edge-376" + }, + { + "from_blog_id": 83, + "to_blog_id": 84, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-20/", + "id": "benchmark-edge-377" + }, + { + "from_blog_id": 84, + "to_blog_id": 65, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-01/", + "id": "benchmark-edge-378" + }, + { + "from_blog_id": 84, + "to_blog_id": 66, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-02/", + "id": "benchmark-edge-379" + }, + { + "from_blog_id": 84, + "to_blog_id": 78, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/data-ai-14/", + "id": "benchmark-edge-380" + }, + { + "from_blog_id": 85, + "to_blog_id": 55, + "link_text": "community bridge", + "link_url_raw": "https://benchmark.heyblog.local/design-09/", + "id": "benchmark-edge-381" + }, + { + "from_blog_id": 85, + "to_blog_id": 86, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-02/", + "id": "benchmark-edge-382" + }, + { + "from_blog_id": 85, + "to_blog_id": 87, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-03/", + "id": "benchmark-edge-383" + }, + { + "from_blog_id": 85, + "to_blog_id": 89, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-05/", + "id": "benchmark-edge-384" + }, + { + "from_blog_id": 86, + "to_blog_id": 87, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-03/", + "id": "benchmark-edge-385" + }, + { + "from_blog_id": 86, + "to_blog_id": 96, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-12/", + "id": "benchmark-edge-386" + }, + { + "from_blog_id": 87, + "to_blog_id": 88, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-04/", + "id": "benchmark-edge-387" + }, + { + "from_blog_id": 87, + "to_blog_id": 93, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-09/", + "id": "benchmark-edge-388" + }, + { + "from_blog_id": 87, + "to_blog_id": 98, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-14/", + "id": "benchmark-edge-389" + }, + { + "from_blog_id": 88, + "to_blog_id": 89, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-05/", + "id": "benchmark-edge-390" + }, + { + "from_blog_id": 89, + "to_blog_id": 86, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-02/", + "id": "benchmark-edge-391" + }, + { + "from_blog_id": 89, + "to_blog_id": 88, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-04/", + "id": "benchmark-edge-392" + }, + { + "from_blog_id": 89, + "to_blog_id": 90, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-06/", + "id": "benchmark-edge-393" + }, + { + "from_blog_id": 90, + "to_blog_id": 91, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-07/", + "id": "benchmark-edge-394" + }, + { + "from_blog_id": 90, + "to_blog_id": 93, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-09/", + "id": "benchmark-edge-395" + }, + { + "from_blog_id": 91, + "to_blog_id": 85, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-01/", + "id": "benchmark-edge-396" + }, + { + "from_blog_id": 91, + "to_blog_id": 92, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-08/", + "id": "benchmark-edge-397" + }, + { + "from_blog_id": 91, + "to_blog_id": 94, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-10/", + "id": "benchmark-edge-398" + }, + { + "from_blog_id": 92, + "to_blog_id": 90, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-06/", + "id": "benchmark-edge-399" + }, + { + "from_blog_id": 92, + "to_blog_id": 93, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-09/", + "id": "benchmark-edge-400" + }, + { + "from_blog_id": 92, + "to_blog_id": 97, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-13/", + "id": "benchmark-edge-401" + }, + { + "from_blog_id": 92, + "to_blog_id": 100, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-16/", + "id": "benchmark-edge-402" + }, + { + "from_blog_id": 93, + "to_blog_id": 59, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/design-13/", + "id": "benchmark-edge-403" + }, + { + "from_blog_id": 93, + "to_blog_id": 92, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-08/", + "id": "benchmark-edge-404" + }, + { + "from_blog_id": 93, + "to_blog_id": 94, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-10/", + "id": "benchmark-edge-405" + }, + { + "from_blog_id": 94, + "to_blog_id": 89, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-05/", + "id": "benchmark-edge-406" + }, + { + "from_blog_id": 94, + "to_blog_id": 95, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-11/", + "id": "benchmark-edge-407" + }, + { + "from_blog_id": 94, + "to_blog_id": 97, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-13/", + "id": "benchmark-edge-408" + }, + { + "from_blog_id": 95, + "to_blog_id": 89, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-05/", + "id": "benchmark-edge-409" + }, + { + "from_blog_id": 95, + "to_blog_id": 91, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-07/", + "id": "benchmark-edge-410" + }, + { + "from_blog_id": 95, + "to_blog_id": 94, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-10/", + "id": "benchmark-edge-411" + }, + { + "from_blog_id": 95, + "to_blog_id": 96, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-12/", + "id": "benchmark-edge-412" + }, + { + "from_blog_id": 95, + "to_blog_id": 97, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-13/", + "id": "benchmark-edge-413" + }, + { + "from_blog_id": 96, + "to_blog_id": 85, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-01/", + "id": "benchmark-edge-414" + }, + { + "from_blog_id": 96, + "to_blog_id": 87, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-03/", + "id": "benchmark-edge-415" + }, + { + "from_blog_id": 96, + "to_blog_id": 93, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-09/", + "id": "benchmark-edge-416" + }, + { + "from_blog_id": 96, + "to_blog_id": 97, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-13/", + "id": "benchmark-edge-417" + }, + { + "from_blog_id": 96, + "to_blog_id": 98, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-14/", + "id": "benchmark-edge-418" + }, + { + "from_blog_id": 97, + "to_blog_id": 98, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-14/", + "id": "benchmark-edge-419" + }, + { + "from_blog_id": 98, + "to_blog_id": 91, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-07/", + "id": "benchmark-edge-420" + }, + { + "from_blog_id": 98, + "to_blog_id": 99, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-15/", + "id": "benchmark-edge-421" + }, + { + "from_blog_id": 99, + "to_blog_id": 90, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-06/", + "id": "benchmark-edge-422" + }, + { + "from_blog_id": 99, + "to_blog_id": 91, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-07/", + "id": "benchmark-edge-423" + }, + { + "from_blog_id": 99, + "to_blog_id": 96, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-12/", + "id": "benchmark-edge-424" + }, + { + "from_blog_id": 99, + "to_blog_id": 100, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-16/", + "id": "benchmark-edge-425" + }, + { + "from_blog_id": 100, + "to_blog_id": 85, + "link_text": "blogroll", + "link_url_raw": "https://benchmark.heyblog.local/culture-01/", + "id": "benchmark-edge-426" + }, + { + "from_blog_id": 100, + "to_blog_id": 95, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-11/", + "id": "benchmark-edge-427" + }, + { + "from_blog_id": 100, + "to_blog_id": 97, + "link_text": "friend link", + "link_url_raw": "https://benchmark.heyblog.local/culture-13/", + "id": "benchmark-edge-428" + } + ], + "meta": { + "strategy": "synthetic-community-benchmark", + "limit": 100, + "source": "scripts/generate_visualization_benchmark.py", + "generated_at": "2026-06-05T18:43:51.851750+00:00", + "total_nodes": 100, + "total_edges": 428, + "selected_nodes": 100, + "selected_edges": 428, + "available_nodes": 100, + "available_edges": 428, + "benchmark": { + "seed": 42, + "model": "seeded stochastic block model inspired by LFR mixing-parameter benchmarks", + "community_sizes": { + "indie-web": 24, + "engineering": 22, + "design": 18, + "data-ai": 20, + "culture": 16 + }, + "intra_probability": 0.34, + "inter_probability": 0.002, + "estimated_mixing_rate": 0.006, + "layout": "fixed separated community centers with deterministic jitter" + } + } +} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 8874f0e..b068b84 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -206,6 +206,51 @@ beforeEach(() => { }), ); } + if (url.pathname === "/benchmarks/blog-community-graph.json") { + return new Response( + JSON.stringify({ + nodes: [ + { + id: 1, + url: "https://benchmark.heyblog.local/indie-web-01/", + domain: "indie-web-01.benchmark.heyblog.local", + title: "Indie Web Notes 01", + icon_url: null, + incoming_count: 1, + outgoing_count: 1, + degree: 2, + component_id: "indie-web", + }, + { + id: 2, + url: "https://benchmark.heyblog.local/indie-web-02/", + domain: "indie-web-02.benchmark.heyblog.local", + title: "Indie Web Notes 02", + icon_url: null, + incoming_count: 1, + outgoing_count: 1, + degree: 2, + component_id: "indie-web", + }, + ], + edges: [ + { + id: "benchmark-edge-001", + from_blog_id: 1, + to_blog_id: 2, + link_text: "blogroll", + link_url_raw: "https://benchmark.heyblog.local/indie-web-02/", + }, + ], + meta: { + strategy: "synthetic-community-benchmark", + limit: 2, + total_nodes: 2, + total_edges: 1, + }, + }), + ); + } throw new Error(`Unhandled fetch: ${url.toString()}`); }); @@ -487,6 +532,22 @@ test("defaults visualization slider to two hundred when the blog count is larger expect(slider).toHaveValue("200"); }); +test("loads the static clustered benchmark graph through the visualization route", async () => { + window.history.replaceState({}, "", "/visualization/benchmark"); + + render(); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/benchmarks/blog-community-graph.json"), + expect.anything(), + ); + }); + expect(screen.queryByRole("dialog", { name: "选择图谱规模" })).not.toBeInTheDocument(); + expect(forceGraphProps.at(-1)!.graphData.nodes).toHaveLength(2); + expect(forceGraphProps.at(-1)!.graphData.links).toHaveLength(1); +}); + test("adds a public filter stats route that renders success-source split", async () => { window.history.replaceState({}, "", "/filter-stats"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23a2772..eff52d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/GraphVisualization.test.tsx b/frontend/src/components/GraphVisualization.test.tsx index 481d357..cc6ca18 100644 --- a/frontend/src/components/GraphVisualization.test.tsx +++ b/frontend/src/components/GraphVisualization.test.tsx @@ -204,8 +204,7 @@ describe("GraphVisualization", () => { id: "2", blogId: 2, label: "Beta Blog", - iconUrl: - "/api/icons/proxy?url=https%3A%2F%2Ft2.gstatic.com%2FfaviconV2%3Fclient%3DSOCIAL%26type%3DFAVICON%26fallback_opts%3DTYPE%2CSIZE%2CURL%26url%3Dhttps%3A%2F%2Fbeta.example.com%26size%3D64", + iconUrl: undefined, val: 1, }), ]), @@ -301,6 +300,20 @@ describe("GraphVisualization", () => { expect(nodeObject.userData.iconUrl).toBe("/api/icons/proxy?url=https%3A%2F%2Falpha.example.com%2Ffavicon.ico"); }); + test("renders iconless nodes as neutral gray spheres", () => { + render(); + + const graphProps = forceGraphRenders.at(-1); + const iconlessNode = graphProps!.graphData.nodes[1]; + const nodeObject = graphProps!.nodeThreeObject(iconlessNode); + const core = nodeObject.children[1] as any; + + expect(iconlessNode.iconUrl).toBeUndefined(); + expect(nodeObject.children).toHaveLength(2); + expect(nodeObject.userData.iconUrl).toBeUndefined(); + expect(core.material.color.getHexString()).toBe("94a3b8"); + }); + test("tunes forces for natural clusters instead of a centered sphere", () => { const graph = { d3Force: vi.fn((name: string, force?: unknown) => { @@ -319,10 +332,10 @@ describe("GraphVisualization", () => { tuneNaturalClusterForces(graph as never); expect(forceCalls).toContainEqual(["center", null]); - expect(chargeForce.strength).toHaveBeenCalledWith(-118); - expect(chargeForce.distanceMax).toHaveBeenCalledWith(820); - expect(linkForce.distance).toHaveBeenCalledWith(72); - expect(linkForce.strength).toHaveBeenCalledWith(0.42); + expect(chargeForce.strength).toHaveBeenCalledWith(-190); + expect(chargeForce.distanceMax).toHaveBeenCalledWith(720); + expect(linkForce.distance).toHaveBeenCalledWith(58); + expect(linkForce.strength).toHaveBeenCalledWith(0.56); expect(d3ReheatSimulation).toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/GraphVisualization.tsx b/frontend/src/components/GraphVisualization.tsx index 5f5359f..4f5464f 100644 --- a/frontend/src/components/GraphVisualization.tsx +++ b/frontend/src/components/GraphVisualization.tsx @@ -2,14 +2,14 @@ import { RotateCcw, ZoomIn, ZoomOut } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ForceGraph3D, { type ForceGraphMethods } from "react-force-graph-3d"; import * as THREE from "three"; -import { resolveProxiedBlogIconUrls } from "../lib/icon"; +import { resolveIconProxyUrl } from "../lib/icon"; import type { GraphData, GraphEdge, GraphNode } from "../types/graph"; export const GRAPH_RENDER_COOLDOWN_TICKS = 120; -const GRAPH_LINK_DISTANCE = 72; -const GRAPH_LINK_STRENGTH = 0.42; -const GRAPH_CHARGE_STRENGTH = -118; -const GRAPH_CHARGE_DISTANCE_MAX = 820; +const GRAPH_LINK_DISTANCE = 58; +const GRAPH_LINK_STRENGTH = 0.56; +const GRAPH_CHARGE_STRENGTH = -190; +const GRAPH_CHARGE_DISTANCE_MAX = 720; interface GraphVisualizationProps { data: GraphData; @@ -17,6 +17,7 @@ interface GraphVisualizationProps { highlightNodeId?: number; onRenderProgress?: (progress: number) => void; onRenderComplete?: () => void; + useNodeIcons?: boolean; } interface RenderNode extends Omit { @@ -51,7 +52,15 @@ function targetIdOf(link: RenderLink): string { return typeof link.target === "object" ? link.target.id : String(link.target); } -function buildGraphData(data: GraphData): RenderGraphData { +function buildExplicitIconUrls(node: GraphNode, useNodeIcons: boolean): string[] { + const iconUrl = node.iconUrl?.trim(); + if (!useNodeIcons || !iconUrl) { + return []; + } + return [resolveIconProxyUrl(iconUrl)]; +} + +function buildGraphData(data: GraphData, useNodeIcons: boolean): RenderGraphData { const nodesById = new Map(); for (const node of data.nodes) { @@ -59,7 +68,7 @@ function buildGraphData(data: GraphData): RenderGraphData { if (!id) { continue; } - const iconUrls = resolveProxiedBlogIconUrls(node); + const iconUrls = buildExplicitIconUrls(node, useNodeIcons); nodesById.set(id, { ...node, id, @@ -131,12 +140,6 @@ function colorForNode(node: RenderNode, highlightNodeId?: number, neighborIds?: if (highlightNodeId !== undefined) { return "#334155"; } - if ((node.incomingCount ?? 0) > (node.outgoingCount ?? 0)) { - return "#fbbf24"; - } - if ((node.outgoingCount ?? 0) > 0) { - return "#818cf8"; - } return "#94a3b8"; } @@ -256,13 +259,14 @@ export function GraphVisualization({ highlightNodeId, onRenderProgress, onRenderComplete, + useNodeIcons = true, }: GraphVisualizationProps) { const graphRef = useRef | undefined>(undefined); const containerRef = useRef(null); const renderTickRef = useRef(0); const [size, setSize] = useState({ width: 960, height: 720 }); const [isMeasured, setIsMeasured] = useState(false); - const graphData = useMemo(() => buildGraphData(data), [data]); + const graphData = useMemo(() => buildGraphData(data, useNodeIcons), [data, useNodeIcons]); const neighborIds = useMemo(() => buildNeighborIds(graphData, highlightNodeId), [graphData, highlightNodeId]); const selectedGraphId = highlightNodeId === undefined ? undefined : String(highlightNodeId); diff --git a/frontend/src/lib/benchmarkGraph.ts b/frontend/src/lib/benchmarkGraph.ts new file mode 100644 index 0000000..75c9daf --- /dev/null +++ b/frontend/src/lib/benchmarkGraph.ts @@ -0,0 +1,128 @@ +import type { GraphData, GraphEdge, GraphMeta, GraphNode } from "../types/graph"; + +interface BenchmarkGraphNodePayload { + id: number; + url: string; + domain: string; + title: string | null; + icon_url: string | null; + incoming_count?: number; + outgoing_count?: number; + degree?: number; + component_id?: string; + x?: number; + y?: number; + z?: number; +} + +interface BenchmarkGraphEdgePayload { + id?: number | string; + from_blog_id: number; + to_blog_id: number; + link_text: string | null; + link_url_raw: string; +} + +interface BenchmarkGraphPayload { + nodes: BenchmarkGraphNodePayload[]; + edges: BenchmarkGraphEdgePayload[]; + meta?: { + strategy: string; + limit: number; + generated_at?: string; + source?: string; + total_nodes?: number; + total_edges?: number; + available_nodes?: number; + available_edges?: number; + selected_nodes?: number; + selected_edges?: number; + }; +} + +/** + * Convert a static benchmark node payload to the frontend graph node model. + * + * @param node Raw benchmark node using backend field names. + * @returns Normalized graph node. + */ +function toBenchmarkNode(node: BenchmarkGraphNodePayload): GraphNode { + return { + id: Number(node.id), + url: node.url, + domain: node.domain, + title: node.title, + iconUrl: node.icon_url, + incomingCount: node.incoming_count, + outgoingCount: node.outgoing_count, + degree: node.degree, + componentId: node.component_id, + x: node.x, + y: node.y, + z: node.z, + }; +} + +/** + * Convert a static benchmark edge payload to the frontend graph edge model. + * + * @param edge Raw benchmark edge using backend field names. + * @param index Fallback edge index. + * @returns Normalized graph edge. + */ +function toBenchmarkEdge(edge: BenchmarkGraphEdgePayload, index: number): GraphEdge { + return { + id: edge.id ? String(edge.id) : `benchmark-edge-${index}`, + source: Number(edge.from_blog_id), + target: Number(edge.to_blog_id), + linkText: edge.link_text, + linkUrlRaw: edge.link_url_raw, + }; +} + +/** + * Convert static benchmark metadata to the frontend graph meta model. + * + * @param meta Raw benchmark metadata. + * @returns Normalized graph metadata. + */ +function toBenchmarkMeta(meta: BenchmarkGraphPayload["meta"]): GraphMeta | undefined { + if (!meta) { + return undefined; + } + return { + strategy: meta.strategy, + limit: meta.limit, + generatedAt: meta.generated_at, + source: meta.source, + totalNodes: meta.total_nodes, + totalEdges: meta.total_edges, + availableNodes: meta.available_nodes, + availableEdges: meta.available_edges, + selectedNodes: meta.selected_nodes, + selectedEdges: meta.selected_edges, + }; +} + +/** + * Fetch the static visualization benchmark graph. + * + * @returns Normalized graph data for the shared 3D visualization component. + */ +export async function fetchBenchmarkGraphData(): Promise { + const response = await fetch("/benchmarks/blog-community-graph.json", { + headers: { + accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error(`benchmark_graph_error_${response.status}`); + } + + const payload = (await response.json()) as BenchmarkGraphPayload; + return { + nodes: payload.nodes.map(toBenchmarkNode), + edges: payload.edges.map(toBenchmarkEdge), + meta: toBenchmarkMeta(payload.meta), + }; +} diff --git a/frontend/src/pages/AboutPage.tsx b/frontend/src/pages/AboutPage.tsx index 4c0b7d2..eccfd7d 100644 --- a/frontend/src/pages/AboutPage.tsx +++ b/frontend/src/pages/AboutPage.tsx @@ -8,7 +8,7 @@ import avatarImage from "../assets/images/avatar.png"; */ export function AboutPage() { return ( -
+
@@ -57,8 +57,8 @@ export function AboutPage() {
-
-
+
+
HeyBlog avatar({ nodes: [], edges: [] }); const [blogDetail, setBlogDetail] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -34,8 +37,12 @@ export function VisualizationPage() { }, [isLoading, renderProgress]); useEffect(() => { + if (isBenchmarkMode) { + void loadBenchmarkGraph(); + return; + } void loadGraphLimitBounds(); - }, []); + }, [isBenchmarkMode]); useEffect(() => { const highlight = searchParams.get("highlight"); @@ -70,6 +77,37 @@ export function VisualizationPage() { } } + /** + * Load the deterministic clustered graph benchmark from static frontend assets. + * + * @returns Promise resolved after benchmark graph state updates. + */ + async function loadBenchmarkGraph() { + setSelectedLimit(100); + setPendingLimit(100); + setMaxGraphLimit(100); + setBlogDetail(null); + setHighlightNodeId(undefined); + setIsRendering(false); + setRenderProgress(0); + + try { + setIsStatsLoading(false); + setIsLoading(true); + const benchmarkGraph = await fetchBenchmarkGraphData(); + setRenderProgress(0.12); + setIsRendering(true); + setGraphData(benchmarkGraph); + } catch { + setSelectedLimit(null); + setIsRendering(false); + setRenderProgress(0); + toast.error("Benchmark 图谱加载失败,请先运行生成脚本。"); + } finally { + setIsLoading(false); + } + } + /** * Load the selected graph size using deterministic backend sampling. * @@ -107,6 +145,22 @@ export function VisualizationPage() { * @returns Promise resolved after all requested data is loaded. */ async function openBlog(blogId: number, options: { loadNeighborhood: boolean }) { + if (isBenchmarkMode) { + const node = graphData.nodes.find((item) => item.id === blogId); + if (!node) { + return; + } + setBlogDetail({ + ...node, + incomingLinks: node.incomingCount ?? 0, + outgoingLinks: node.outgoingCount ?? 0, + relatedNodes: [], + recommendedBlogs: [], + }); + setHighlightNodeId(blogId); + return; + } + try { const detail = await fetchBlogDetail(blogId); setBlogDetail(detail); @@ -151,6 +205,7 @@ export function VisualizationPage() { data={graphData} onNodeClick={handleNodeClick} highlightNodeId={highlightNodeId} + useNodeIcons={!isBenchmarkMode} onRenderProgress={(progress) => setRenderProgress((current) => Math.max(current, progress))} onRenderComplete={() => { setRenderProgress(1); diff --git a/frontend/src/types/graph.ts b/frontend/src/types/graph.ts index 2ce2046..5f4a670 100644 --- a/frontend/src/types/graph.ts +++ b/frontend/src/types/graph.ts @@ -10,6 +10,7 @@ export interface GraphNode { description?: string | null; x?: number; y?: number; + z?: number; degree?: number; incomingCount?: number; outgoingCount?: number; diff --git a/graph-icons-debug.png b/graph-icons-debug.png deleted file mode 100644 index ae03e02a0b0cbd8445df473e84a5e5517f2024a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 144868 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�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 diff --git a/scripts/generate_visualization_benchmark.py b/scripts/generate_visualization_benchmark.py new file mode 100644 index 0000000..a7a9323 --- /dev/null +++ b/scripts/generate_visualization_benchmark.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Generate a deterministic clustered graph payload for visualization QA. + +The benchmark uses a seeded stochastic-block-model style construction: +blogs are assigned to planted communities, intra-community edges are sampled +with a higher probability than inter-community bridges, and a few hub blogs are +given extra outgoing links. This mirrors the practical idea behind LFR-style +community-detection benchmarks: a known community assignment plus a controllable +mixing rate for cross-community edges. +""" + +from __future__ import annotations + +import argparse +import json +import math +import random +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +DEFAULT_OUTPUT = Path("frontend/public/benchmarks/blog-community-graph.json") +DEFAULT_SEED = 42 +COMMUNITIES = [ + ("indie-web", "Indie Web", 24), + ("engineering", "Engineering", 22), + ("design", "Design", 18), + ("data-ai", "Data & AI", 20), + ("culture", "Culture", 16), +] + + +@dataclass(frozen=True) +class BlogNode: + """Synthetic blog node emitted in backend-compatible graph JSON form. + + Attributes: + id: Stable numeric blog id. + slug: URL-safe blog slug. + title: Human-readable blog title. + community_id: Planted benchmark community id. + community_label: Human-readable community label. + """ + + id: int + slug: str + title: str + community_id: str + community_label: str + + +def parse_args() -> argparse.Namespace: + """Parse command-line options for benchmark graph generation. + + Returns: + Parsed argparse namespace containing output path, seed, and edge rates. + """ + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="JSON output path.") + parser.add_argument("--seed", type=int, default=DEFAULT_SEED, help="Deterministic random seed.") + parser.add_argument("--intra-probability", type=float, default=0.34, help="Same-community edge probability.") + parser.add_argument("--inter-probability", type=float, default=0.002, help="Cross-community edge probability.") + parser.add_argument("--hub-links", type=int, default=1, help="Extra cross-community links per community hub.") + return parser.parse_args() + + +def make_slug(label: str, index: int) -> str: + """Build a stable blog slug from a community label and ordinal. + + Args: + label: Human-readable community label. + index: One-based blog index within that community. + + Returns: + URL-safe synthetic blog slug. + """ + + return f"{label.lower().replace(' & ', '-').replace(' ', '-')}-{index:02d}" + + +def build_nodes() -> list[BlogNode]: + """Create the planted blog communities. + + Returns: + List of 100 synthetic blog nodes split across five communities. + """ + + nodes: list[BlogNode] = [] + next_id = 1 + for community_id, community_label, size in COMMUNITIES: + for index in range(1, size + 1): + slug = make_slug(community_label, index) + nodes.append( + BlogNode( + id=next_id, + slug=slug, + title=f"{community_label} Notes {index:02d}", + community_id=community_id, + community_label=community_label, + ) + ) + next_id += 1 + return nodes + + +def edge_key(source: int, target: int) -> tuple[int, int]: + """Normalize a directed edge pair for duplicate checks. + + Args: + source: Source blog id. + target: Target blog id. + + Returns: + Directed edge identity tuple. + """ + + return (source, target) + + +def add_edge(edges: dict[tuple[int, int], dict[str, Any]], source: BlogNode, target: BlogNode, link_text: str) -> None: + """Add one directed edge unless it already exists or is a self-link. + + Args: + edges: Mutable edge dictionary keyed by directed source/target ids. + source: Source blog node. + target: Target blog node. + link_text: Synthetic friend-link label. + """ + + if source.id == target.id: + return + key = edge_key(source.id, target.id) + if key in edges: + return + edges[key] = { + "from_blog_id": source.id, + "to_blog_id": target.id, + "link_text": link_text, + "link_url_raw": f"https://benchmark.heyblog.local/{target.slug}/", + } + + +def build_edges( + nodes: list[BlogNode], + rng: random.Random, + intra_probability: float, + inter_probability: float, + hub_links: int, +) -> list[dict[str, Any]]: + """Sample benchmark edges with strong planted community structure. + + Args: + nodes: Synthetic blog nodes. + rng: Seeded random number generator. + intra_probability: Same-community edge probability. + inter_probability: Cross-community edge probability. + hub_links: Extra bridge count added from each community hub. + + Returns: + Backend-compatible edge dictionaries. + """ + + edges: dict[tuple[int, int], dict[str, Any]] = {} + by_community: dict[str, list[BlogNode]] = {} + for node in nodes: + by_community.setdefault(node.community_id, []).append(node) + + for community_nodes in by_community.values(): + for index, source in enumerate(community_nodes): + target = community_nodes[(index + 1) % len(community_nodes)] + add_edge(edges, source, target, "blogroll") + + for source_index, source in enumerate(nodes): + for target in nodes[source_index + 1 :]: + probability = intra_probability if source.community_id == target.community_id else inter_probability + if rng.random() >= probability: + continue + if rng.random() < 0.5: + add_edge(edges, source, target, "friend link") + else: + add_edge(edges, target, source, "friend link") + + for community_nodes in by_community.values(): + hub = community_nodes[0] + outside_nodes = [node for node in nodes if node.community_id != hub.community_id] + for target in rng.sample(outside_nodes, k=min(hub_links, len(outside_nodes))): + add_edge(edges, hub, target, "community bridge") + + sorted_edges = sorted(edges.values(), key=lambda edge: (edge["from_blog_id"], edge["to_blog_id"])) + for index, edge in enumerate(sorted_edges, start=1): + edge["id"] = f"benchmark-edge-{index:03d}" + return sorted_edges + + +def degree_counts(nodes: list[BlogNode], edges: list[dict[str, Any]]) -> dict[int, dict[str, int]]: + """Calculate directed degree counts for frontend visual weighting. + + Args: + nodes: Synthetic blog nodes. + edges: Generated directed edge list. + + Returns: + Mapping from blog id to incoming/outgoing/total degree counters. + """ + + counts = {node.id: {"incoming": 0, "outgoing": 0, "degree": 0} for node in nodes} + for edge in edges: + source = int(edge["from_blog_id"]) + target = int(edge["to_blog_id"]) + counts[source]["outgoing"] += 1 + counts[target]["incoming"] += 1 + counts[source]["degree"] += 1 + counts[target]["degree"] += 1 + return counts + + +def community_centers() -> dict[str, tuple[float, float, float]]: + """Return fixed 3D centers that make planted communities visually separate. + + Returns: + Mapping from community id to deterministic x/y/z layout center. + """ + + return { + "indie-web": (-520.0, -260.0, 0.0), + "engineering": (520.0, -260.0, 0.0), + "design": (-520.0, 300.0, 0.0), + "data-ai": (520.0, 300.0, 0.0), + "culture": (0.0, 40.0, 520.0), + } + + +def node_position(node: BlogNode, index: int, rng: random.Random) -> dict[str, float]: + """Place one benchmark node near its planted community center. + + Args: + node: Synthetic blog node to position. + index: Zero-based node index used for deterministic angular spread. + rng: Seeded random number generator for small jitter. + + Returns: + Mapping containing x, y, and z coordinates. + """ + + center_x, center_y, center_z = community_centers()[node.community_id] + angle = (index * 2.399963229728653) % 6.283185307179586 + radius = 42.0 + (index % 5) * 15.0 + rng.uniform(-8.0, 8.0) + z_jitter = rng.uniform(-36.0, 36.0) + return { + "x": round(center_x + radius * math.cos(angle), 3), + "y": round(center_y + radius * math.sin(angle), 3), + "z": round(center_z + z_jitter, 3), + } + + +def to_payload( + nodes: list[BlogNode], + edges: list[dict[str, Any]], + seed: int, + intra_probability: float, + inter_probability: float, +) -> dict[str, Any]: + """Build the backend-compatible benchmark graph payload. + + Args: + nodes: Synthetic blog nodes. + edges: Generated directed edge list. + seed: Random seed used for reproducibility. + intra_probability: Same-community edge probability. + inter_probability: Cross-community edge probability. + + Returns: + JSON-serializable graph payload consumed by the frontend. + """ + + counts = degree_counts(nodes, edges) + position_rng = random.Random(seed + 1009) + generated_at = datetime.now(timezone.utc).isoformat() + graph_nodes = [] + for index, node in enumerate(nodes): + node_counts = counts[node.id] + graph_nodes.append( + { + "id": node.id, + "url": f"https://benchmark.heyblog.local/{node.slug}/", + "domain": f"{node.slug}.benchmark.heyblog.local", + "title": node.title, + "icon_url": None, + "incoming_count": node_counts["incoming"], + "outgoing_count": node_counts["outgoing"], + "degree": node_counts["degree"], + "component_id": node.community_id, + "benchmark_community_label": node.community_label, + **node_position(node, index, position_rng), + } + ) + + return { + "nodes": graph_nodes, + "edges": edges, + "meta": { + "strategy": "synthetic-community-benchmark", + "limit": len(nodes), + "source": "scripts/generate_visualization_benchmark.py", + "generated_at": generated_at, + "total_nodes": len(nodes), + "total_edges": len(edges), + "selected_nodes": len(nodes), + "selected_edges": len(edges), + "available_nodes": len(nodes), + "available_edges": len(edges), + "benchmark": { + "seed": seed, + "model": "seeded stochastic block model inspired by LFR mixing-parameter benchmarks", + "community_sizes": {community_id: size for community_id, _label, size in COMMUNITIES}, + "intra_probability": intra_probability, + "inter_probability": inter_probability, + "estimated_mixing_rate": round(inter_probability / (intra_probability + inter_probability), 3), + "layout": "fixed separated community centers with deterministic jitter", + }, + }, + } + + +def main() -> None: + """Generate the benchmark graph JSON file on disk.""" + + args = parse_args() + rng = random.Random(args.seed) + nodes = build_nodes() + edges = build_edges(nodes, rng, args.intra_probability, args.inter_probability, args.hub_links) + payload = to_payload(nodes, edges, args.seed, args.intra_probability, args.inter_probability) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"Wrote {len(nodes)} nodes and {len(edges)} edges to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_visualization_benchmark.sh b/scripts/run_visualization_benchmark.sh new file mode 100755 index 0000000..e43b583 --- /dev/null +++ b/scripts/run_visualization_benchmark.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PORT="${1:-3001}" +HOST="${HOST:-127.0.0.1}" + +echo "Generating visualization benchmark graph..." +python3 "$ROOT_DIR/scripts/generate_visualization_benchmark.py" + +echo +echo "Starting HeyBlog frontend benchmark server..." +echo "Benchmark URL: http://$HOST:$PORT/visualization/benchmark" +echo "Stop server: Ctrl+C" +echo + +cd "$ROOT_DIR/frontend" +npm run dev -- --host "$HOST" --port "$PORT" diff --git a/tests/test_visualization_benchmark.py b/tests/test_visualization_benchmark.py new file mode 100644 index 0000000..0eb29f5 --- /dev/null +++ b/tests/test_visualization_benchmark.py @@ -0,0 +1,34 @@ +import json +from pathlib import Path + +from scripts.generate_visualization_benchmark import main + + +def test_visualization_benchmark_has_planted_communities(tmp_path: Path, monkeypatch) -> None: + """Generated benchmark should contain 100 clustered blogs and sparse bridges.""" + + output = tmp_path / "benchmark.json" + monkeypatch.setattr( + "sys.argv", + ["generate_visualization_benchmark.py", "--output", str(output)], + ) + + main() + + payload = json.loads(output.read_text(encoding="utf-8")) + nodes = payload["nodes"] + edges = payload["edges"] + community_by_id = {node["id"]: node["component_id"] for node in nodes} + internal_edges = [ + edge + for edge in edges + if community_by_id[edge["from_blog_id"]] == community_by_id[edge["to_blog_id"]] + ] + bridge_edges = [edge for edge in edges if edge not in internal_edges] + + assert len(nodes) == 100 + assert 420 <= len(edges) <= 560 + assert len(internal_edges) > len(bridge_edges) * 12 + assert len(bridge_edges) <= 35 + assert payload["meta"]["benchmark"]["seed"] == 42 + assert all({"x", "y", "z"}.issubset(node) for node in nodes) From 63406a2e56687a5e9624b287021f0e66f23d746e Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Fri, 5 Jun 2026 20:34:17 +0100 Subject: [PATCH 08/35] =?UTF-8?q?=F0=9F=90=9E=20fix:=20ruff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/filters.py | 2 -- pyproject.toml | 1 + tests/test_repository.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/crawler/filters.py b/crawler/filters.py index abd5f76..956e88d 100644 --- a/crawler/filters.py +++ b/crawler/filters.py @@ -6,8 +6,6 @@ from urllib.parse import urlparse from crawler.crawling.decisions.rule_helpers import BLOCKED_TLDS -from crawler.crawling.decisions.rule_helpers import FILE_SUFFIX_BLOCKLIST -from crawler.crawling.decisions.rule_helpers import PATH_BLOCKLIST from crawler.crawling.decisions.rule_helpers import PLATFORM_BLOCKLIST from crawler.crawling.decisions.rule_helpers import has_asset_suffix from crawler.crawling.decisions.rule_helpers import has_extra_location_parts diff --git a/pyproject.toml b/pyproject.toml index ac78163..f0d5918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ runtime-models = [ ] dev = [ "pytest>=8.3,<9", + "ruff>=0.8,<1", ] [tool.pytest.ini_options] diff --git a/tests/test_repository.py b/tests/test_repository.py index ec3ee3a..54360ba 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1894,7 +1894,6 @@ def test_repository_blog_label_counts_use_all_persisted_url_labels(tmp_path: Pat blog_tag = repository.create_blog_label_tag(name="blog") company_tag = repository.create_blog_label_tag(name="company") other_tag = repository.create_blog_label_tag(name="other") - unknown_tag = repository.create_blog_label_tag(name="unknown") timestamp = repository_module.now_utc() with session_scope(repository.session_factory) as session: session.add_all( From d7f18c1e46ec753b8b95199537f97e257c1247ab Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Fri, 5 Jun 2026 21:08:13 +0100 Subject: [PATCH 09/35] =?UTF-8?q?=F0=9F=93=83=20docs:=20readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- readme.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/readme.md b/readme.md index d54162d..637f9a9 100644 --- a/readme.md +++ b/readme.md @@ -23,6 +23,8 @@ 2026年6月3日,一觉醒来从 9 star变成了11 star,突破两位数,开心 +2026年6月6日,发现有13人注册了用户,有点想哭,开心 + ## 文档导航 @@ -37,6 +39,10 @@ ## Quick Start +### 0. 推荐 +启动codex、claude code或任意vibecoding工具,然后:请完整阅读该项目确保你了解该项目,然后配置合理的.env文件后docker本地部署 + + ### 1. 仅 API / 后端最小路径 当你只想调试 HTTP 协议、聚合层行为,或者暂时不需要浏览器界面时,走这条路径最合适。 From 4880a8edb1aafde149f9a88f97712b184fdf5f6c Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Fri, 5 Jun 2026 21:08:26 +0100 Subject: [PATCH 10/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.test.tsx | 146 ++++----------------- frontend/src/pages/HomePage.tsx | 220 +++----------------------------- 2 files changed, 38 insertions(+), 328 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b068b84..0e2fac4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -261,144 +261,42 @@ afterEach(() => { vi.useRealTimers(); }); -test("renders paginated home cards, reloads from server for filters, and refreshes statuses by polling", async () => { +test("renders the home summary without queue metrics, status filters, or catalog cards", async () => { render(); await waitFor(() => { expect(screen.getByRole("heading", { name: "HeyBlog!" })).toBeInTheDocument(); }); expect(screen.getByText("基于友链爬取所有博客!")).toBeInTheDocument(); - - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=30&sort=id_asc&status=PROCESSING"), - expect.anything(), - ); - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=30&sort=id_asc&status=WAITING"), - expect.anything(), - ); - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=30&sort=id_asc&status=FINISHED"), - expect.anything(), - ); - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=30&sort=id_asc&status=FAILED"), - expect.anything(), - ); - expect(screen.getByText("Processing Blog")).toBeInTheDocument(); - expect(screen.getByText("Newest Processing Blog")).toBeInTheDocument(); - expect(screen.getByText("Waiting Blog")).toBeInTheDocument(); - expect(screen.getByText("当前显示第 1 / 2 页,本页 30 个,共 34 个博客")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "PROCESSING" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "FAILED" })).toBeInTheDocument(); - const titles = screen.getAllByRole("heading", { level: 3 }).map((node) => node.textContent); - expect(titles.slice(0, 4)).toEqual(["Processing Blog", "Newest Processing Blog", "Waiting Blog", "Newest Waiting Blog"]); - - fireEvent.click(screen.getByRole("button", { name: "FAILED" })); - - await waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=30&sort=id_desc&status=FAILED"), - expect.anything(), - ); - }); - expect(screen.getByText("Failed Blog")).toBeInTheDocument(); + expect(screen.getByText("总节点数")).toBeInTheDocument(); + expect(screen.getByText("总连接数")).toBeInTheDocument(); + expect(screen.getByText("34")).toBeInTheDocument(); + expect(screen.getByText("10")).toBeInTheDocument(); + expect(screen.queryByText("待处理队列")).not.toBeInTheDocument(); + expect(screen.queryByText("处理中 / 失败")).not.toBeInTheDocument(); + expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining("/api/status"), expect.anything()); + + expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining("/api/blogs/catalog"), expect.anything()); + expect(screen.queryByRole("button", { name: "ALL" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "PROCESSING" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "WAITING" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "FINISHED" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "FAILED" })).not.toBeInTheDocument(); expect(screen.queryByText("Processing Blog")).not.toBeInTheDocument(); - expect(screen.getByText("当前显示第 1 / 1 页,本页 1 个,共 1 个博客")).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "WAITING" })); - - await waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=30&sort=id_asc&status=WAITING"), - expect.anything(), - ); - }); - const waitingTitles = screen.getAllByRole("heading", { level: 3 }).map((node) => node.textContent); - expect(waitingTitles.slice(0, 2)).toEqual(["Waiting Blog", "Newest Waiting Blog"]); - - fireEvent.click(screen.getByRole("button", { name: "ALL" })); - - await waitFor(() => { - expect(screen.getByText("Processing Blog")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: "下一页" })); - - await waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=60&sort=id_asc&status=PROCESSING"), - expect.anything(), - ); - }); - expect(screen.getByText("Failed Blog")).toBeInTheDocument(); - expect(screen.getByText("Extra Blog 32")).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "PROCESSING" })); - - await waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=30&sort=id_desc&status=PROCESSING"), - expect.anything(), - ); - }); - expect(screen.getByText("Newest Processing Blog")).toBeInTheDocument(); - expect(screen.getByText("Processing Blog")).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "ALL" })); - - await waitFor(() => { - expect(screen.getByText("Waiting Blog")).toBeInTheDocument(); - }); - - catalogItems = catalogItems.map((item) => - item.id === 1 ? { ...item, crawl_status: "FINISHED", status_code: 200, last_crawled_at: "2026-04-17T10:00:00Z" } : item, - ); - statusPayload = { - ...statusPayload, - pending_tasks: 2, - processing_tasks: 1, - finished_tasks: 31, - }; + expect(screen.queryByText("Waiting Blog")).not.toBeInTheDocument(); + expect(screen.queryByText("Finished Blog")).not.toBeInTheDocument(); + expect(screen.queryByText("Failed Blog")).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText(/输入 URL 或标题进行搜索/i)).not.toBeInTheDocument(); await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); await waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=30&sort=id_asc&status=PROCESSING"), - expect.anything(), - ); - }); - - fireEvent.click(screen.getByRole("button", { name: "PROCESSING" })); - - await waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=30&sort=id_desc&status=PROCESSING"), - expect.anything(), - ); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/api/stats"), expect.anything()); }); - expect(screen.getByText("Newest Processing Blog")).toBeInTheDocument(); - expect(screen.queryByText("Processing Blog")).not.toBeInTheDocument(); - - fireEvent.change(screen.getByPlaceholderText(/输入 URL 或标题进行搜索/i), { - target: { value: "Newest" }, - }); - fireEvent.click(screen.getByRole("button", { name: /搜索博客/i })); - - await waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=30&q=Newest&sort=id_desc&status=PROCESSING"), - expect.anything(), - ); - }); - expect(screen.getByText("Newest Processing Blog")).toBeInTheDocument(); - expect(screen.queryByText("Processing Blog")).not.toBeInTheDocument(); - expect(screen.queryByText("Newest Waiting Blog")).not.toBeInTheDocument(); - expect(screen.queryByText("Waiting Blog")).not.toBeInTheDocument(); - expect(screen.getByText("搜索词: Newest")).toBeInTheDocument(); + expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining("/api/blogs/catalog"), expect.anything()); + expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining("/api/status"), expect.anything()); }); test("adds a random blog route that loads nine finished cards and refreshes them on demand", async () => { diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index c38fcc7..40da777 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,86 +1,21 @@ -import { Loader2, Network, GitBranch, Radar, TimerReset } from "lucide-react"; +import { Loader2, Network, GitBranch } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { BlogCard } from "../components/BlogCard"; import { Navigation } from "../components/Navigation"; -import { SearchBar } from "../components/SearchBar"; -import { fetchBlogsCatalog, fetchStats, fetchStatus } from "../lib/api"; -import type { BlogCatalogPage, StatsData, StatusData } from "../types/graph"; +import { fetchStats } from "../lib/api"; +import type { StatsData } from "../types/graph"; -const DEFAULT_PAGE_SIZE = 30; const HOME_REFRESH_INTERVAL_MS = 5000; -const HOME_STATUS_ORDER = ["PROCESSING", "WAITING", "FINISHED", "FAILED"] as const; -const HOME_STATUS_FILTERS = ["ALL", ...HOME_STATUS_ORDER] as const; - -type HomeStatusFilter = (typeof HOME_STATUS_FILTERS)[number]; /** - * Load one synthetic "ALL" page by concatenating status buckets in priority order. - * - * Each bucket is read directly from the catalog API and keeps ascending blog-id - * ordering inside the bucket. - * - * @param page Current homepage page number. - * @param pageSize Maximum number of cards per page. - * @param searchQuery Optional fuzzy-search keyword applied to the catalog query. - * @returns One combined catalog page. - */ -async function fetchAllStatusCatalogPage( - page: number, - pageSize: number, - searchQuery: string, -): Promise { - const takeCount = page * pageSize; - const responses = await Promise.allSettled( - HOME_STATUS_ORDER.map((status) => - fetchBlogsCatalog({ - page: 1, - pageSize: takeCount, - q: searchQuery || undefined, - sort: "id_asc", - status, - }), - ), - ); - const fulfilledResponses = responses - .filter((response): response is PromiseFulfilledResult => response.status === "fulfilled") - .map((response) => response.value); - if (fulfilledResponses.length === 0) { - throw new Error("all_catalog_buckets_failed"); - } - - const mergedItems = fulfilledResponses.flatMap((response) => response.items); - const offset = (page - 1) * pageSize; - const totalItems = fulfilledResponses.reduce((sum, response) => sum + response.totalItems, 0); - const totalPages = totalItems > 0 ? Math.ceil(totalItems / pageSize) : 0; - - return { - items: mergedItems.slice(offset, offset + pageSize), - page, - pageSize, - totalItems, - totalPages, - hasNext: page < totalPages, - hasPrev: page > 1, - sort: "home_status_priority_asc", - }; -} - -/** - * Render the public home page with stats, search, and card-based blog discovery. + * Render the public home page summary without the status-filtered blog catalog. * * @returns Home route UI. */ export function HomePage() { - const [catalog, setCatalog] = useState(null); const [stats, setStats] = useState({ totalNodes: 0, totalEdges: 0 }); - const [status, setStatus] = useState(null); const [isInitialLoading, setIsInitialLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); - const [isSearching, setIsSearching] = useState(false); - const [statusFilter, setStatusFilter] = useState("ALL"); - const [currentPage, setCurrentPage] = useState(1); - const [searchQuery, setSearchQuery] = useState(""); const refreshInFlightRef = useRef(false); const hasLoadedOnceRef = useRef(false); @@ -90,13 +25,10 @@ export function HomePage() { hasLoadedOnceRef.current = true; } void loadHomePage({ - page: currentPage, - searchQuery, - statusFilter, showInitialLoading: isFirstLoad, showRefreshState: !isFirstLoad, }); - }, [currentPage, searchQuery, statusFilter]); + }, []); useEffect(() => { let isDisposed = false; @@ -106,9 +38,6 @@ export function HomePage() { return; } await loadHomePage({ - page: currentPage, - searchQuery, - statusFilter, showInitialLoading: false, showRefreshState: true, showErrorToast: false, @@ -122,9 +51,6 @@ export function HomePage() { function handleVisibilityChange() { if (document.visibilityState === "visible") { void loadHomePage({ - page: currentPage, - searchQuery, - statusFilter, showInitialLoading: false, showRefreshState: true, showErrorToast: false, @@ -138,18 +64,15 @@ export function HomePage() { window.clearInterval(intervalId); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [currentPage, searchQuery, statusFilter]); + }, []); /** - * Load the home page summary and one catalog page. + * Load the home page summary metrics. * * @param options Loading behavior flags. * @returns Promise resolved when the homepage state finishes updating. */ async function loadHomePage(options?: { - page?: number; - searchQuery?: string; - statusFilter?: HomeStatusFilter; showInitialLoading?: boolean; showRefreshState?: boolean; showErrorToast?: boolean; @@ -161,9 +84,6 @@ export function HomePage() { const showInitialLoading = options?.showInitialLoading ?? false; const showRefreshState = options?.showRefreshState ?? false; const showErrorToast = options?.showErrorToast ?? true; - const page = options?.page ?? currentPage; - const currentSearchQuery = options?.searchQuery ?? searchQuery; - const selectedStatusFilter = options?.statusFilter ?? statusFilter; refreshInFlightRef.current = true; try { @@ -173,22 +93,8 @@ export function HomePage() { if (showRefreshState) { setIsRefreshing(true); } - const [catalogResponse, statsResponse, statusResponse] = await Promise.all([ - selectedStatusFilter === "ALL" - ? fetchAllStatusCatalogPage(page, DEFAULT_PAGE_SIZE, currentSearchQuery) - : fetchBlogsCatalog({ - page, - pageSize: DEFAULT_PAGE_SIZE, - q: currentSearchQuery || undefined, - sort: selectedStatusFilter === "WAITING" ? "id_asc" : "id_desc", - status: selectedStatusFilter, - }), - fetchStats(), - fetchStatus(), - ]); - setCatalog(catalogResponse); + const statsResponse = await fetchStats(); setStats(statsResponse); - setStatus(statusResponse); } catch { if (showErrorToast) { toast.error("首页数据加载失败,请刷新页面重试。"); @@ -197,32 +103,10 @@ export function HomePage() { refreshInFlightRef.current = false; setIsInitialLoading(false); setIsRefreshing(false); - setIsSearching(false); } } - /** - * Update the selected homepage status filter and reset pagination to the oldest page. - * - * @param filter Next status filter selected by the user. - */ - function handleStatusFilterChange(filter: HomeStatusFilter) { - setStatusFilter(filter); - setCurrentPage(1); - } - - /** - * Apply one fuzzy-search keyword to the homepage catalog. - * - * @param query Search keyword entered by the user. - */ - function handleSearch(query: string) { - setIsSearching(true); - setSearchQuery(query); - setCurrentPage(1); - } - - if (isInitialLoading || !catalog) { + if (isInitialLoading) { return (

@@ -245,12 +129,9 @@ export function HomePage() {

基于友链爬取所有博客!

-
- -
-
+
@@ -265,84 +146,15 @@ export function HomePage() {
总连接数
{stats.totalEdges}
-
-
- -
-
待处理队列
-
{status?.pendingTasks ?? 0}
-
-
-
- -
-
处理中 / 失败
-
- {(status?.processingTasks ?? 0) + (status?.failedTasks ?? 0)} -
-
-
-
- {HOME_STATUS_FILTERS.map((filter) => { - const isActive = statusFilter === filter; - return ( - - ); - })} -
-
- - 当前显示第 {catalog.page} / {Math.max(catalog.totalPages, 1)} 页,本页 {catalog.items.length} 个,共 {catalog.totalItems} 个博客 +
+ {isRefreshing ? ( + + + 正在刷新 - {searchQuery ? 搜索词: {searchQuery} : null} - {isRefreshing ? ( - - - 正在刷新 - - ) : null} -
-
- -
- {catalog.items.map((blog) => ( - - ))} -
- -
- -
- 每页最多 {DEFAULT_PAGE_SIZE} 个 -
- + ) : null}
From 4856a023888ce7efd784b90c826d414a547a00b0 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sat, 6 Jun 2026 12:24:57 +0100 Subject: [PATCH 11/35] =?UTF-8?q?=F0=9F=8E=88=20perf:=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=B8=B2=E6=9F=93ticks=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.test.tsx | 2 + .../components/GraphVisualization.test.tsx | 45 +++++- .../src/components/GraphVisualization.tsx | 139 +++++++++++++++++- frontend/src/pages/VisualizationPage.tsx | 28 ++++ 4 files changed, 208 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0e2fac4..742cbc6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -363,6 +363,8 @@ test("lets visualization users choose a graph size with a blog-count slider", as expect.anything(), ); expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "12"); + expect(screen.getByText("预计需要 120 ticks")).toBeInTheDocument(); + expect(screen.getByText("预估所需渲染时间:约 2 秒")).toBeInTheDocument(); act(() => { forceGraphProps.at(-1)!.onEngineTick(); forceGraphProps.at(-1)!.onEngineTick(); diff --git a/frontend/src/components/GraphVisualization.test.tsx b/frontend/src/components/GraphVisualization.test.tsx index cc6ca18..430f0d0 100644 --- a/frontend/src/components/GraphVisualization.test.tsx +++ b/frontend/src/components/GraphVisualization.test.tsx @@ -1,6 +1,6 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { GraphVisualization } from "./GraphVisualization"; +import { estimateGraphRenderCooldownTicks, GraphVisualization } from "./GraphVisualization"; import { tuneNaturalClusterForces } from "./GraphVisualization"; import type { ForwardedRef } from "react"; import type { GraphData } from "../types/graph"; @@ -50,6 +50,7 @@ const { chargeForce, d3ReheatSimulation, forceCalls, forceGraphRenders, ForceGra })), controls: vi.fn(() => ({ update: vi.fn() })), cameraPosition: vi.fn(), + refresh: vi.fn(), }; if (typeof resolvedRef === "function") { resolvedRef(graphInstance); @@ -181,6 +182,12 @@ afterEach(() => { }); describe("GraphVisualization", () => { + test("estimates larger render cooldowns for bigger or denser graphs", () => { + expect(estimateGraphRenderCooldownTicks(2, 1)).toBe(120); + expect(estimateGraphRenderCooldownTicks(100, 500)).toBeGreaterThan(120); + expect(estimateGraphRenderCooldownTicks(10000, 100000)).toBeGreaterThan(720); + }); + test("passes cleaned node-link data into the 3D force graph", () => { render(); @@ -281,6 +288,40 @@ describe("GraphVisualization", () => { expect(graphProps!.linkColor(defaultLink)).toBe("rgba(224, 242, 254, 0.78)"); }); + test("uses dynamic cooldown ticks and completes early after stable movement", () => { + const handleProgress = vi.fn(); + const handleComplete = vi.fn(); + const graphWithPositions: GraphData = { + nodes: forceGraphData.nodes.map((node, index) => ({ + ...node, + x: index * 10, + y: 0, + z: 0, + })), + edges: forceGraphData.edges, + }; + + render( + , + ); + + const initialProps = forceGraphRenders.at(-1)!; + expect(initialProps.cooldownTicks).toBe(estimateGraphRenderCooldownTicks(2, 1)); + + act(() => { + for (let index = 0; index < 100; index += 1) { + initialProps.onEngineTick(); + } + }); + + const stableProps = forceGraphRenders.at(-1)!; + expect(stableProps.cooldownTicks).toBe(100); + stableProps.onEngineStop(); + + expect(handleProgress).toHaveBeenLastCalledWith(1); + expect(handleComplete).toHaveBeenCalled(); + }); + test("exposes icon-only zoom and reset controls", () => { render(); diff --git a/frontend/src/components/GraphVisualization.tsx b/frontend/src/components/GraphVisualization.tsx index 4f5464f..15a6f8a 100644 --- a/frontend/src/components/GraphVisualization.tsx +++ b/frontend/src/components/GraphVisualization.tsx @@ -6,6 +6,10 @@ import { resolveIconProxyUrl } from "../lib/icon"; import type { GraphData, GraphEdge, GraphNode } from "../types/graph"; export const GRAPH_RENDER_COOLDOWN_TICKS = 120; +const GRAPH_RENDER_MIN_STABILITY_TICKS = 80; +const GRAPH_RENDER_STABLE_SAMPLE_TICKS = 20; +const GRAPH_RENDER_AVERAGE_MOVEMENT_THRESHOLD = 0.15; +const GRAPH_RENDER_MAX_MOVEMENT_THRESHOLD = 1; const GRAPH_LINK_DISTANCE = 58; const GRAPH_LINK_STRENGTH = 0.56; const GRAPH_CHARGE_STRENGTH = -190; @@ -17,6 +21,7 @@ interface GraphVisualizationProps { highlightNodeId?: number; onRenderProgress?: (progress: number) => void; onRenderComplete?: () => void; + onRenderTickEstimate?: (ticks: number) => void; useNodeIcons?: boolean; } @@ -40,10 +45,103 @@ interface RenderGraphData { links: RenderLink[]; } +interface NodePosition { + x: number; + y: number; + z: number; +} + +interface MovementSample { + averageMovement: number; + maxMovement: number; + measuredNodes: number; +} + function nodeTitle(node: GraphNode): string { return node.title?.trim() || node.domain || node.url || `Blog ${node.id}`; } +/** + * Keep one numeric value above an inclusive minimum. + * + * @param value Candidate value. + * @param min Inclusive minimum. + * @returns Value constrained to at least min. + */ +function clampMin(value: number, min: number): number { + return Math.max(min, value); +} + +/** + * Estimate the maximum force-layout duration from graph size. + * + * @param nodeCount Number of renderable graph nodes. + * @param edgeCount Number of renderable graph links. + * @returns Cooldown tick upper bound used by the force graph engine. + */ +export function estimateGraphRenderCooldownTicks(nodeCount: number, edgeCount: number): number { + const safeNodeCount = Math.max(0, nodeCount); + const safeEdgeCount = Math.max(0, edgeCount); + const edgeDensity = safeEdgeCount / Math.max(1, safeNodeCount); + const estimatedTicks = Math.round( + 80 + 12 * Math.sqrt(safeNodeCount) + 4 * Math.sqrt(safeEdgeCount) + Math.min(180, edgeDensity * 18), + ); + + return clampMin(estimatedTicks, GRAPH_RENDER_COOLDOWN_TICKS); +} + +/** + * Capture the current 3D positions for nodes that have been placed by d3. + * + * @param nodes Render nodes from the active graph payload. + * @returns Map keyed by render node id with current coordinates. + */ +function snapshotNodePositions(nodes: RenderNode[]): Map { + const positions = new Map(); + for (const node of nodes) { + if (node.x === undefined || node.y === undefined || node.z === undefined) { + continue; + } + positions.set(node.id, { x: node.x, y: node.y, z: node.z }); + } + return positions; +} + +/** + * Measure node displacement since the previous force tick. + * + * @param nodes Render nodes from the active graph payload. + * @param previousPositions Position snapshot from the previous tick. + * @returns Average and maximum displacement, or undefined when no positions are available. + */ +function measureNodeMovement(nodes: RenderNode[], previousPositions: Map): MovementSample | undefined { + let totalMovement = 0; + let maxMovement = 0; + let measuredNodes = 0; + + for (const node of nodes) { + const previous = previousPositions.get(node.id); + if (!previous || node.x === undefined || node.y === undefined || node.z === undefined) { + continue; + } + + const movement = Math.hypot(node.x - previous.x, node.y - previous.y, node.z - previous.z); + totalMovement += movement; + maxMovement = Math.max(maxMovement, movement); + measuredNodes += 1; + } + + if (measuredNodes === 0) { + return undefined; + } + + return { + averageMovement: totalMovement / measuredNodes, + maxMovement, + measuredNodes, + }; +} + function sourceIdOf(link: RenderLink): string { return typeof link.source === "object" ? link.source.id : String(link.source); } @@ -259,14 +357,23 @@ export function GraphVisualization({ highlightNodeId, onRenderProgress, onRenderComplete, + onRenderTickEstimate, useNodeIcons = true, }: GraphVisualizationProps) { const graphRef = useRef | undefined>(undefined); const containerRef = useRef(null); const renderTickRef = useRef(0); + const stableTickRef = useRef(0); + const earlyStopRequestedRef = useRef(false); + const previousPositionsRef = useRef>(new Map()); const [size, setSize] = useState({ width: 960, height: 720 }); const [isMeasured, setIsMeasured] = useState(false); const graphData = useMemo(() => buildGraphData(data, useNodeIcons), [data, useNodeIcons]); + const estimatedCooldownTicks = useMemo( + () => estimateGraphRenderCooldownTicks(graphData.nodes.length, graphData.links.length), + [graphData.links.length, graphData.nodes.length], + ); + const [cooldownTicks, setCooldownTicks] = useState(estimatedCooldownTicks); const neighborIds = useMemo(() => buildNeighborIds(graphData, highlightNodeId), [graphData, highlightNodeId]); const selectedGraphId = highlightNodeId === undefined ? undefined : String(highlightNodeId); @@ -288,7 +395,12 @@ export function GraphVisualization({ useEffect(() => { renderTickRef.current = 0; - }, [graphData]); + stableTickRef.current = 0; + earlyStopRequestedRef.current = false; + previousPositionsRef.current = snapshotNodePositions(graphData.nodes); + setCooldownTicks(estimatedCooldownTicks); + onRenderTickEstimate?.(estimatedCooldownTicks); + }, [estimatedCooldownTicks, graphData, onRenderTickEstimate]); useEffect(() => { const graph = graphRef.current; @@ -338,8 +450,27 @@ export function GraphVisualization({ const handleEngineTick = useCallback(() => { renderTickRef.current += 1; - onRenderProgress?.(Math.min(renderTickRef.current / GRAPH_RENDER_COOLDOWN_TICKS, 0.98)); - }, [onRenderProgress]); + const movement = measureNodeMovement(graphData.nodes, previousPositionsRef.current); + previousPositionsRef.current = snapshotNodePositions(graphData.nodes); + + if ( + movement && + renderTickRef.current >= GRAPH_RENDER_MIN_STABILITY_TICKS && + movement.averageMovement < GRAPH_RENDER_AVERAGE_MOVEMENT_THRESHOLD && + movement.maxMovement < GRAPH_RENDER_MAX_MOVEMENT_THRESHOLD + ) { + stableTickRef.current += 1; + } else { + stableTickRef.current = 0; + } + + if (!earlyStopRequestedRef.current && stableTickRef.current >= GRAPH_RENDER_STABLE_SAMPLE_TICKS) { + earlyStopRequestedRef.current = true; + setCooldownTicks((current) => Math.min(current, renderTickRef.current)); + } + + onRenderProgress?.(Math.min(renderTickRef.current / cooldownTicks, 0.98)); + }, [cooldownTicks, graphData.nodes, onRenderProgress]); const handleEngineStop = useCallback(() => { onRenderProgress?.(1); @@ -394,7 +525,7 @@ export function GraphVisualization({ }} d3VelocityDecay={0.38} d3AlphaDecay={0.025} - cooldownTicks={GRAPH_RENDER_COOLDOWN_TICKS} + cooldownTicks={cooldownTicks} onEngineTick={handleEngineTick} onEngineStop={handleEngineStop} controlType="orbit" diff --git a/frontend/src/pages/VisualizationPage.tsx b/frontend/src/pages/VisualizationPage.tsx index ee92546..3565a61 100644 --- a/frontend/src/pages/VisualizationPage.tsx +++ b/frontend/src/pages/VisualizationPage.tsx @@ -10,6 +10,18 @@ import { fetchBlogDetail, fetchGraphData, fetchStats, fetchSubgraph } from "../l import type { BlogDetail, GraphData, GraphNode } from "../types/graph"; const DEFAULT_GRAPH_LIMIT = 200; +const ESTIMATED_RENDER_TICKS_PER_SECOND = 60; + +/** + * Format a force-layout tick estimate as an approximate render duration. + * + * @param ticks Estimated force-layout tick count. + * @returns Human-readable duration label. + */ +function formatEstimatedRenderTime(ticks: number): string { + const seconds = Math.max(1, Math.ceil(ticks / ESTIMATED_RENDER_TICKS_PER_SECOND)); + return `约 ${seconds} 秒`; +} /** * Render the dedicated graph exploration route. @@ -26,6 +38,7 @@ export function VisualizationPage() { const [isStatsLoading, setIsStatsLoading] = useState(true); const [isRendering, setIsRendering] = useState(false); const [renderProgress, setRenderProgress] = useState(0); + const [estimatedRenderTicks, setEstimatedRenderTicks] = useState(null); const [maxGraphLimit, setMaxGraphLimit] = useState(0); const [pendingLimit, setPendingLimit] = useState(DEFAULT_GRAPH_LIMIT); const [selectedLimit, setSelectedLimit] = useState(null); @@ -35,6 +48,10 @@ export function VisualizationPage() { const loadingFloor = isLoading ? 0.08 : 0; return Math.round(Math.max(loadingFloor, renderProgress) * 100); }, [isLoading, renderProgress]); + const estimatedRenderTime = useMemo( + () => (estimatedRenderTicks ? formatEstimatedRenderTime(estimatedRenderTicks) : null), + [estimatedRenderTicks], + ); useEffect(() => { if (isBenchmarkMode) { @@ -90,6 +107,7 @@ export function VisualizationPage() { setHighlightNodeId(undefined); setIsRendering(false); setRenderProgress(0); + setEstimatedRenderTicks(null); try { setIsStatsLoading(false); @@ -102,6 +120,7 @@ export function VisualizationPage() { setSelectedLimit(null); setIsRendering(false); setRenderProgress(0); + setEstimatedRenderTicks(null); toast.error("Benchmark 图谱加载失败,请先运行生成脚本。"); } finally { setIsLoading(false); @@ -120,6 +139,7 @@ export function VisualizationPage() { setHighlightNodeId(undefined); setIsRendering(false); setRenderProgress(0); + setEstimatedRenderTicks(null); try { setIsLoading(true); @@ -131,6 +151,7 @@ export function VisualizationPage() { setSelectedLimit(null); setIsRendering(false); setRenderProgress(0); + setEstimatedRenderTicks(null); toast.error("图谱加载失败,请刷新页面重试。"); } finally { setIsLoading(false); @@ -207,6 +228,7 @@ export function VisualizationPage() { highlightNodeId={highlightNodeId} useNodeIcons={!isBenchmarkMode} onRenderProgress={(progress) => setRenderProgress((current) => Math.max(current, progress))} + onRenderTickEstimate={setEstimatedRenderTicks} onRenderComplete={() => { setRenderProgress(1); setIsRendering(false); @@ -273,6 +295,12 @@ export function VisualizationPage() { {isLoading ? "正在加载图谱数据..." : "正在计算 3D 力导布局..."}
+ {!isLoading && estimatedRenderTicks ? ( +
+
预计需要 {estimatedRenderTicks} ticks
+ {estimatedRenderTime ?
预估所需渲染时间:{estimatedRenderTime}
: null} +
+ ) : null}
Date: Sat, 6 Jun 2026 12:39:19 +0100 Subject: [PATCH 12/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=8F=AF=E8=A7=86?= =?UTF-8?q?=E5=8C=96=E6=96=B0=E5=A2=9E=E2=80=9C=E7=B2=BE=E7=AE=80=E2=80=9D?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.test.tsx | 87 ++++++++++++++++++++++-- frontend/src/pages/VisualizationPage.tsx | 66 +++++++++++++++++- 2 files changed, 147 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 742cbc6..60e8cfa 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -194,11 +194,67 @@ beforeEach(() => { domain: "graph.example.com", title: "Graph Example", icon_url: null, + incoming_count: 2, + outgoing_count: 1, + }, + { + id: 2, + url: "https://two.example.com/", + domain: "two.example.com", + title: "Two Example", + icon_url: null, + incoming_count: 1, + outgoing_count: 1, + }, + { + id: 3, + url: "https://three.example.com/", + domain: "three.example.com", + title: "Three Example", + icon_url: null, + incoming_count: 1, + outgoing_count: 1, + }, + { + id: 4, + url: "https://leaf.example.com/", + domain: "leaf.example.com", + title: "Leaf Example", + icon_url: null, incoming_count: 0, - outgoing_count: 0, + outgoing_count: 1, + }, + ], + edges: [ + { + id: "edge-1-2", + from_blog_id: 1, + to_blog_id: 2, + link_text: null, + link_url_raw: "https://two.example.com/", + }, + { + id: "edge-2-3", + from_blog_id: 2, + to_blog_id: 3, + link_text: null, + link_url_raw: "https://three.example.com/", + }, + { + id: "edge-3-1", + from_blog_id: 3, + to_blog_id: 1, + link_text: null, + link_url_raw: "https://graph.example.com/", + }, + { + id: "edge-1-4", + from_blog_id: 1, + to_blog_id: 4, + link_text: null, + link_url_raw: "https://leaf.example.com/", }, ], - edges: [], meta: { strategy: "degree", limit: 200, @@ -349,6 +405,8 @@ test("lets visualization users choose a graph size with a blog-count slider", as expect(screen.queryByText(/显示实际下载大小/)).not.toBeInTheDocument(); expect(screen.queryByText("该功能仍不成熟!")).not.toBeInTheDocument(); expect(screen.queryByText("数据统计")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "精简" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "全" })).toHaveAttribute("aria-pressed", "false"); fireEvent.change(slider, { target: { value: "20" } }); expect(slider).toHaveValue("20"); @@ -362,9 +420,13 @@ test("lets visualization users choose a graph size with a blog-count slider", as expect.stringContaining("/api/graph/views/core?strategy=seed&limit=20"), expect.anything(), ); + expect(forceGraphProps.at(-1)!.graphData.nodes.map((node: { id: string }) => node.id)).toEqual(["1", "2", "3"]); + expect(forceGraphProps.at(-1)!.graphData.links).toHaveLength(3); expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "12"); - expect(screen.getByText("预计需要 120 ticks")).toBeInTheDocument(); - expect(screen.getByText("预估所需渲染时间:约 2 秒")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("预计需要 126 ticks")).toBeInTheDocument(); + }); + expect(screen.getByText("预估所需渲染时间:约 3 秒")).toBeInTheDocument(); act(() => { forceGraphProps.at(-1)!.onEngineTick(); forceGraphProps.at(-1)!.onEngineTick(); @@ -382,6 +444,23 @@ test("lets visualization users choose a graph size with a blog-count slider", as expect(screen.queryByRole("button", { name: /搜索博客/i })).not.toBeInTheDocument(); }); +test("lets visualization users load the full graph without compact filtering", async () => { + window.history.replaceState({}, "", "/visualization"); + + render(); + + const fullButton = await screen.findByRole("button", { name: "全" }); + fireEvent.click(fullButton); + expect(fullButton).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(screen.getByRole("button", { name: "确认" })); + + await waitFor(() => { + expect(forceGraphProps.at(-1)!.graphData.nodes).toHaveLength(4); + }); + expect(forceGraphProps.at(-1)!.graphData.links).toHaveLength(4); +}); + test("ignores stale cached visualization graph data and reloads sampled sizes online", async () => { window.history.replaceState({}, "", "/visualization"); window.localStorage.setItem( diff --git a/frontend/src/pages/VisualizationPage.tsx b/frontend/src/pages/VisualizationPage.tsx index 3565a61..63b945f 100644 --- a/frontend/src/pages/VisualizationPage.tsx +++ b/frontend/src/pages/VisualizationPage.tsx @@ -11,6 +11,7 @@ import type { BlogDetail, GraphData, GraphNode } from "../types/graph"; const DEFAULT_GRAPH_LIMIT = 200; const ESTIMATED_RENDER_TICKS_PER_SECOND = 60; +type GraphDisplayMode = "compact" | "full"; /** * Format a force-layout tick estimate as an approximate render duration. @@ -23,6 +24,39 @@ function formatEstimatedRenderTime(ticks: number): string { return `约 ${seconds} 秒`; } +/** + * Keep only graph nodes connected to at least two distinct other nodes. + * + * @param graph Raw graph returned by the backend. + * @returns Compact graph with filtered nodes and only edges between kept nodes. + */ +export function compactGraphData(graph: GraphData): GraphData { + const neighborIdsByNodeId = new Map>(); + for (const node of graph.nodes) { + neighborIdsByNodeId.set(node.id, new Set()); + } + + for (const edge of graph.edges) { + if (!neighborIdsByNodeId.has(edge.source) || !neighborIdsByNodeId.has(edge.target) || edge.source === edge.target) { + continue; + } + neighborIdsByNodeId.get(edge.source)?.add(edge.target); + neighborIdsByNodeId.get(edge.target)?.add(edge.source); + } + + const keptNodeIds = new Set( + Array.from(neighborIdsByNodeId.entries()) + .filter(([, neighborIds]) => neighborIds.size >= 2) + .map(([nodeId]) => nodeId), + ); + + return { + ...graph, + nodes: graph.nodes.filter((node) => keptNodeIds.has(node.id)), + edges: graph.edges.filter((edge) => keptNodeIds.has(edge.source) && keptNodeIds.has(edge.target)), + }; +} + /** * Render the dedicated graph exploration route. * @@ -42,7 +76,12 @@ export function VisualizationPage() { const [maxGraphLimit, setMaxGraphLimit] = useState(0); const [pendingLimit, setPendingLimit] = useState(DEFAULT_GRAPH_LIMIT); const [selectedLimit, setSelectedLimit] = useState(null); + const [graphDisplayMode, setGraphDisplayMode] = useState("compact"); const [highlightNodeId, setHighlightNodeId] = useState(); + const visibleGraphData = useMemo( + () => (graphDisplayMode === "compact" ? compactGraphData(graphData) : graphData), + [graphData, graphDisplayMode], + ); const shouldShowProgressOverlay = isLoading || isRendering; const progressPercent = useMemo(() => { const loadingFloor = isLoading ? 0.08 : 0; @@ -100,6 +139,7 @@ export function VisualizationPage() { * @returns Promise resolved after benchmark graph state updates. */ async function loadBenchmarkGraph() { + setGraphDisplayMode("full"); setSelectedLimit(100); setPendingLimit(100); setMaxGraphLimit(100); @@ -167,7 +207,7 @@ export function VisualizationPage() { */ async function openBlog(blogId: number, options: { loadNeighborhood: boolean }) { if (isBenchmarkMode) { - const node = graphData.nodes.find((item) => item.id === blogId); + const node = visibleGraphData.nodes.find((item) => item.id === blogId); if (!node) { return; } @@ -223,7 +263,7 @@ export function VisualizationPage() {
节点数量
{pendingLimit}
+
+ + +
Date: Sat, 6 Jun 2026 15:32:11 +0100 Subject: [PATCH 13/35] =?UTF-8?q?=F0=9F=A6=84=20refactor:=20=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/api-docs.md | 2 + frontend/src/App.test.tsx | 43 +++++++++- frontend/src/App.tsx | 2 + frontend/src/pages/BlogDetailPage.tsx | 14 +++ frontend/src/pages/HomePage.tsx | 118 +++++++++++++++++++++++++- 5 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 frontend/src/pages/BlogDetailPage.tsx diff --git a/doc/api-docs.md b/doc/api-docs.md index 3bbd498..15fca56 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -424,6 +424,8 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 当前前端使用方式: +- 首页搜索框使用 `page=1&page_size=30&url=<输入 URL>&sort=id_desc` 查询已发现博客,并把返回项渲染为可滚动结果列表。 + #### `GET /api/icons/proxy` 用途:把已知 icon URL 作为同源图片返回,供 3D 图谱 WebGL texture 加载使用。 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 60e8cfa..ecd44ea 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -122,15 +122,20 @@ beforeEach(() => { const pageSize = Number(url.searchParams.get("page_size") || "30"); const status = url.searchParams.get("status"); const query = (url.searchParams.get("q") || "").trim().toLowerCase(); + const urlQuery = (url.searchParams.get("url") || "").trim().toLowerCase(); const sort = url.searchParams.get("sort") || "id_asc"; const filteredItems = sortCatalogItems( (status ? catalogItems.filter((item) => item.crawl_status === status) : catalogItems).filter((item) => { - if (!query) { + if (!query && !urlQuery) { return true; } const title = String(item.title ?? "").toLowerCase(); const blogUrl = String(item.url ?? "").toLowerCase(); - return title.includes(query) || blogUrl.includes(query); + const normalizedUrl = String(item.normalized_url ?? "").toLowerCase(); + return ( + (!query || title.includes(query) || blogUrl.includes(query)) && + (!urlQuery || blogUrl.includes(urlQuery) || normalizedUrl.includes(urlQuery)) + ); }), sort, ); @@ -317,7 +322,7 @@ afterEach(() => { vi.useRealTimers(); }); -test("renders the home summary without queue metrics, status filters, or catalog cards", async () => { +test("renders the home summary with URL search while keeping queue metrics and catalog cards hidden", async () => { render(); await waitFor(() => { @@ -342,7 +347,7 @@ test("renders the home summary without queue metrics, status filters, or catalog expect(screen.queryByText("Waiting Blog")).not.toBeInTheDocument(); expect(screen.queryByText("Finished Blog")).not.toBeInTheDocument(); expect(screen.queryByText("Failed Blog")).not.toBeInTheDocument(); - expect(screen.queryByPlaceholderText(/输入 URL 或标题进行搜索/i)).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText("输入你的博客链接,看看你的博客有没有被找到吧!")).toBeInTheDocument(); await act(async () => { await vi.advanceTimersByTimeAsync(5000); @@ -355,6 +360,36 @@ test("renders the home summary without queue metrics, status filters, or catalog expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining("/api/status"), expect.anything()); }); +test("lets home users search normalized URLs and open a blank blog detail route", async () => { + render(); + + const input = await screen.findByPlaceholderText("输入你的博客链接,看看你的博客有没有被找到吧!"); + fireEvent.change(input, { target: { value: "finished-blog.example.com" } }); + fireEvent.click(screen.getByRole("button", { name: "搜索博客" })); + + await waitFor(() => { + const searchCall = vi + .mocked(fetch) + .mock.calls.find(([input]) => String(input).includes("/api/blogs/catalog?")); + expect(searchCall).toBeDefined(); + const requestUrl = new URL(String(searchCall![0]), "http://localhost"); + expect(requestUrl.searchParams.get("page")).toBe("1"); + expect(requestUrl.searchParams.get("page_size")).toBe("30"); + expect(requestUrl.searchParams.get("url")).toBe("finished-blog.example.com"); + expect(requestUrl.searchParams.get("sort")).toBe("id_desc"); + }); + expect(screen.getByText("1 个匹配")).toBeInTheDocument(); + expect(screen.getByText("Finished Blog")).toBeInTheDocument(); + expect(screen.getByText("https://finished-blog.example.com/")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Finished Blog/i })); + + await waitFor(() => { + expect(window.location.pathname).toBe("/blogs/3"); + }); + expect(screen.queryByRole("heading", { name: "HeyBlog!" })).not.toBeInTheDocument(); +}); + test("adds a random blog route that loads nine finished cards and refreshes them on demand", async () => { window.history.replaceState({}, "", "/random"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eff52d6..3500b58 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { Toaster } from "sonner"; import { AboutPage } from "./pages/AboutPage"; import { AdminPage } from "./pages/AdminPage"; +import { BlogDetailPage } from "./pages/BlogDetailPage"; import { FilterStatsPage } from "./pages/FilterStatsPage"; import { HomePage } from "./pages/HomePage"; import { ProfilePage } from "./pages/ProfilePage"; @@ -19,6 +20,7 @@ export default function App() { } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/BlogDetailPage.tsx b/frontend/src/pages/BlogDetailPage.tsx new file mode 100644 index 0000000..acd9459 --- /dev/null +++ b/frontend/src/pages/BlogDetailPage.tsx @@ -0,0 +1,14 @@ +import { Navigation } from "../components/Navigation"; + +/** + * Render the temporary blog detail route shell. + * + * @returns Blank blog detail page placeholder. + */ +export function BlogDetailPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 40da777..e6592a2 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,11 +1,13 @@ -import { Loader2, Network, GitBranch } from "lucide-react"; +import { GitBranch, Loader2, Network, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { Navigation } from "../components/Navigation"; -import { fetchStats } from "../lib/api"; -import type { StatsData } from "../types/graph"; +import { fetchBlogsCatalog, fetchStats } from "../lib/api"; +import type { BlogCatalogItem, StatsData } from "../types/graph"; const HOME_REFRESH_INTERVAL_MS = 5000; +const HOME_SEARCH_PAGE_SIZE = 30; /** * Render the public home page summary without the status-filtered blog catalog. @@ -13,9 +15,16 @@ const HOME_REFRESH_INTERVAL_MS = 5000; * @returns Home route UI. */ export function HomePage() { + const navigate = useNavigate(); const [stats, setStats] = useState({ totalNodes: 0, totalEdges: 0 }); const [isInitialLoading, setIsInitialLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searchTotalItems, setSearchTotalItems] = useState(0); + const [lastSearchQuery, setLastSearchQuery] = useState(""); + const [hasSearched, setHasSearched] = useState(false); + const [isSearching, setIsSearching] = useState(false); const refreshInFlightRef = useRef(false); const hasLoadedOnceRef = useRef(false); @@ -106,6 +115,51 @@ export function HomePage() { } } + /** + * Search accepted blogs by URL using the server-side normalized URL fuzzy filter. + * + * @param event Search form submit event. + * @returns Promise resolved after results are rendered. + */ + async function handleSearchSubmit(event: React.FormEvent) { + event.preventDefault(); + const query = searchInput.trim(); + if (!query) { + setHasSearched(false); + setLastSearchQuery(""); + setSearchResults([]); + setSearchTotalItems(0); + return; + } + + setIsSearching(true); + try { + const page = await fetchBlogsCatalog({ + page: 1, + pageSize: HOME_SEARCH_PAGE_SIZE, + url: query, + sort: "id_desc", + }); + setSearchResults(page.items); + setSearchTotalItems(page.totalItems); + setLastSearchQuery(query); + setHasSearched(true); + } catch { + toast.error("博客搜索失败,请稍后重试。"); + } finally { + setIsSearching(false); + } + } + + /** + * Navigate to the temporary blog detail route. + * + * @param blog Selected search result. + */ + function openBlogDetail(blog: BlogCatalogItem) { + navigate(`/blogs/${blog.id}`); + } + if (isInitialLoading) { return (
) : null} diff --git a/frontend/src/pages/VisualizationPage.tsx b/frontend/src/pages/VisualizationPage.tsx index 63b945f..3882b61 100644 --- a/frontend/src/pages/VisualizationPage.tsx +++ b/frontend/src/pages/VisualizationPage.tsx @@ -216,7 +216,25 @@ export function VisualizationPage() { incomingLinks: node.incomingCount ?? 0, outgoingLinks: node.outgoingCount ?? 0, relatedNodes: [], + outgoingNodes: [], recommendedBlogs: [], + discoveryPath: null, + relationGraphs: { + incoming: { + direction: "incoming", + focusBlogId: node.id, + depth: 2, + nodes: [node], + edges: [], + }, + outgoing: { + direction: "outgoing", + focusBlogId: node.id, + depth: 2, + nodes: [node], + edges: [], + }, + }, }); setHighlightNodeId(blogId); return; diff --git a/frontend/src/types/graph.ts b/frontend/src/types/graph.ts index 5f4a670..4727f40 100644 --- a/frontend/src/types/graph.ts +++ b/frontend/src/types/graph.ts @@ -62,11 +62,47 @@ export interface RecommendedBlog extends GraphNode { viaBlogs: GraphNode[]; } +export interface BlogDiscoveryStep { + blog: Pick | null; + blogId: number; + url: string; + domain: string; + acceptedBy: string | null; + acceptedLabel: string | null; + rawId: number | null; + rawSourceBlogId: number | null; + rawAcceptedBy: string | null; + discoveredAt: string | null; +} + +export interface BlogDiscoveryPath { + mode: "manual" | "crawled"; + originSource: string | null; + originLabel: string; + targetSource: string | null; + truncated: boolean; + steps: BlogDiscoveryStep[]; +} + +export interface BlogRelationGraph { + direction: "incoming" | "outgoing"; + focusBlogId: number; + depth: number; + nodes: GraphNode[]; + edges: GraphEdge[]; +} + export interface BlogDetail extends GraphNode { incomingLinks: number; outgoingLinks: number; relatedNodes: GraphNode[]; + outgoingNodes: GraphNode[]; recommendedBlogs: RecommendedBlog[]; + discoveryPath: BlogDiscoveryPath | null; + relationGraphs: { + incoming: BlogRelationGraph; + outgoing: BlogRelationGraph; + }; } export interface StatsData { diff --git a/persistence_api/repository.py b/persistence_api/repository.py index 80131aa..d818de0 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -1723,6 +1723,14 @@ def _recommended_blog_payload( } +DISCOVERY_SOURCE_LABELS = { + "seed": "种子导入", + "user": "用户手动添加", + "rss": "RSS 判定", + "model": "模型判定", +} + + class RepositoryProtocol(Protocol): """Protocol shared by in-process and HTTP-backed repositories.""" @@ -2301,6 +2309,212 @@ def _blog_detail_relation_payloads( for edge in edges ] + def _blog_discovery_path_payload(self, session: Session, blog: BlogModel) -> dict[str, Any]: + """Return a compact discovery-path payload for one blog. + + Args: + session: Active database session used for tracing raw discoveries. + blog: Blog row being displayed on the detail page. + + Returns: + Payload with discovery mode and ordered path steps from origin to + target. Manual seed/user blogs return a single-step path. + """ + + path_reversed: list[dict[str, Any]] = [] + visited_blog_ids: set[int] = set() + current_blog: BlogModel | None = blog + while current_blog is not None: + if current_blog is None: + break + current_blog_id = int(_business_blog_id(current_blog)) + if current_blog_id in visited_blog_ids: + break + visited_blog_ids.add(current_blog_id) + accepted_by = str(current_blog.accepted_by or "").strip().lower() + if accepted_by in {"seed", "user"}: + path_reversed.append(self._discovery_path_step(current_blog, raw=None, edge=None)) + break + incoming_edge = self._earliest_incoming_discovery_edge(session, current_blog_id) + raw = ( + self._success_raw_for_edge(session, incoming_edge) + if incoming_edge is not None + else self._earliest_success_raw_for_blog(session, current_blog) + ) + path_reversed.append(self._discovery_path_step(current_blog, raw=raw, edge=incoming_edge)) + source_blog_id = int(incoming_edge.from_blog_id) if incoming_edge is not None else ( + int(raw.source_blog_id) if raw is not None else None + ) + if source_blog_id is None: + break + current_blog = self._get_blog_by_business_id(session, source_blog_id) + + path = list(reversed(path_reversed)) + origin_source = str(path[0].get("accepted_by") or path[0].get("raw_accepted_by") or "").strip().lower() if path else "" + target_source = str(path[-1].get("accepted_by") or path[-1].get("raw_accepted_by") or "").strip().lower() if path else "" + mode = "manual" if target_source in {"seed", "user"} and len(path) == 1 else "crawled" + if origin_source in {"seed", "user"} and mode == "crawled": + origin_label = DISCOVERY_SOURCE_LABELS.get(origin_source, origin_source) + else: + origin_label = "发现链路" + return { + "mode": mode, + "origin_source": origin_source or None, + "origin_label": origin_label, + "target_source": target_source or None, + "truncated": False, + "steps": path, + } + + def _earliest_incoming_discovery_edge(self, session: Session, blog_id: int) -> EdgeModel | None: + """Return the earliest non-self incoming edge that discovered a blog.""" + + return session.scalar( + select(EdgeModel) + .where( + EdgeModel.to_blog_id == blog_id, + EdgeModel.from_blog_id != blog_id, + ) + .order_by(EdgeModel.discovered_at.asc(), EdgeModel.id.asc()) + .limit(1) + ) + + def _success_raw_for_edge(self, session: Session, edge: EdgeModel) -> RawDiscoveredUrlModel | None: + """Return the successful raw discovery row that produced one edge when available.""" + + candidate_urls = { + str(edge.link_url_raw or ""), + normalize_url(str(edge.link_url_raw or "")).normalized_url, + resolve_blog_identity(str(edge.link_url_raw or "")).canonical_url, + } + return session.scalar( + select(RawDiscoveredUrlModel) + .where( + RawDiscoveredUrlModel.source_blog_id == int(edge.from_blog_id), + RawDiscoveredUrlModel.normalized_url.in_([url for url in candidate_urls if url]), + RawDiscoveredUrlModel.status == RAW_DISCOVERED_URL_SUCCESS_STATUS, + ) + .order_by(RawDiscoveredUrlModel.id.asc()) + .limit(1) + ) + + def _earliest_success_raw_for_blog(self, session: Session, blog: BlogModel) -> RawDiscoveredUrlModel | None: + """Return the earliest successful raw discovery row for one blog.""" + + candidate_urls = {str(blog.normalized_url or ""), str(blog.url or "")} + identity = resolve_blog_identity(str(blog.url or blog.normalized_url or "")) + candidate_urls.add(identity.canonical_url) + normalized = normalize_url(str(blog.url or blog.normalized_url or "")) + candidate_urls.add(normalized.normalized_url) + return session.scalar( + select(RawDiscoveredUrlModel) + .where( + RawDiscoveredUrlModel.normalized_url.in_([url for url in candidate_urls if url]), + RawDiscoveredUrlModel.status == RAW_DISCOVERED_URL_SUCCESS_STATUS, + ) + .order_by(RawDiscoveredUrlModel.id.asc()) + .limit(1) + ) + + def _discovery_path_step( + self, + blog: BlogModel, + *, + raw: RawDiscoveredUrlModel | None, + edge: EdgeModel | None, + ) -> dict[str, Any]: + """Serialize one blog as a discovery path step.""" + + blog_view = _BlogPayloadView.from_model(blog) + accepted_by = str(blog.accepted_by or "").strip().lower() or None + raw_accepted_by = str(raw.accepted_by or "").strip().lower() if raw is not None else None + source = accepted_by or raw_accepted_by + raw_source_blog_id = int(raw.source_blog_id) if raw is not None else ( + int(edge.from_blog_id) if edge is not None else None + ) + discovered_at = _iso(raw.discovered_at) if raw is not None else ( + _iso(edge.discovered_at) if edge is not None else None + ) + return { + "blog": blog_view.as_neighbor_payload() if blog_view is not None else None, + "blog_id": int(_business_blog_id(blog)), + "url": str(blog.url or ""), + "domain": str(blog.domain or ""), + "accepted_by": accepted_by, + "accepted_label": DISCOVERY_SOURCE_LABELS.get(str(source or ""), source), + "raw_id": int(raw.id) if raw is not None else None, + "raw_source_blog_id": raw_source_blog_id, + "raw_accepted_by": raw_accepted_by or None, + "discovered_at": discovered_at, + } + + def _blog_relation_graph_payload( + self, + session: Session, + *, + blog: BlogModel, + direction: str, + depth: int = 2, + ) -> dict[str, Any]: + """Return a small directional relation graph around one blog. + + Args: + session: Active database session. + blog: Focus blog for the graph. + direction: Either ``incoming`` for upstream sources or + ``outgoing`` for downstream targets. + depth: Number of graph layers to traverse. + + Returns: + Payload with normalized graph nodes, directed edges, focus blog id, + direction, and depth metadata. + """ + + focus_id = int(_business_blog_id(blog)) + node_ids: set[int] = {focus_id} + edges_by_id: dict[int, EdgeModel] = {} + frontier = {focus_id} + for layer_index in range(depth): + next_frontier: set[int] = set() + for current_id in sorted(frontier): + if direction == "incoming": + statement = ( + select(EdgeModel) + .where(EdgeModel.to_blog_id == current_id, EdgeModel.from_blog_id != current_id) + .order_by(EdgeModel.discovered_at.asc(), EdgeModel.id.asc()) + ) + else: + statement = ( + select(EdgeModel) + .where(EdgeModel.from_blog_id == current_id, EdgeModel.to_blog_id != current_id) + .order_by(EdgeModel.discovered_at.asc(), EdgeModel.id.asc()) + ) + layer_edges = session.scalars(statement).all() + for edge in layer_edges: + edges_by_id[int(edge.id)] = edge + related_id = int(edge.from_blog_id) if direction == "incoming" else int(edge.to_blog_id) + if related_id not in node_ids: + next_frontier.add(related_id) + node_ids.add(related_id) + frontier = next_frontier + if not frontier: + break + + blog_rows = session.execute( + self._blog_select()[0].where(BlogModel.blog_id.in_(sorted(node_ids))) + ).all() + nodes_by_id: dict[int, dict[str, Any]] = {} + for row in blog_rows: + payload = self._row_blog_payload(row) + nodes_by_id[int(payload["blog_id"])] = payload + return { + "direction": direction, + "focus_blog_id": focus_id, + "depth": depth, + "nodes": [nodes_by_id[node_id] for node_id in sorted(node_ids) if node_id in nodes_by_id], + "edges": [_edge_payload(edge) for edge in sorted(edges_by_id.values(), key=lambda item: int(item.id))], + } + def _recommended_blog_rows( self, session: Session, @@ -4454,6 +4668,19 @@ def get_blog_detail(self, blog_id: int) -> dict[str, Any] | None: return { **self._row_blog_payload(blog_row), + "discovery_path": self._blog_discovery_path_payload(session, blog_row[0]), + "relation_graphs": { + "incoming": self._blog_relation_graph_payload( + session, + blog=blog_row[0], + direction="incoming", + ), + "outgoing": self._blog_relation_graph_payload( + session, + blog=blog_row[0], + direction="outgoing", + ), + }, "incoming_edges": self._blog_detail_relation_payloads( session, incoming_edges, diff --git a/tests/test_repository.py b/tests/test_repository.py index 1e70e6f..b7994af 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -2516,6 +2516,15 @@ def test_repository_blog_detail_aggregates_bidirectional_relationships(tmp_path: "activity_at": detail["recommended_blogs"][0]["blog"]["activity_at"], "identity_complete": True, } + assert detail["relation_graphs"]["incoming"]["focus_blog_id"] == alpha_id + assert [node["blog_id"] for node in detail["relation_graphs"]["incoming"]["nodes"]] == [alpha_id, gamma_id] + assert detail["relation_graphs"]["incoming"]["edges"][0]["from_blog_id"] == gamma_id + assert detail["relation_graphs"]["outgoing"]["focus_blog_id"] == alpha_id + assert {node["blog_id"] for node in detail["relation_graphs"]["outgoing"]["nodes"]} == { + alpha_id, + beta_id, + delta_id, + } assert detail["recommended_blogs"][0]["reason"] == "mutual_connection" assert detail["recommended_blogs"][0]["mutual_connection_count"] == 1 assert detail["recommended_blogs"][0]["via_blogs"] == [ @@ -2527,3 +2536,216 @@ def test_repository_blog_detail_aggregates_bidirectional_relationships(tmp_path: "icon_url": "https://beta.example/favicon.ico", } ] + + +def test_repository_blog_detail_relation_graph_keeps_all_edges_within_two_layers( + tmp_path: Path, +) -> None: + """Relation graphs should keep every edge reachable within the configured two-layer depth.""" + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + focus_id, inserted = repository.upsert_blog( + url="https://focus.example/", + normalized_url="https://focus.example/", + domain="focus.example", + ) + assert inserted is True + + outgoing_first_ids: list[int] = [] + incoming_first_ids: list[int] = [] + for index in range(12): + outgoing_id, inserted = repository.upsert_blog( + url=f"https://out-first-{index}.example/", + normalized_url=f"https://out-first-{index}.example/", + domain=f"out-first-{index}.example", + ) + assert inserted is True + outgoing_first_ids.append(outgoing_id) + repository.add_edge( + from_blog_id=focus_id, + to_blog_id=outgoing_id, + link_url_raw=f"https://out-first-{index}.example/", + link_text=f"Out first {index}", + ) + + incoming_id, inserted = repository.upsert_blog( + url=f"https://in-first-{index}.example/", + normalized_url=f"https://in-first-{index}.example/", + domain=f"in-first-{index}.example", + ) + assert inserted is True + incoming_first_ids.append(incoming_id) + repository.add_edge( + from_blog_id=incoming_id, + to_blog_id=focus_id, + link_url_raw="https://focus.example/", + link_text=f"In first {index}", + ) + + outgoing_second_ids: list[int] = [] + incoming_second_ids: list[int] = [] + for index in range(11): + outgoing_id, inserted = repository.upsert_blog( + url=f"https://out-second-{index}.example/", + normalized_url=f"https://out-second-{index}.example/", + domain=f"out-second-{index}.example", + ) + assert inserted is True + outgoing_second_ids.append(outgoing_id) + repository.add_edge( + from_blog_id=outgoing_first_ids[0], + to_blog_id=outgoing_id, + link_url_raw=f"https://out-second-{index}.example/", + link_text=f"Out second {index}", + ) + + incoming_id, inserted = repository.upsert_blog( + url=f"https://in-second-{index}.example/", + normalized_url=f"https://in-second-{index}.example/", + domain=f"in-second-{index}.example", + ) + assert inserted is True + incoming_second_ids.append(incoming_id) + repository.add_edge( + from_blog_id=incoming_id, + to_blog_id=incoming_first_ids[0], + link_url_raw=f"https://in-first-0.example/", + link_text=f"In second {index}", + ) + + detail = repository.get_blog_detail(focus_id) + + assert detail is not None + outgoing_node_ids = {node["blog_id"] for node in detail["relation_graphs"]["outgoing"]["nodes"]} + assert set(outgoing_first_ids).issubset(outgoing_node_ids) + assert set(outgoing_second_ids).issubset(outgoing_node_ids) + + incoming_node_ids = {node["blog_id"] for node in detail["relation_graphs"]["incoming"]["nodes"]} + assert set(incoming_first_ids).issubset(incoming_node_ids) + assert set(incoming_second_ids).issubset(incoming_node_ids) + + +def test_repository_blog_detail_includes_discovery_path(tmp_path: Path) -> None: + """Detail payloads should explain manual origins and crawled discovery chains.""" + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + seed_id, inserted = repository.upsert_blog( + url="https://seed.example/", + normalized_url="https://seed.example/", + domain="seed.example", + accepted_by="seed", + seed_source_path="seed.csv", + seed_source_row=2, + ) + assert inserted is True + middle_id, inserted = repository.upsert_blog( + url="https://middle.example/", + normalized_url="https://middle.example/", + domain="middle.example", + accepted_by="rss", + ) + assert inserted is True + target_id, inserted = repository.upsert_blog( + url="https://target.example/", + normalized_url="https://target.example/", + domain="target.example", + accepted_by="model", + ) + assert inserted is True + first_raw = repository.create_raw_discovered_url( + source_blog_id=seed_id, + normalized_url="https://middle.example/", + status="pending", + ) + repository.update_raw_discovered_url_status(record_id=first_raw, status="success", accepted_by="rss") + second_raw = repository.create_raw_discovered_url( + source_blog_id=middle_id, + normalized_url="https://target.example/", + status="pending", + ) + repository.update_raw_discovered_url_status(record_id=second_raw, status="success", accepted_by="model") + + seed_detail = repository.get_blog_detail(seed_id) + target_detail = repository.get_blog_detail(target_id) + + assert seed_detail is not None + assert seed_detail["discovery_path"]["mode"] == "manual" + assert seed_detail["discovery_path"]["steps"][0]["accepted_by"] == "seed" + assert target_detail is not None + assert target_detail["discovery_path"]["mode"] == "crawled" + assert [step["domain"] for step in target_detail["discovery_path"]["steps"]] == [ + "seed.example", + "middle.example", + "target.example", + ] + assert target_detail["discovery_path"]["steps"][0]["accepted_label"] == "种子导入" + assert target_detail["discovery_path"]["steps"][1]["raw_source_blog_id"] == seed_id + assert target_detail["discovery_path"]["steps"][2]["raw_source_blog_id"] == middle_id + + +def test_repository_blog_detail_discovery_path_keeps_full_history(tmp_path: Path) -> None: + """Discovery paths should return every historical source step, even for long chains.""" + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + blog_ids: list[int] = [] + domains = [f"chain-{index}.example" for index in range(15)] + for index, domain in enumerate(domains): + blog_id, inserted = repository.upsert_blog( + url=f"https://{domain}/", + normalized_url=f"https://{domain}/", + domain=domain, + accepted_by="seed" if index == 0 else "rss", + ) + assert inserted is True + blog_ids.append(blog_id) + + for source_id, target_domain in zip(blog_ids[:-1], domains[1:], strict=True): + raw_id = repository.create_raw_discovered_url( + source_blog_id=source_id, + normalized_url=f"https://{target_domain}/", + status="pending", + ) + repository.update_raw_discovered_url_status(record_id=raw_id, status="success", accepted_by="rss") + + detail = repository.get_blog_detail(blog_ids[-1]) + + assert detail is not None + assert detail["discovery_path"]["truncated"] is False + assert [step["domain"] for step in detail["discovery_path"]["steps"]] == domains + + +def test_repository_blog_detail_discovery_path_uses_incoming_edge_for_alias_raw_url(tmp_path: Path) -> None: + """Discovery paths should follow incoming edges when raw URLs differ from canonical blog URLs.""" + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + seed_id, inserted = repository.upsert_blog( + url="https://seed.example/", + normalized_url="https://seed.example/", + domain="seed.example", + accepted_by="seed", + ) + assert inserted is True + target_id, inserted = repository.upsert_blog( + url="https://target.example/", + normalized_url="https://target.example/", + domain="target.example", + accepted_by="rss", + ) + assert inserted is True + raw_id = repository.create_raw_discovered_url( + source_blog_id=seed_id, + normalized_url="https://blog.target.example/", + status="pending", + ) + repository.update_raw_discovered_url_status(record_id=raw_id, status="success", accepted_by="rss") + repository.add_edge( + from_blog_id=seed_id, + to_blog_id=target_id, + link_url_raw="https://blog.target.example/", + link_text="Target", + ) + + detail = repository.get_blog_detail(target_id) + + assert detail is not None + assert [step["domain"] for step in detail["discovery_path"]["steps"]] == [ + "seed.example", + "target.example", + ] + assert detail["discovery_path"]["steps"][1]["raw_source_blog_id"] == seed_id From da18deb4eb60e2049895ce04acde3f2ea55c7620 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 11:50:31 +0100 Subject: [PATCH 17/35] =?UTF-8?q?=F0=9F=90=9E=20fix:=20ruff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_repository.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_repository.py b/tests/test_repository.py index b7994af..a57ba4f 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -6,7 +6,6 @@ import pyarrow.parquet as pq import pytest from sqlalchemy import event -from sqlalchemy import func from sqlalchemy import select sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) @@ -2608,7 +2607,7 @@ def test_repository_blog_detail_relation_graph_keeps_all_edges_within_two_layers repository.add_edge( from_blog_id=incoming_id, to_blog_id=incoming_first_ids[0], - link_url_raw=f"https://in-first-0.example/", + link_url_raw="https://in-first-0.example/", link_text=f"In second {index}", ) From 69e0f247ac4015fae9d7ddd4bfded2e131142161 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 11:59:40 +0100 Subject: [PATCH 18/35] =?UTF-8?q?=F0=9F=90=9E=20fix:=20=E5=AF=B9=E5=BA=94?= =?UTF-8?q?=E7=9A=84=E4=BF=AE=E6=94=B9=E6=B5=8B=E8=AF=95=E6=A1=88=E4=BE=8B?= =?UTF-8?q?=EF=BC=88=E4=BF=AE=E6=94=B9=E6=B5=8B=E8=AF=95=E6=A1=88=E4=BE=8B?= =?UTF-8?q?=E5=A5=BD=E6=80=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/crawling/decisions/chain.py | 50 ++++++++++++++++++++++++++++- tests/test_filters.py | 10 +++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crawler/crawling/decisions/chain.py b/crawler/crawling/decisions/chain.py index 3dd252a..a740e84 100644 --- a/crawler/crawling/decisions/chain.py +++ b/crawler/crawling/decisions/chain.py @@ -94,6 +94,41 @@ def _build_rss_discovery_filter(settings: Settings) -> BaseUrlFilter: return RssDiscoveryFilter() +def _build_implicit_success_deciders( + settings: Settings, + *, + configured_filter_kinds: set[str], + disabled_filter_kinds: set[str], +) -> list[BaseUrlFilter]: + """Append optional success deciders controlled only by settings toggles. + + Args: + settings: Runtime settings that enable or disable optional deciders. + configured_filter_kinds: Filter kinds mentioned by the TOML config. + disabled_filter_kinds: Filter kinds explicitly disabled in the TOML + config. + + Returns: + Success decider filters that should be appended after deterministic + rule filters because the config omitted them and the corresponding + runtime toggle is enabled. + """ + implicit_filters: list[BaseUrlFilter] = [] + if ( + settings.rss_discovery_enabled + and "rss_discovery" not in configured_filter_kinds + and "rss_discovery" not in disabled_filter_kinds + ): + implicit_filters.append(_build_rss_discovery_filter(settings)) + if ( + settings.decision_model_consensus_enabled + and "model_consensus" not in configured_filter_kinds + and "model_consensus" not in disabled_filter_kinds + ): + implicit_filters.append(_build_model_consensus_filter(settings)) + return implicit_filters + + FILTER_REGISTRY: dict[str, FilterFactory] = { "duplicate_url": _static_filter_factory(DuplicateUrlFilter), "non_http_scheme": _static_filter_factory(NonHttpSchemeFilter), @@ -167,10 +202,16 @@ def steps(self) -> tuple[BaseUrlFilter, ...]: def from_settings(cls, settings: Settings) -> "ConfiguredUrlFilterChain": """Build a filter chain using the configured TOML ordering.""" loaded_filters: list[BaseUrlFilter] = [] + configured_filter_kinds: set[str] = set() + disabled_filter_kinds: set[str] = set() for item in _load_filter_chain_config(settings.filter_chain_config_path): + kind = str(item.get("kind", "")).strip() + if kind: + configured_filter_kinds.add(kind) if not bool(item.get("enabled", True)): + if kind: + disabled_filter_kinds.add(kind) continue - kind = str(item.get("kind", "")).strip() if kind == "model_consensus" and not settings.decision_model_consensus_enabled: continue if kind == "rss_discovery" and not settings.rss_discovery_enabled: @@ -179,6 +220,13 @@ def from_settings(cls, settings: Settings) -> "ConfiguredUrlFilterChain": if factory is None: raise ValueError(f"unknown_filter_kind:{kind}") loaded_filters.append(factory(settings)) + loaded_filters.extend( + _build_implicit_success_deciders( + settings, + configured_filter_kinds=configured_filter_kinds, + disabled_filter_kinds=disabled_filter_kinds, + ) + ) return cls(filters=tuple(loaded_filters)) @property diff --git a/tests/test_filters.py b/tests/test_filters.py index f5d9547..46bcc26 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -17,8 +17,8 @@ def test_filter_rejects_known_platform_domains() -> None: assert not is_blog_candidate("https://t.co/share", "blog.example.com") -def test_filter_rejects_blocked_tlds_like_gov_and_org() -> None: - """Reject blocked TLD categories such as government/organization domains.""" +def test_filter_rejects_government_tlds_but_allows_org_domains() -> None: + """Reject government/education TLDs while allowing organization domains.""" decision = decide_blog_candidate("https://agency.gov/", "blog.example.com") gov_cn_decision = decide_blog_candidate("https://beian.miit.gov.cn/", "blog.example.com") org_decision = decide_blog_candidate("https://foundation.org/", "blog.example.com") @@ -29,9 +29,9 @@ def test_filter_rejects_blocked_tlds_like_gov_and_org() -> None: assert not gov_cn_decision.accepted assert gov_cn_decision.hard_blocked assert "blocked_tld" in gov_cn_decision.reasons - assert not org_decision.accepted - assert org_decision.hard_blocked - assert "blocked_tld" in org_decision.reasons + assert org_decision.accepted + assert not org_decision.hard_blocked + assert "blocked_tld" not in org_decision.reasons def test_filter_rejects_exact_url_and_prefix_blocklist_entries() -> None: From 00ae89be24949d6d395f1e69e9d9228a19f81db2 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 14:27:53 +0100 Subject: [PATCH 19/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E9=9A=8F=E6=9C=BA?= =?UTF-8?q?=E5=8D=9A=E5=AE=A2=E7=95=8C=E9=9D=A2=E6=96=B0=E5=A2=9E=E5=8D=9A?= =?UTF-8?q?=E5=AE=A2=E8=AF=A6=E6=83=85=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.test.tsx | 17 +++++++++++++++++ frontend/src/pages/RandomBlogPage.tsx | 21 ++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2399107..5729d05 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -707,6 +707,23 @@ test("adds a random blog route that loads nine finished cards and refreshes them }); }); +test("lets random blog users open one blog detail route", async () => { + window.history.replaceState({}, "", "/random"); + + render(); + + await waitFor(() => { + expect(screen.getByText("当前展示 9 个随机博客卡片")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getAllByRole("button", { name: "查看详情" })[0]); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/api/blogs/32"), expect.anything()); + }); + expect(await screen.findByRole("heading", { name: "Extra Blog 32" })).toBeInTheDocument(); +}); + test("lets visualization users choose a graph size with a blog-count slider", async () => { window.history.replaceState({}, "", "/visualization"); diff --git a/frontend/src/pages/RandomBlogPage.tsx b/frontend/src/pages/RandomBlogPage.tsx index 0dd7a20..4390797 100644 --- a/frontend/src/pages/RandomBlogPage.tsx +++ b/frontend/src/pages/RandomBlogPage.tsx @@ -1,5 +1,6 @@ -import { Loader2, RefreshCw } from "lucide-react"; +import { Eye, Loader2, RefreshCw } from "lucide-react"; import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { BlogCard } from "../components/BlogCard"; import { Navigation } from "../components/Navigation"; @@ -21,6 +22,7 @@ const RANDOM_LABELS = [ * @returns Random finished-blog discovery page. */ export function RandomBlogPage() { + const navigate = useNavigate(); const [blogs, setBlogs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); @@ -96,6 +98,15 @@ export function RandomBlogPage() { } } + /** + * Open the internal blog detail route for a random blog card. + * + * @param blog Blog selected from the random catalog. + */ + function openBlogDetail(blog: BlogCatalogItem) { + navigate(`/blogs/${blog.id}`); + } + if (isLoading) { return (
@@ -136,6 +147,14 @@ export function RandomBlogPage() {
{blogs.map((blog) => ( +
{RANDOM_LABELS.map((label) => { const isSaving = savingLabelKey === `${blog.id}:${label.slug}`; From f4617669a1e52e4744224eee39640e9f193c23e0 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 16:19:18 +0100 Subject: [PATCH 20/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=89=93=E5=BC=80=E5=8D=9A=E5=AE=A2=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=92=8C=E5=A4=96=E9=83=A8=E9=93=BE=E6=8E=A5=E7=9A=84?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0607_01_add_recommendation_event_tables.py | 178 +++++++ ...02_add_blog_interaction_entrance_fields.py | 93 ++++ backend/main.py | 61 +++ doc/api-docs.md | 196 +++++++ frontend/src/App.test.tsx | 121 ++++- frontend/src/components/BlogCard.tsx | 27 +- frontend/src/components/BlogDetailLink.tsx | 45 ++ frontend/src/components/BlogDetailPanel.tsx | 25 +- frontend/src/components/BlogExternalLink.tsx | 48 ++ frontend/src/lib/api.ts | 104 ++++ frontend/src/lib/blogInteractions.ts | 133 +++++ frontend/src/pages/BlogDetailPage.tsx | 56 +- frontend/src/pages/HomePage.tsx | 22 +- frontend/src/pages/RandomBlogPage.tsx | 60 ++- frontend/src/types/graph.ts | 32 ++ memory/MEMORY.md | 3 - memory/filter-chain-two-phase-architecture.md | 20 - persistence_api/main.py | 59 ++ persistence_api/models.py | 109 ++++ persistence_api/repository.py | 503 ++++++++++++++++++ shared/http_clients/persistence_http.py | 123 +++++ tests/test_repository.py | 84 +++ tests/test_service_split.py | 172 ++++++ 23 files changed, 2172 insertions(+), 102 deletions(-) create mode 100644 alembic/versions/20260607_01_add_recommendation_event_tables.py create mode 100644 alembic/versions/20260607_02_add_blog_interaction_entrance_fields.py create mode 100644 frontend/src/components/BlogDetailLink.tsx create mode 100644 frontend/src/components/BlogExternalLink.tsx create mode 100644 frontend/src/lib/blogInteractions.ts delete mode 100644 memory/MEMORY.md delete mode 100644 memory/filter-chain-two-phase-architecture.md diff --git a/alembic/versions/20260607_01_add_recommendation_event_tables.py b/alembic/versions/20260607_01_add_recommendation_event_tables.py new file mode 100644 index 0000000..4d505eb --- /dev/null +++ b/alembic/versions/20260607_01_add_recommendation_event_tables.py @@ -0,0 +1,178 @@ +"""Add local recommendation request, impression, and interaction tables. + +Revision ID: 20260607_01 +Revises: 20260606_01 +Create Date: 2026-06-07 14:21:29 BST +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260607_01" +down_revision = "20260606_01" +branch_labels = None +depends_on = None + + +def _tables() -> set[str]: + """Return currently present database table names. + + Args: + None. + + Returns: + Set of table names currently present in the migration target. + """ + + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def upgrade() -> None: + """Create the recommendation event substrate tables. + + Args: + None. + + Returns: + None. The migration mutates the active database schema in place. + """ + + tables = _tables() + if "recommendation_requests" not in tables: + op.create_table( + "recommendation_requests", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("request_uuid", sa.Text(), nullable=False), + sa.Column("surface", sa.Text(), nullable=False), + sa.Column("strategy", sa.Text(), nullable=False), + sa.Column("strategy_version", sa.Text(), nullable=False, server_default="v1"), + sa.Column("visitor_id", sa.Text(), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("session_id", sa.Text(), nullable=False), + sa.Column("source", sa.Text(), nullable=True), + sa.Column("page_url", sa.Text(), nullable=True), + sa.Column("requested_count", sa.Integer(), nullable=False), + sa.Column("served_count", sa.Integer(), nullable=False), + sa.Column("context_json", sa.JSON(), nullable=False, server_default=sa.text("'{}'")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("request_uuid", name="uq_recommendation_requests_request_uuid"), + ) + op.create_index("ix_recommendation_requests_request_uuid", "recommendation_requests", ["request_uuid"]) + op.create_index("ix_recommendation_requests_surface", "recommendation_requests", ["surface"]) + op.create_index("ix_recommendation_requests_user_id", "recommendation_requests", ["user_id"]) + op.create_index("ix_recommendation_requests_visitor_id", "recommendation_requests", ["visitor_id"]) + op.create_index("ix_recommendation_requests_session_id", "recommendation_requests", ["session_id"]) + op.create_index( + "ix_recommendation_requests_surface_created", + "recommendation_requests", + ["surface", "created_at"], + ) + op.create_index( + "ix_recommendation_requests_strategy_created", + "recommendation_requests", + ["strategy", "strategy_version", "created_at"], + ) + + tables = _tables() + if "recommendation_impressions" not in tables: + op.create_table( + "recommendation_impressions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "request_id", + sa.Integer(), + sa.ForeignKey("recommendation_requests.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("blog_id", sa.Integer(), sa.ForeignKey("blogs.blog_id", ondelete="CASCADE"), nullable=False), + sa.Column("normalized_url", sa.Text(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("score", sa.Integer(), nullable=True), + sa.Column("reason_json", sa.JSON(), nullable=False, server_default=sa.text("'{}'")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("request_id", "position", name="uq_recommendation_impression_request_position"), + sa.UniqueConstraint("request_id", "blog_id", name="uq_recommendation_impression_request_blog"), + ) + op.create_index("ix_recommendation_impressions_request_id", "recommendation_impressions", ["request_id"]) + op.create_index("ix_recommendation_impressions_blog_id", "recommendation_impressions", ["blog_id"]) + op.create_index("ix_recommendation_impressions_normalized_url", "recommendation_impressions", ["normalized_url"]) + op.create_index( + "ix_recommendation_impressions_blog_created", + "recommendation_impressions", + ["blog_id", "created_at"], + ) + + tables = _tables() + if "blog_interactions" not in tables: + op.create_table( + "blog_interactions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("event_uuid", sa.Text(), nullable=False), + sa.Column( + "request_id", + sa.Integer(), + sa.ForeignKey("recommendation_requests.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "impression_id", + sa.Integer(), + sa.ForeignKey("recommendation_impressions.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("blog_id", sa.Integer(), sa.ForeignKey("blogs.blog_id", ondelete="CASCADE"), nullable=False), + sa.Column("normalized_url", sa.Text(), nullable=False), + sa.Column("event_type", sa.Text(), nullable=False), + sa.Column("position", sa.Integer(), nullable=True), + sa.Column("entrance_kind", sa.Text(), nullable=False), + sa.Column("entrance_url", sa.Text(), nullable=False), + sa.Column("interaction_order", sa.Integer(), nullable=False, server_default="1"), + sa.Column("visitor_id", sa.Text(), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("session_id", sa.Text(), nullable=False), + sa.Column("client_event_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("attributes_json", sa.JSON(), nullable=False, server_default=sa.text("'{}'")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("event_uuid", name="uq_blog_interactions_event_uuid"), + ) + op.create_index("ix_blog_interactions_event_uuid", "blog_interactions", ["event_uuid"]) + op.create_index("ix_blog_interactions_request_id", "blog_interactions", ["request_id"]) + op.create_index("ix_blog_interactions_impression_id", "blog_interactions", ["impression_id"]) + op.create_index("ix_blog_interactions_blog_id", "blog_interactions", ["blog_id"]) + op.create_index("ix_blog_interactions_normalized_url", "blog_interactions", ["normalized_url"]) + op.create_index("ix_blog_interactions_event_type", "blog_interactions", ["event_type"]) + op.create_index("ix_blog_interactions_entrance_kind", "blog_interactions", ["entrance_kind"]) + op.create_index("ix_blog_interactions_entrance_url", "blog_interactions", ["entrance_url"]) + op.create_index("ix_blog_interactions_visitor_id", "blog_interactions", ["visitor_id"]) + op.create_index("ix_blog_interactions_user_id", "blog_interactions", ["user_id"]) + op.create_index("ix_blog_interactions_session_id", "blog_interactions", ["session_id"]) + op.create_index( + "ix_blog_interactions_blog_event_created", + "blog_interactions", + ["blog_id", "event_type", "created_at"], + ) + op.create_index("ix_blog_interactions_request_event", "blog_interactions", ["request_id", "event_type"]) + + +def downgrade() -> None: + """Drop the recommendation event substrate tables. + + Args: + None. + + Returns: + None. The migration mutates the active database schema in place. + """ + + tables = _tables() + if "blog_interactions" in tables: + op.drop_table("blog_interactions") + tables = _tables() + if "recommendation_impressions" in tables: + op.drop_table("recommendation_impressions") + tables = _tables() + if "recommendation_requests" in tables: + op.drop_table("recommendation_requests") diff --git a/alembic/versions/20260607_02_add_blog_interaction_entrance_fields.py b/alembic/versions/20260607_02_add_blog_interaction_entrance_fields.py new file mode 100644 index 0000000..2cf8f0d --- /dev/null +++ b/alembic/versions/20260607_02_add_blog_interaction_entrance_fields.py @@ -0,0 +1,93 @@ +"""Add entrance metadata columns to existing blog interaction tables. + +Revision ID: 20260607_02 +Revises: 20260607_01 +Create Date: 2026-06-07 15:08:00 BST +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260607_02" +down_revision = "20260607_01" +branch_labels = None +depends_on = None + + +def _table_columns(table_name: str) -> set[str]: + """Return current column names for one table. + + Args: + table_name: Table to inspect. + + Returns: + Set of column names currently present on the table. + """ + + inspector = sa.inspect(op.get_bind()) + if table_name not in set(inspector.get_table_names()): + return set() + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + """Backfill entrance metadata columns added after the first event migration. + + Args: + None. + + Returns: + None. The active database schema is mutated in place. + """ + + columns = _table_columns("blog_interactions") + if not columns: + return + if "entrance_kind" not in columns: + op.add_column( + "blog_interactions", + sa.Column("entrance_kind", sa.Text(), nullable=False, server_default="legacy_unknown"), + ) + op.alter_column("blog_interactions", "entrance_kind", server_default=None) + if "entrance_url" not in columns: + op.add_column( + "blog_interactions", + sa.Column("entrance_url", sa.Text(), nullable=False, server_default="legacy_unknown"), + ) + op.alter_column("blog_interactions", "entrance_url", server_default=None) + op.create_index( + "ix_blog_interactions_entrance_kind", + "blog_interactions", + ["entrance_kind"], + if_not_exists=True, + ) + op.create_index( + "ix_blog_interactions_entrance_url", + "blog_interactions", + ["entrance_url"], + if_not_exists=True, + ) + + +def downgrade() -> None: + """Drop entrance metadata columns from blog interaction rows. + + Args: + None. + + Returns: + None. The active database schema is mutated in place. + """ + + columns = _table_columns("blog_interactions") + if not columns: + return + op.drop_index("ix_blog_interactions_entrance_url", table_name="blog_interactions", if_exists=True) + op.drop_index("ix_blog_interactions_entrance_kind", table_name="blog_interactions", if_exists=True) + if "entrance_url" in columns: + op.drop_column("blog_interactions", "entrance_url") + if "entrance_kind" in columns: + op.drop_column("blog_interactions", "entrance_kind") diff --git a/backend/main.py b/backend/main.py index 4d8ef18..bc6c078 100644 --- a/backend/main.py +++ b/backend/main.py @@ -73,6 +73,31 @@ class IncrementBlogUserLabelRequest(BaseModel): previous_label: str | None = None +class CreateRandomRecommendationBatchRequest(BaseModel): + count: int = 9 + visitor_id: str + session_id: str + source: str | None = None + page_url: str | None = None + context: dict[str, Any] | None = None + + +class RecordRecommendationEventRequest(BaseModel): + event_uuid: str + event_type: str + blog_id: int + visitor_id: str + session_id: str + entrance_kind: str + entrance_url: str + request_uuid: str | None = None + impression_id: int | None = None + position: int | None = None + interaction_order: int = 1 + client_event_at: str | None = None + attributes: dict[str, Any] | None = None + + class BlogLabelTitlePreviewRequest(BaseModel): url: str @@ -607,6 +632,42 @@ def lookup_blog_candidates(url: str) -> dict[str, Any]: lambda: get_state().persistence.lookup_blog_candidates(url=url) ) + @app.post("/api/recommendations/random-blog-batches") + def post_random_recommendation_batch( + payload: CreateRandomRecommendationBatchRequest, + user: dict[str, Any] | None = Depends(optional_user), + ) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.create_random_recommendation_batch( + **payload.model_dump(), + user_id=int(user["id"]) if user is not None else None, + ) + ) + + @app.post("/api/recommendation-events") + def post_recommendation_event( + payload: RecordRecommendationEventRequest, + user: dict[str, Any] | None = Depends(optional_user), + ) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.record_blog_interaction( + **payload.model_dump(), + user_id=int(user["id"]) if user is not None else None, + ) + ) + + @app.get("/api/blogs/{blog_id}/stats") + def get_blog_recommendation_stats(blog_id: int) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.get_blog_recommendation_stats(blog_id) + ) + + @app.get("/api/admin/recommendation-stats") + def get_admin_recommendation_stats(_: None = Depends(require_admin_access)) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.get_recommendation_strategy_stats() + ) + @app.get("/api/icons/proxy") def proxy_icon(url: str) -> Response: """Return one remote icon through the backend origin for graph textures. diff --git a/doc/api-docs.md b/doc/api-docs.md index 323b261..70a5e3a 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -74,6 +74,9 @@ Public API 由 `backend` 服务统一暴露,供 public 浏览、图谱与 inge - `POST /api/auth/logout` - `GET /api/me/label-selections` - `GET /api/blogs/catalog` +- `POST /api/recommendations/random-blog-batches` +- `POST /api/recommendation-events` +- `GET /api/blogs/{blog_id}/stats` - `POST /api/blogs/user-seeds` - `POST /api/blogs/{blog_id}/user-labels` - `GET /api/blogs/lookup` @@ -121,6 +124,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - `GET /api/admin/blog-labeling/parquet-export` - `POST /api/admin/blog-labeling/title-preview` - `PUT /api/admin/blog-labeling/labels/{blog_id}` +- `GET /api/admin/recommendation-stats` - `POST /api/admin/blog-dedup-scans` - `GET /api/admin/blog-dedup-scans/latest` - `GET /api/admin/blog-dedup-scans/{run_id}/items` @@ -427,6 +431,174 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 首页搜索框使用 `page=1&page_size=30&url=<输入 URL>&sort=id_desc` 查询已发现博客,并把返回项渲染为可滚动结果列表。 +#### `POST /api/recommendations/random-blog-batches` + +用途:随机博客页请求一组新的推荐卡片,并把本次刷新作为一条可追踪的 recommendation request 持久化。服务端会同时写入有序 impression 记录,所以后续点击、详情打开和标注事件可以归因到“哪次刷新中的第几个 URL”。 + +请求体: + +```json +{ + "count": 9, + "visitor_id": "visitor_lx7...", + "session_id": "session_lx7...", + "source": "random_page", + "page_url": "http://localhost:3000/random", + "context": { + "refresh_kind": "manual" + } +} +``` + +认证说明: + +- 未登录也可调用;`visitor_id` 与 `session_id` 由前端本地生成,用于匿名统计。 +- 登录后可带 `Authorization: Bearer `;backend 会把用户 ID 转发给 persistence 以便后续用户维度分析。 + +行为说明: + +- 当前 surface 固定为 `random_blog_page` +- 当前 strategy 固定为 `weighted_random`,`strategy_version = v1` +- 只返回 `crawl_status=FINISHED` 且 `acceptance_status=ACCEPTED` 的博客 +- 随机排序复用 catalog 的 `sort=random` 权重逻辑:管理员非 blog 标签会过滤,用户公开反馈会影响权重 +- `count` 当前允许 `1..50`;随机页默认请求 `9` + +成功响应示例: + +```json +{ + "request_uuid": "r_abc", + "surface": "random_blog_page", + "strategy": "weighted_random", + "strategy_version": "v1", + "visitor_id": "visitor_lx7", + "session_id": "session_lx7", + "requested_count": 9, + "served_count": 9, + "created_at": "2026-06-07T13:30:00+00:00", + "items": [ + { + "id": 12, + "url": "https://blog.example.com/", + "normalized_url": "https://blog.example.com/", + "request_uuid": "r_abc", + "impression_id": 101, + "position": 1 + } + ] +} +``` + +错误语义: + +- `401`: bearer token 非法或过期 +- `422`: count、visitor/session ID 或 JSON context 非法 + +#### `POST /api/recommendation-events` + +用途:记录随机博客卡片上的用户行为。事件以 `event_uuid` 幂等写入,适合前端在详情跳转、外链打开、标注选择等动作发生时尽力而为上报。 + +请求体: + +```json +{ + "event_uuid": "event_lx7...", + "event_type": "detail_open", + "blog_id": 12, + "visitor_id": "visitor_lx7...", + "session_id": "session_lx7...", + "entrance_kind": "random_blog_page", + "entrance_url": "http://localhost:3000/random", + "request_uuid": "r_abc", + "impression_id": 101, + "position": 1, + "interaction_order": 1, + "client_event_at": "2026-06-07T13:31:00.000Z", + "attributes": { + "label": "blog" + } +} +``` + +支持的 `event_type`: + +- `click` +- `detail_open` +- `external_open` +- `label_select` +- `refresh` +- `dismiss` +- `copy_url` + +行为说明: + +- 同一个 `event_uuid` 重复上报时不会重复计数,响应中会返回 `duplicate: true` +- `entrance_kind` 与 `entrance_url` 为必填字段。`entrance_kind` 使用稳定、可聚合的路口种类,例如 `random_blog_page`、`home_search_result`、`blog_detail_discovery_path`、`blog_detail_relation_graph`;`entrance_url` 保留触发动作时的原始页面 URL 或上下文 URL,便于追溯具体来源。 +- 若传入 `request_uuid` 或 `impression_id`,服务端会校验它们存在且与 `blog_id` 匹配 +- 前端不应因为事件上报失败而阻塞用户跳转或标注主流程 +- 持久化时事件落到 `blog_interactions`,其中 `entrance_kind` 与 `entrance_url` 单独存列并建立索引,便于按稳定路口维度统计详情打开、外链打开和标签选择。 + +错误语义: + +- `404`: 目标 blog 不存在 +- `401`: bearer token 非法或过期 +- `422`: event type、request/impression 归因或 JSON attributes 非法 + +#### `GET /api/blogs/{blog_id}/stats` + +用途:返回单个博客在推荐系统中的曝光和交互统计,供详情页或后续运营面板展示。 + +成功响应示例: + +```json +{ + "blog_id": 12, + "normalized_url": "https://blog.example.com/", + "impressions": 20, + "clicks": 1, + "detail_opens": 3, + "external_opens": 0, + "label_selects": 2, + "unique_visitors": 5, + "ctr": 0.2, + "last_interaction_at": "2026-06-07T13:31:00+00:00", + "by_event_type": { + "detail_open": 3, + "label_select": 2 + } +} +``` + +错误语义: + +- `404`: 目标 blog 不存在 + +#### `GET /api/admin/recommendation-stats` + +用途:返回推荐请求、曝光和交互的策略级汇总。该接口位于 admin API 下,需要 `Authorization: Bearer `。 + +成功响应示例: + +```json +{ + "total_requests": 10, + "total_impressions": 90, + "total_interactions": 12, + "by_strategy": [ + { + "surface": "random_blog_page", + "strategy": "weighted_random", + "strategy_version": "v1", + "requests": 10, + "impressions": 90, + "clicks": 8, + "unique_visitors": 6, + "ctr": 0.0888888889 + } + ] +} +``` + #### `POST /api/blogs/user-seeds` 用途:当首页 URL 搜索没有命中时,允许用户提交一个完整博客链接作为用户来源 seed。该接口只执行确定性规则过滤,跳过 RSS discovery 与模型共识;规则通过后会把 URL 同时写入 `blogs` 与 `seeds`。 @@ -1560,6 +1732,30 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 支持 `statuses` 多状态过滤与 `id_asc` 排序,供统一 discovery 队列视图使用 - blog 行数据会直接带上连接度、活跃度和身份完整度等派生字段 +### `POST /internal/recommendations/random-blog-batches` + +用途:为 backend 创建随机博客推荐批次,并写入 `recommendation_requests` 与 `recommendation_impressions`。 + +请求体字段与 `POST /api/recommendations/random-blog-batches` 一致,额外允许 backend 传入已解析的 `user_id`。 + +### `POST /internal/recommendation-events` + +用途:为 backend 写入幂等推荐交互事件,数据落到 `blog_interactions`。 + +请求体字段与 `POST /api/recommendation-events` 一致,额外允许 backend 传入已解析的 `user_id`。其中 `entrance_kind` 与 `entrance_url` 仍为必填字段,persistence-api 会清洗长度并写入 `blog_interactions.entrance_kind` / `blog_interactions.entrance_url`。 + +### `GET /internal/blogs/{blog_id}/recommendation-stats` + +用途:返回单个博客的推荐曝光、点击/详情打开、标注选择、独立访客和 CTR 统计。 + +返回结构与 `GET /api/blogs/{blog_id}/stats` 一致。 + +### `GET /internal/recommendation-stats` + +用途:返回 strategy/surface/version 维度的推荐请求、曝光、交互和 CTR 汇总。 + +返回结构与 `GET /api/admin/recommendation-stats` 一致。 + ### `GET /internal/blogs/lookup?url=...` 用途:为 backend 提供数据库权威的博客 URL 存在性查询。 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 5729d05..652d2ac 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -307,6 +307,43 @@ beforeEach(() => { }), ); } + if (url.pathname === "/api/recommendations/random-blog-batches") { + const body = JSON.parse(String(init?.body ?? "{}")); + const count = Number(body.count || 9); + const filteredItems = sortCatalogItems( + catalogItems.filter((item) => item.crawl_status === "FINISHED"), + "random", + ).slice(0, count); + return new Response( + JSON.stringify({ + request_uuid: "request-random-1", + surface: "random_blog_page", + strategy: "weighted_random", + strategy_version: "v1", + visitor_id: body.visitor_id, + session_id: body.session_id, + requested_count: count, + served_count: filteredItems.length, + created_at: "2026-06-07T13:30:00Z", + items: filteredItems.map((item, index) => ({ + ...item, + request_uuid: "request-random-1", + impression_id: index + 101, + position: index + 1, + })), + }), + ); + } + if (url.pathname === "/api/recommendation-events") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response( + JSON.stringify({ + id: 1, + ...body, + duplicate: false, + }), + ); + } if (url.pathname === "/api/blogs/user-seeds") { const body = JSON.parse(String(init?.body ?? "{}")); const submittedUrl = String(body.homepage_url || "https://missing-blog.example.com/"); @@ -579,6 +616,17 @@ test("lets home users search normalized URLs and open the blog detail route", as await waitFor(() => { expect(window.location.pathname).toBe("/blogs/3"); }); + expect( + vi + .mocked(fetch) + .mock.calls.some( + ([input, init]) => + String(input).includes("/api/recommendation-events") && + String(init?.body).includes('"event_type":"detail_open"') && + String(init?.body).includes('"entrance_kind":"home_search_result"') && + String(init?.body).includes('"entrance_url"'), + ), + ).toBe(true); expect(screen.queryByRole("heading", { name: "HeyBlog!" })).not.toBeInTheDocument(); await waitFor(() => { expect(screen.getByRole("heading", { name: "Finished Blog" })).toBeInTheDocument(); @@ -685,8 +733,11 @@ test("adds a random blog route that loads nine finished cards and refreshes them }); expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/blogs/catalog?page=1&page_size=9&sort=random&status=FINISHED"), - expect.anything(), + expect.stringContaining("/api/recommendations/random-blog-batches"), + expect.objectContaining({ + method: "POST", + body: expect.stringContaining('"count":9'), + }), ); expect(screen.getByText("当前展示 9 个随机博客卡片")).toBeInTheDocument(); expect(screen.getByText("Extra Blog 32")).toBeInTheDocument(); @@ -701,7 +752,7 @@ test("adds a random blog route that loads nine finished cards and refreshes them const randomCalls = vi .mocked(fetch) .mock.calls.filter(([input]) => - String(input).includes("/api/blogs/catalog?page=1&page_size=9&sort=random&status=FINISHED"), + String(input).includes("/api/recommendations/random-blog-batches"), ); expect(randomCalls).toHaveLength(2); }); @@ -721,9 +772,73 @@ test("lets random blog users open one blog detail route", async () => { await waitFor(() => { expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/api/blogs/32"), expect.anything()); }); + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/recommendation-events"), + expect.objectContaining({ + method: "POST", + body: expect.stringContaining('"event_type":"detail_open"'), + }), + ); + }); + expect( + vi + .mocked(fetch) + .mock.calls.some( + ([input, init]) => + String(input).includes("/api/recommendation-events") && + String(init?.body).includes('"entrance_kind":"random_blog_page"') && + String(init?.body).includes('"entrance_url"'), + ), + ).toBe(true); expect(await screen.findByRole("heading", { name: "Extra Blog 32" })).toBeInTheDocument(); }); +test("records random blog external URL opens as recommendation interactions", async () => { + window.history.replaceState({}, "", "/random"); + + render(); + + await waitFor(() => { + expect(screen.getByText("当前展示 9 个随机博客卡片")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getAllByRole("link", { name: /打开 Extra Blog 32/i })[0]); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/recommendation-events"), + expect.objectContaining({ + method: "POST", + body: expect.stringContaining('"event_type":"external_open"'), + }), + ); + }); + expect( + vi + .mocked(fetch) + .mock.calls.some( + ([input, init]) => + String(input).includes("/api/recommendation-events") && + String(init?.body).includes('"entrance_kind":"random_blog_page"') && + String(init?.body).includes('"entrance_url"'), + ), + ).toBe(true); +}); + +test("renders one external URL text per random blog card", async () => { + window.history.replaceState({}, "", "/random"); + + render(); + + await waitFor(() => { + expect(screen.getByText("当前展示 9 个随机博客卡片")).toBeInTheDocument(); + }); + + const firstRandomBlog = catalogItems[31]; + expect(screen.getAllByText(String(firstRandomBlog.url))).toHaveLength(1); +}); + test("lets visualization users choose a graph size with a blog-count slider", async () => { window.history.replaceState({}, "", "/visualization"); diff --git a/frontend/src/components/BlogCard.tsx b/frontend/src/components/BlogCard.tsx index 011ac92..d0bf450 100644 --- a/frontend/src/components/BlogCard.tsx +++ b/frontend/src/components/BlogCard.tsx @@ -1,11 +1,14 @@ -import { ArrowUpRight, CheckCircle2, Clock3, XCircle } from "lucide-react"; +import { CheckCircle2, Clock3, XCircle } from "lucide-react"; import { useState, type ReactNode } from "react"; +import { BlogExternalLink } from "./BlogExternalLink"; import { resolveBlogIconUrl } from "../lib/icon"; import type { BlogCatalogItem } from "../types/graph"; interface BlogCardProps { blog: BlogCatalogItem; children?: ReactNode; + externalEntranceKind: string; + externalEntranceUrl: string; } function statusTone(crawlStatus: string) { @@ -41,9 +44,11 @@ function statusTone(crawlStatus: string) { * Render one catalog blog card in the example-inspired home layout. * * @param blog Catalog row returned by `/api/blogs/catalog`. + * @param externalEntranceKind Stable entry-point category for external-open tracking. + * @param externalEntranceUrl Raw entry-point URL for external-open tracking. * @returns Blog summary card. */ -export function BlogCard({ blog, children }: BlogCardProps) { +export function BlogCard({ blog, children, externalEntranceKind, externalEntranceUrl }: BlogCardProps) { const tone = statusTone(blog.crawlStatus); const ToneIcon = tone.icon; const iconUrl = resolveBlogIconUrl(blog); @@ -78,17 +83,17 @@ export function BlogCard({ blog, children }: BlogCardProps) {
-
{children ?
{children}
: null} diff --git a/frontend/src/components/BlogDetailLink.tsx b/frontend/src/components/BlogDetailLink.tsx new file mode 100644 index 0000000..be67d30 --- /dev/null +++ b/frontend/src/components/BlogDetailLink.tsx @@ -0,0 +1,45 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { openTrackedBlogDetail, type BlogInteractionEntrance } from "../lib/blogInteractions"; +import type { BlogCatalogItem, GraphNode } from "../types/graph"; + +interface BlogDetailLinkProps extends Omit, "onClick"> { + blog: BlogCatalogItem | GraphNode; + entranceKind: string; + entranceUrl: string; + children: ReactNode; + eventAttributes?: Record; +} + +/** + * Render the canonical tracked navigation control for blog detail routes. + * + * @param blog Blog target whose detail route should open. + * @param entranceKind Stable entry-point category for analytics. + * @param entranceUrl Raw entry-point URL for analytics. + * @param children Visible button content. + * @param eventAttributes Optional event metadata sent with the interaction. + * @returns Button that records `detail_open` and navigates to `/blogs/:id`. + */ +export function BlogDetailLink({ + blog, + entranceKind, + entranceUrl, + children, + eventAttributes, + ...buttonProps +}: BlogDetailLinkProps) { + const navigate = useNavigate(); + const entrance: BlogInteractionEntrance = { entranceKind, entranceUrl }; + return ( + + ); +} diff --git a/frontend/src/components/BlogDetailPanel.tsx b/frontend/src/components/BlogDetailPanel.tsx index af20660..9fade9a 100644 --- a/frontend/src/components/BlogDetailPanel.tsx +++ b/frontend/src/components/BlogDetailPanel.tsx @@ -1,9 +1,12 @@ -import { ArrowLeft, ArrowRight, ExternalLink, Sparkles, X } from "lucide-react"; +import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react"; +import { BlogExternalLink } from "./BlogExternalLink"; import type { BlogDetail } from "../types/graph"; interface BlogDetailPanelProps { detail: BlogDetail; onClose: () => void; + entranceKind?: string; + entranceUrl?: string; } /** @@ -11,9 +14,16 @@ interface BlogDetailPanelProps { * * @param detail Selected blog detail payload. * @param onClose Callback used to dismiss the panel. + * @param entranceKind Optional panel entry-point category for external-open tracking. + * @param entranceUrl Optional panel entry-point URL for external-open tracking. * @returns Floating detail panel. */ -export function BlogDetailPanel({ detail, onClose }: BlogDetailPanelProps) { +export function BlogDetailPanel({ + detail, + onClose, + entranceKind = "blog_detail_panel_external", + entranceUrl = window.location.href, +}: BlogDetailPanelProps) { return (
@@ -29,15 +39,14 @@ export function BlogDetailPanel({ detail, onClose }: BlogDetailPanelProps) {
diff --git a/frontend/src/components/BlogExternalLink.tsx b/frontend/src/components/BlogExternalLink.tsx new file mode 100644 index 0000000..37e3809 --- /dev/null +++ b/frontend/src/components/BlogExternalLink.tsx @@ -0,0 +1,48 @@ +import { ArrowUpRight } from "lucide-react"; +import type { AnchorHTMLAttributes, ReactNode } from "react"; +import { blogInteractionTarget, recordBlogInteraction, type BlogInteractionEntrance } from "../lib/blogInteractions"; +import type { BlogCatalogItem, GraphNode } from "../types/graph"; + +interface BlogExternalLinkProps extends Omit, "href" | "target" | "rel" | "onClick"> { + blog: BlogCatalogItem | GraphNode; + entranceKind: string; + entranceUrl: string; + children?: ReactNode; + showIcon?: boolean; + eventAttributes?: Record; +} + +/** + * Render the canonical tracked external link for a blog URL. + * + * @param blog Blog target whose external URL should open. + * @param entranceKind Stable entry-point category for analytics. + * @param entranceUrl Raw entry-point URL for analytics. + * @param children Optional visible link content. + * @param showIcon Whether to append the external-link icon. + * @param eventAttributes Optional event metadata sent with the interaction. + * @returns External anchor that records `external_open` before opening. + */ +export function BlogExternalLink({ + blog, + entranceKind, + entranceUrl, + children, + showIcon = true, + eventAttributes, + ...anchorProps +}: BlogExternalLinkProps) { + const entrance: BlogInteractionEntrance = { entranceKind, entranceUrl }; + return ( + recordBlogInteraction(blogInteractionTarget(blog), "external_open", entrance, eventAttributes)} + > + {children ?? blog.url} + {showIcon ? : null} + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f85b647..b73ccce 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -17,6 +17,8 @@ import type { GraphMeta, GraphNode, LookupResult, + RandomRecommendationBatch, + RecommendationEventInput, RecommendedBlog, StatsData, StatusData, @@ -66,6 +68,9 @@ interface BackendGraphNode { outgoing_count?: number; activity_at?: string | null; identity_complete?: boolean; + request_uuid?: string; + impression_id?: number; + position?: number; x?: number; y?: number; degree?: number; @@ -212,6 +217,19 @@ interface BackendCatalogPayload { sort: string; } +interface BackendRandomRecommendationBatchPayload { + request_uuid: string; + surface: string; + strategy: string; + strategy_version: string; + visitor_id: string; + session_id: string; + requested_count: number; + served_count: number; + created_at: string | null; + items: BackendGraphNode[]; +} + interface CreateIngestionRequestPayload { request_id: number; request_token: string; @@ -380,6 +398,9 @@ function toBlogCatalogItem(node: BackendGraphNode): BlogCatalogItem { return { ...toGraphNode(node), normalizedUrl: node.normalized_url ?? node.url, + requestUuid: node.request_uuid, + impressionId: node.impression_id, + position: node.position, identityKey: node.identity_key ?? "", identityReasonCodes: node.identity_reason_codes ?? [], identityRulesetVersion: node.identity_ruleset_version ?? "", @@ -398,6 +419,27 @@ function toBlogCatalogItem(node: BackendGraphNode): BlogCatalogItem { }; } +/** + * Convert one backend recommendation batch into the frontend random-page model. + * + * @param payload Backend recommendation batch payload. + * @returns Normalized batch with catalog items. + */ +function toRandomRecommendationBatch(payload: BackendRandomRecommendationBatchPayload): RandomRecommendationBatch { + return { + requestUuid: payload.request_uuid, + surface: payload.surface, + strategy: payload.strategy, + strategyVersion: payload.strategy_version, + visitorId: payload.visitor_id, + sessionId: payload.session_id, + requestedCount: payload.requested_count, + servedCount: payload.served_count, + createdAt: payload.created_at, + items: payload.items.map(toBlogCatalogItem), + }; +} + /** * Convert one backend relation graph into frontend graph coordinates. * @@ -905,6 +947,68 @@ export async function fetchBlogsCatalog(query: BlogCatalogQuery = {}): Promise; + token?: string | null; +}): Promise { + const payload = await apiJson("/api/recommendations/random-blog-batches", { + method: "POST", + headers: input.token ? authHeaders(input.token) : undefined, + body: JSON.stringify({ + count: input.count, + visitor_id: input.visitorId, + session_id: input.sessionId, + source: input.source, + page_url: input.pageUrl, + context: input.context, + }), + }); + return toRandomRecommendationBatch(payload); +} + +/** + * Record one recommendation event without changing page state. + * + * @param input Event attribution and metadata. + * @param token Optional auth token used for registered-user attribution. + * @returns Promise resolved after the backend accepts the event. + */ +export async function postRecommendationEvent( + input: RecommendationEventInput, + token?: string | null, +): Promise { + await apiJson("/api/recommendation-events", { + method: "POST", + headers: token ? authHeaders(token) : undefined, + body: JSON.stringify({ + event_uuid: input.eventUuid, + event_type: input.eventType, + blog_id: input.blogId, + visitor_id: input.visitorId, + session_id: input.sessionId, + entrance_kind: input.entranceKind, + entrance_url: input.entranceUrl, + request_uuid: input.requestUuid, + impression_id: input.impressionId, + position: input.position, + interaction_order: input.interactionOrder, + client_event_at: input.clientEventAt, + attributes: input.attributes, + }), + }); +} + /** * Submit one ingestion request when a searched blog is missing. * diff --git a/frontend/src/lib/blogInteractions.ts b/frontend/src/lib/blogInteractions.ts new file mode 100644 index 0000000..086208f --- /dev/null +++ b/frontend/src/lib/blogInteractions.ts @@ -0,0 +1,133 @@ +import { readStoredAuthSession } from "./auth"; +import { postRecommendationEvent } from "./api"; +import type { NavigateFunction } from "react-router-dom"; +import type { BlogCatalogItem, GraphNode } from "../types/graph"; + +const BLOG_VISITOR_STORAGE_KEY = "heyblog.blog_interactions.visitor_id"; +const BLOG_SESSION_STORAGE_KEY = "heyblog.blog_interactions.session_id"; + +let interactionOrder = 0; + +export interface BlogInteractionEntrance { + entranceKind: string; + entranceUrl: string; +} + +export interface BlogInteractionTarget { + id: number; + requestUuid?: string; + impressionId?: number; + position?: number; +} + +/** + * Convert a graph node into the minimal interaction target shape. + * + * @param blog Blog-like frontend model. + * @returns Target fields required by the interaction event API. + */ +export function blogInteractionTarget(blog: BlogCatalogItem | GraphNode): BlogInteractionTarget { + return { + id: blog.id, + requestUuid: "requestUuid" in blog ? blog.requestUuid : undefined, + impressionId: "impressionId" in blog ? blog.impressionId : undefined, + position: "position" in blog ? blog.position : undefined, + }; +} + +/** + * Create one browser-local random identifier without requiring crypto support. + * + * @param prefix Stable prefix that identifies the ID family. + * @returns URL-safe identifier string. + */ +export function createBlogInteractionId(prefix: string) { + const random = Math.random().toString(36).slice(2); + return `${prefix}_${Date.now().toString(36)}_${random}`; +} + +/** + * Read or create the browser-stable visitor ID used for blog interactions. + * + * @returns Stable local visitor ID. + */ +export function getBlogInteractionVisitorId() { + const existing = localStorage.getItem(BLOG_VISITOR_STORAGE_KEY); + if (existing) { + return existing; + } + const created = createBlogInteractionId("visitor"); + localStorage.setItem(BLOG_VISITOR_STORAGE_KEY, created); + return created; +} + +/** + * Read or create the tab-session ID used for blog interactions. + * + * @returns Stable session ID for the current tab session. + */ +export function getBlogInteractionSessionId() { + const existing = sessionStorage.getItem(BLOG_SESSION_STORAGE_KEY); + if (existing) { + return existing; + } + const created = createBlogInteractionId("session"); + sessionStorage.setItem(BLOG_SESSION_STORAGE_KEY, created); + return created; +} + +/** + * Record one non-blocking blog interaction with required entrance metadata. + * + * @param target Blog interaction target. + * @param eventType Event type recognized by the backend. + * @param entrance Required entry-point metadata for later aggregation. + * @param attributes Optional event metadata. + */ +export function recordBlogInteraction( + target: BlogInteractionTarget, + eventType: string, + entrance: BlogInteractionEntrance, + attributes?: Record, +) { + interactionOrder += 1; + const session = readStoredAuthSession(); + void postRecommendationEvent( + { + eventUuid: createBlogInteractionId("event"), + eventType, + blogId: target.id, + visitorId: getBlogInteractionVisitorId(), + sessionId: getBlogInteractionSessionId(), + entranceKind: entrance.entranceKind, + entranceUrl: entrance.entranceUrl, + requestUuid: target.requestUuid, + impressionId: target.impressionId, + position: target.position, + interactionOrder, + clientEventAt: new Date().toISOString(), + attributes, + }, + session?.token, + ).catch((error: unknown) => { + console.warn("Failed to record blog interaction", error); + }); +} + +/** + * Record and open the canonical blog detail route. + * + * @param navigate React Router navigation function. + * @param blog Blog target whose detail route should open. + * @param entrance Required entry-point metadata for later aggregation. + * @param attributes Optional event metadata. + */ +export function openTrackedBlogDetail( + navigate: NavigateFunction, + blog: BlogCatalogItem | GraphNode, + entrance: BlogInteractionEntrance, + attributes?: Record, +) { + recordBlogInteraction(blogInteractionTarget(blog), "detail_open", entrance, attributes); + navigate(`/blogs/${blog.id}`); +} diff --git a/frontend/src/pages/BlogDetailPage.tsx b/frontend/src/pages/BlogDetailPage.tsx index 4fc72da..21cb68c 100644 --- a/frontend/src/pages/BlogDetailPage.tsx +++ b/frontend/src/pages/BlogDetailPage.tsx @@ -1,22 +1,27 @@ import { ArrowLeft, ArrowRight, - ArrowUpRight, Loader2, Network, Route, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ForceGraph2D, { type ForceGraphMethods } from "react-force-graph-2d"; -import { Link, useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; +import { BlogDetailLink } from "../components/BlogDetailLink"; +import { BlogExternalLink } from "../components/BlogExternalLink"; import { Navigation } from "../components/Navigation"; import { fetchBlogDetail } from "../lib/api"; +import { openTrackedBlogDetail } from "../lib/blogInteractions"; import { resolveBlogIconUrls, resolveIconProxyUrl } from "../lib/icon"; import type { BlogDetail, BlogDiscoveryPath, BlogDiscoveryStep, BlogRelationGraph, GraphNode } from "../types/graph"; const RELATION_GRAPH_LINK_DISTANCE = 78; const RELATION_GRAPH_CHARGE_STRENGTH = -260; +const DETAIL_PAGE_EXTERNAL_ENTRANCE_KIND = "blog_detail_hero_external"; +const DETAIL_DISCOVERY_PATH_ENTRANCE_KIND = "blog_detail_discovery_path"; +const DETAIL_RELATION_GRAPH_ENTRANCE_KIND = "blog_detail_relation_graph"; /** * Format a numeric count for compact detail cards. @@ -67,7 +72,7 @@ function BlogHeroIcon({ detail }: { detail: BlogDetail }) { * @param props Discovery step returned by the blog detail API. * @returns Clickable blog card with title, icon, and URL. */ -function DiscoveryPathCard({ step }: { step: BlogDiscoveryStep }) { +function DiscoveryPathCard({ step, entranceUrl }: { step: BlogDiscoveryStep; entranceUrl: string }) { const blog = { id: step.blogId, url: step.url, @@ -84,8 +89,10 @@ function DiscoveryPathCard({ step }: { step: BlogDiscoveryStep }) { }, [step.blogId, step.url, step.domain, step.blog?.iconUrl]); return ( -
@@ -106,7 +113,7 @@ function DiscoveryPathCard({ step }: { step: BlogDiscoveryStep }) {
{step.blog?.title || step.domain}
{step.url}
- + ); } @@ -116,7 +123,7 @@ function DiscoveryPathCard({ step }: { step: BlogDiscoveryStep }) { * @param props Discovery path payload. * @returns Historical discovery path section or null when unavailable. */ -function DiscoveryPathSection({ path }: { path: BlogDiscoveryPath | null }) { +function DiscoveryPathSection({ path, entranceUrl }: { path: BlogDiscoveryPath | null; entranceUrl: string }) { if (!path || path.steps.length === 0) { return null; } @@ -131,7 +138,7 @@ function DiscoveryPathSection({ path }: { path: BlogDiscoveryPath | null }) {
{path.steps.map((step, index) => (
- + {index < path.steps.length - 1 ? (
@@ -277,7 +284,7 @@ function paintRelationPointerArea(node: RelationRenderNode, paintColor: string, * @param props Directional relation graph payload. * @returns 2D force-graph relation view. */ -function RelationGraphView({ graph }: { graph: BlogRelationGraph }) { +function RelationGraphView({ graph, entranceUrl }: { graph: BlogRelationGraph; entranceUrl: string }) { const navigate = useNavigate(); const graphRef = useRef | undefined>(undefined); const containerRef = useRef(null); @@ -387,7 +394,17 @@ function RelationGraphView({ graph }: { graph: BlogRelationGraph }) { d3VelocityDecay={0.34} d3AlphaDecay={0.04} onNodeHover={(node) => setHoveredBlog(node?.original ?? null)} - onNodeClick={(node) => navigate(`/blogs/${node.blogId}`)} + onNodeClick={(node) => { + openTrackedBlogDetail( + navigate, + node.original, + { + entranceKind: DETAIL_RELATION_GRAPH_ENTRANCE_KIND, + entranceUrl, + }, + { relation_direction: graph.direction, focus_blog_id: graph.focusBlogId }, + ); + }} showPointerCursor={(item) => Boolean(item && "blogId" in item)} /> ) : null} @@ -415,7 +432,7 @@ function RelationGraphView({ graph }: { graph: BlogRelationGraph }) { * @param props Incoming and outgoing relation graphs. * @returns Blog association section with two graph pages. */ -function BlogAssociationSection({ detail }: { detail: BlogDetail }) { +function BlogAssociationSection({ detail, entranceUrl }: { detail: BlogDetail; entranceUrl: string }) { const [activeGraph, setActiveGraph] = useState<"incoming" | "outgoing">("incoming"); const graph = detail.relationGraphs[activeGraph]; @@ -448,7 +465,7 @@ function BlogAssociationSection({ detail }: { detail: BlogDetail }) {
{graph.nodes.length > 1 ? ( - + ) : (
暂无{activeGraph === "incoming" ? "入链" : "出链"}关联。 @@ -548,15 +565,14 @@ export function BlogDetailPage() {

{detail.title || detail.domain}

{detail.domain}
- {detail.url} - - +
@@ -585,9 +601,9 @@ export function BlogDetailPage() {
- + - +
@@ -131,6 +185,64 @@ export function HomePage() {

+
+
+ + setSearchInput(event.target.value)} + placeholder="输入你的博客链接,看看你的博客有没有被找到吧!" + disabled={isSearching} + className="w-full rounded-lg border border-slate-300 bg-white px-5 py-4 pr-14 text-base text-slate-950 shadow-sm outline-none transition-colors placeholder:text-slate-400 focus:border-sky-500 focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:bg-slate-50" + /> + +
+ + {hasSearched ? ( +
+
+ 搜索结果 + {searchTotalItems} 个匹配 +
+ {searchResults.length > 0 ? ( +
+ {searchResults.map((blog) => ( + + ))} +
+ ) : ( +
未找到与 {lastSearchQuery} 匹配的博客。
+ )} +
+ ) : null} +
+
From 8b0c34ff9b58bea6d037e2c1c5e23f83a8db40e5 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sat, 6 Jun 2026 16:08:55 +0100 Subject: [PATCH 14/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=8D=9A=E5=AE=A2?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E5=88=9D=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.test.tsx | 63 ++++++- frontend/src/pages/BlogDetailPage.tsx | 261 +++++++++++++++++++++++++- frontend/src/pages/HomePage.tsx | 43 ++++- 3 files changed, 360 insertions(+), 7 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index ecd44ea..573e29c 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -40,6 +40,42 @@ function makeCatalogItem(id: number, crawlStatus: string, title: string) { }; } +function makeDetailPayload(item: Record) { + const relatedBlog = makeCatalogItem(88, "FINISHED", "Related Blog"); + const recommendedBlog = makeCatalogItem(89, "FINISHED", "Recommended Blog"); + const viaBlog = makeCatalogItem(90, "FINISHED", "Mutual Blog"); + return { + ...item, + icon_url: `https://${String(item.domain)}/favicon.ico`, + incoming_edges: [ + { + id: "incoming-1", + from_blog_id: relatedBlog.id, + to_blog_id: item.id, + link_text: "friend", + link_url_raw: item.url, + neighbor_blog: relatedBlog, + }, + ], + outgoing_edges: [ + { + id: "outgoing-1", + from_blog_id: item.id, + to_blog_id: relatedBlog.id, + link_text: "blogroll", + link_url_raw: relatedBlog.url, + neighbor_blog: relatedBlog, + }, + ], + recommended_blogs: [ + { + ...recommendedBlog, + via_blogs: [viaBlog], + }, + ], + }; +} + function sortCatalogItems(items: Array>, sort: string) { const copied = [...items]; if (sort === "id_desc") { @@ -154,6 +190,14 @@ beforeEach(() => { }), ); } + const blogDetailMatch = url.pathname.match(/^\/api\/blogs\/(\d+)$/); + if (blogDetailMatch) { + const detailItem = catalogItems.find((item) => Number(item.id) === Number(blogDetailMatch[1])); + if (!detailItem) { + return new Response(JSON.stringify({ detail: "not_found" }), { status: 404 }); + } + return new Response(JSON.stringify(makeDetailPayload(detailItem))); + } if (url.pathname === "/api/status") { return new Response(JSON.stringify(statusPayload)); } @@ -360,7 +404,10 @@ test("renders the home summary with URL search while keeping queue metrics and c expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining("/api/status"), expect.anything()); }); -test("lets home users search normalized URLs and open a blank blog detail route", async () => { +test("lets home users search normalized URLs and open the blog detail route", async () => { + catalogItems = catalogItems.map((item) => + Number(item.id) === 3 ? { ...item, icon_url: "https://finished-blog.example.com/favicon.ico" } : item, + ); render(); const input = await screen.findByPlaceholderText("输入你的博客链接,看看你的博客有没有被找到吧!"); @@ -381,6 +428,10 @@ test("lets home users search normalized URLs and open a blank blog detail route" expect(screen.getByText("1 个匹配")).toBeInTheDocument(); expect(screen.getByText("Finished Blog")).toBeInTheDocument(); expect(screen.getByText("https://finished-blog.example.com/")).toBeInTheDocument(); + expect(screen.getByAltText("finished-blog.example.com icon")).toHaveAttribute( + "src", + "https://finished-blog.example.com/favicon.ico", + ); fireEvent.click(screen.getByRole("button", { name: /Finished Blog/i })); @@ -388,6 +439,16 @@ test("lets home users search normalized URLs and open a blank blog detail route" expect(window.location.pathname).toBe("/blogs/3"); }); expect(screen.queryByRole("heading", { name: "HeyBlog!" })).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Finished Blog" })).toBeInTheDocument(); + }); + expect(screen.getByAltText("finished-blog.example.com icon")).toHaveAttribute( + "src", + "https://finished-blog.example.com/favicon.ico", + ); + expect(screen.getByRole("heading", { name: "直接相关博客" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "推荐博客" })).toBeInTheDocument(); + expect(screen.getByText("通过 Mutual Blog 关联")).toBeInTheDocument(); }); test("adds a random blog route that loads nine finished cards and refreshes them on demand", async () => { diff --git a/frontend/src/pages/BlogDetailPage.tsx b/frontend/src/pages/BlogDetailPage.tsx index acd9459..bd034a1 100644 --- a/frontend/src/pages/BlogDetailPage.tsx +++ b/frontend/src/pages/BlogDetailPage.tsx @@ -1,14 +1,269 @@ +import { ArrowLeft, ArrowRight, ArrowUpRight, GitBranch, Loader2, Network, Sparkles } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { toast } from "sonner"; import { Navigation } from "../components/Navigation"; +import { fetchBlogDetail } from "../lib/api"; +import { resolveBlogIconUrls } from "../lib/icon"; +import type { BlogDetail, GraphNode, RecommendedBlog } from "../types/graph"; /** - * Render the temporary blog detail route shell. + * Format a numeric count for compact detail cards. * - * @returns Blank blog detail page placeholder. + * @param value Count value to display. + * @returns Localized count string. + */ +function formatCount(value: number) { + return new Intl.NumberFormat("zh-CN").format(value); +} + +/** + * Render one compact blog link in related and recommendation lists. + * + * @param props Blog row and optional supporting copy. + * @returns Clickable blog summary row. + */ +function BlogListItem({ blog, helperText }: { blog: GraphNode | RecommendedBlog; helperText?: string }) { + return ( + +
+
{blog.title || blog.domain}
+
{blog.domain}
+ {helperText ?
{helperText}
: null} +
+ + ); +} + +/** + * Render a detail page hero icon with favicon fallbacks. + * + * @param props Blog detail node used to resolve icon candidates. + * @returns Blog icon image or a text fallback. + */ +function BlogHeroIcon({ detail }: { detail: BlogDetail }) { + const iconUrls = resolveBlogIconUrls(detail); + const [iconIndex, setIconIndex] = useState(0); + const iconUrl = iconUrls[iconIndex]; + + useEffect(() => { + setIconIndex(0); + }, [detail.id, detail.iconUrl, detail.url, detail.domain]); + + return ( +
+ {iconUrl ? ( + {`${detail.domain} setIconIndex((currentIndex) => currentIndex + 1)} + /> + ) : ( + {(detail.domain || "?").slice(0, 1).toUpperCase()} + )} +
+ ); +} + +/** + * Render the public blog detail page. + * + * @returns Blog detail route UI. */ export function BlogDetailPage() { + const { blogId } = useParams(); + const navigate = useNavigate(); + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + const numericBlogId = Number(blogId); + const relatedBlogs = detail?.relatedNodes ?? []; + const recommendedBlogs = detail?.recommendedBlogs ?? []; + + useEffect(() => { + let isDisposed = false; + + /** + * Load the route blog detail payload. + * + * @returns Promise resolved when detail state settles. + */ + async function loadDetail() { + if (!Number.isInteger(numericBlogId) || numericBlogId <= 0) { + setErrorMessage("博客 ID 无效。"); + setIsLoading(false); + return; + } + + setIsLoading(true); + setErrorMessage(null); + try { + const payload = await fetchBlogDetail(numericBlogId); + if (!isDisposed) { + setDetail(payload); + } + } catch { + if (!isDisposed) { + setDetail(null); + setErrorMessage("博客详情加载失败。"); + toast.error("博客详情加载失败,请稍后重试。"); + } + } finally { + if (!isDisposed) { + setIsLoading(false); + } + } + } + + void loadDetail(); + return () => { + isDisposed = true; + }; + }, [numericBlogId]); + return ( -
+
+
+ + + {isLoading ? ( +
+
+ +
正在加载博客详情...
+
+
+ ) : null} + + {!isLoading && errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {!isLoading && detail ? ( +
+
+
+
+ +

{detail.title || detail.domain}

+
{detail.domain}
+ + {detail.url} + + +
+
+
+ +
+
+
+ +
+
入链
+
{formatCount(detail.incomingLinks)}
+
+
+
+ +
+
出链
+
{formatCount(detail.outgoingLinks)}
+
+
+
+ +
+
直接相关博客
+
{formatCount(relatedBlogs.length)}
+
+
+ +
+
+
+ +

直接相关博客

+
+ {relatedBlogs.length > 0 ? ( +
+ {relatedBlogs.map((blog) => ( + + ))} +
+ ) : ( +
暂无直接相关博客。
+ )} +
+ + +
+
+ ) : null} +
); } diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index e6592a2..592695f 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -4,11 +4,45 @@ import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { Navigation } from "../components/Navigation"; import { fetchBlogsCatalog, fetchStats } from "../lib/api"; +import { resolveBlogIconUrls } from "../lib/icon"; import type { BlogCatalogItem, StatsData } from "../types/graph"; const HOME_REFRESH_INTERVAL_MS = 5000; const HOME_SEARCH_PAGE_SIZE = 30; +/** + * Render the icon used in one homepage search result row. + * + * @param props Blog catalog item used for icon resolution. + * @returns Blog icon image or text fallback. + */ +function SearchResultIcon({ blog }: { blog: BlogCatalogItem }) { + const iconUrls = resolveBlogIconUrls(blog); + const [iconIndex, setIconIndex] = useState(0); + const iconUrl = iconUrls[iconIndex]; + + useEffect(() => { + setIconIndex(0); + }, [blog.id, blog.iconUrl, blog.url, blog.domain]); + + return ( +
+ {iconUrl ? ( + {`${blog.domain} setIconIndex((currentIndex) => currentIndex + 1)} + /> + ) : ( + {(blog.domain || "?").slice(0, 1).toUpperCase()} + )} +
+ ); +} + /** * Render the public home page summary without the status-filtered blog catalog. * @@ -225,9 +259,12 @@ export function HomePage() { className="block w-full border-b border-slate-100 px-4 py-4 text-left transition-colors last:border-b-0 hover:bg-sky-50 focus:bg-sky-50 focus:outline-none" >
-
-
{blog.title || blog.domain}
-
{blog.normalizedUrl}
+
+ +
+
{blog.title || blog.domain}
+
{blog.normalizedUrl}
+
{blog.crawlStatus} From 5927259f4b1e0ddae1e298bce4bf30e8f463eab5 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sat, 6 Jun 2026 20:23:33 +0100 Subject: [PATCH 15/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=BA=86=E5=8D=9A=E5=AE=A2=E6=90=9C=E7=B4=A2=EF=BC=8C=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E6=B2=A1=E6=90=9C=E7=B4=A2=E5=88=B0=E7=9A=84=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E9=80=89=E6=8B=A9=E7=9B=B4=E6=8E=A5=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/20260606_01_add_seed_table.py | 74 ++++++ backend/main.py | 19 ++ crawler/crawling/bootstrap.py | 68 +++++- crawler/crawling/decisions/rule_helpers.py | 2 +- doc/api-docs.md | 66 ++++- frontend/src/App.test.tsx | 86 ++++++- .../components/MissingBlogConfirmDialog.tsx | 111 +++++++++ frontend/src/lib/api.ts | 87 ++++++- frontend/src/pages/HomePage.tsx | 30 ++- persistence_api/main.py | 18 ++ persistence_api/models.py | 25 ++ persistence_api/repository.py | 230 ++++++++++++++++-- shared/http_clients/persistence_http.py | 33 +++ tests/test_pipeline.py | 79 ++++++ tests/test_repository.py | 61 ++++- tests/test_service_split.py | 33 +++ 16 files changed, 987 insertions(+), 35 deletions(-) create mode 100644 alembic/versions/20260606_01_add_seed_table.py create mode 100644 frontend/src/components/MissingBlogConfirmDialog.tsx diff --git a/alembic/versions/20260606_01_add_seed_table.py b/alembic/versions/20260606_01_add_seed_table.py new file mode 100644 index 0000000..2b53969 --- /dev/null +++ b/alembic/versions/20260606_01_add_seed_table.py @@ -0,0 +1,74 @@ +"""Persist imported seed CSV rows in a dedicated table. + +Revision ID: 20260606_01 +Revises: 20260602_01 +Create Date: 2026-06-06 16:27:56 BST +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260606_01" +down_revision = "20260602_01" +branch_labels = None +depends_on = None + + +def _tables() -> set[str]: + """Return currently present database table names. + + Args: + None. + + Returns: + Set of table names currently present in the migration target. + """ + + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def upgrade() -> None: + """Create the durable seed import table. + + Args: + None. + + Returns: + None. The migration mutates the active database schema in place. + """ + + if "seeds" in _tables(): + return + op.create_table( + "seeds", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("url", sa.Text(), nullable=False), + sa.Column("normalized_url", sa.Text(), nullable=False), + sa.Column("domain", sa.Text(), nullable=False), + sa.Column("source_path", sa.Text(), nullable=True), + sa.Column("source_row", sa.Integer(), nullable=True), + sa.Column("blog_id", sa.Integer(), sa.ForeignKey("blogs.blog_id", ondelete="SET NULL"), nullable=True), + sa.Column("imported_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("normalized_url", name="uq_seeds_normalized_url"), + ) + op.create_index("ix_seeds_normalized_url", "seeds", ["normalized_url"]) + + +def downgrade() -> None: + """Drop the durable seed import table. + + Args: + None. + + Returns: + None. The migration mutates the active database schema in place. + """ + + if "seeds" not in _tables(): + return + op.drop_index("ix_seeds_normalized_url", table_name="seeds") + op.drop_table("seeds") diff --git a/backend/main.py b/backend/main.py index 89a3b0a..4d8ef18 100644 --- a/backend/main.py +++ b/backend/main.py @@ -53,6 +53,10 @@ class CreateIngestionRequest(BaseModel): email: str +class CreateUserSeedRequest(BaseModel): + homepage_url: str + + class UserAuthRequest(BaseModel): email: str password: str @@ -847,6 +851,21 @@ def create_ingestion_request(payload: CreateIngestionRequest) -> dict[str, Any]: ) return result + @app.post("/api/blogs/user-seeds") + def create_user_seed(payload: CreateUserSeedRequest) -> dict[str, Any]: + result = _call_upstream_with_http_error_translation( + lambda: get_state().persistence.create_user_seed(**payload.model_dump()) + ) + log_event( + LOGGER, + event="blog.user_seed.created", + message="user seed created", + stage="ingestion", + run_id=result.get("blog_id"), + url=payload.homepage_url, + ) + return result + @app.get("/api/ingestion-requests") def list_priority_ingestion_requests() -> list[dict[str, Any]]: return _call_upstream_with_http_error_translation( diff --git a/crawler/crawling/bootstrap.py b/crawler/crawling/bootstrap.py index 81cae71..91b2727 100644 --- a/crawler/crawling/bootstrap.py +++ b/crawler/crawling/bootstrap.py @@ -14,9 +14,10 @@ class BootstrapService: """Import crawler seed URLs into persistence storage. - The bootstrap flow reads the configured seed CSV, normalizes each URL, and - upserts the result into the blog repository so later crawl runs have an - initial queue to process. + The bootstrap flow first replays any durable seeds already stored in + persistence. When no durable seeds exist yet, it reads the configured seed + CSV, normalizes each URL, stores it in the seed table, and upserts the blog + queue row. """ def __init__(self, repository: RepositoryProtocol, logger: CrawlerLogger) -> None: @@ -35,20 +36,68 @@ def __init__(self, repository: RepositoryProtocol, logger: CrawlerLogger) -> Non self.logger = logger def bootstrap_seeds(self, seed_path: Path) -> dict[str, Any]: - """Import seed URLs from a CSV file into the blogs table. + """Import seed URLs into the blogs table. Args: - seed_path: Filesystem path to the CSV file containing a ``url`` - column of initial crawl targets. + seed_path: Filesystem path to the fallback CSV file containing a + ``url`` column of initial crawl targets. The CSV is only read + when the durable seed table is empty. Returns: A small result payload containing the imported seed file path and the number of newly created blog rows. """ + existing_seeds = self.repository.list_seeds() + if existing_seeds: + created = self._bootstrap_from_seed_rows(existing_seeds) + self.logger.bootstrap_success(seed_path) + return {"seed_path": str(seed_path), "imported": created} + created = self._bootstrap_from_csv(seed_path) + self.logger.bootstrap_success(seed_path) + return {"seed_path": str(seed_path), "imported": created} + + def _bootstrap_from_seed_rows(self, seeds: list[dict[str, Any]]) -> int: + """Replay persisted seed rows into the blog queue. + + Args: + seeds: Durable seed payloads loaded from persistence. + + Returns: + Number of newly inserted blog rows. + """ + + created = 0 + for seed in seeds: + raw_url = str(seed.get("url") or "").strip() + normalized_url = str(seed.get("normalized_url") or "").strip() + domain = str(seed.get("domain") or "").strip() + if not raw_url or not normalized_url or not domain: + continue + _, inserted = self.repository.upsert_blog( + url=raw_url, + normalized_url=normalized_url, + domain=domain, + accepted_by="seed", + seed_source_path=seed.get("source_path"), + seed_source_row=seed.get("source_row"), + ) + created += int(inserted) + return created + + def _bootstrap_from_csv(self, seed_path: Path) -> int: + """Load fallback CSV seed rows into seeds and blogs. + + Args: + seed_path: Filesystem path to the seed CSV file. + + Returns: + Number of newly inserted blog rows. + """ + created = 0 with seed_path.open("r", encoding="utf-8") as handle: reader = csv.DictReader(handle) - for row in reader: + for row_number, row in enumerate(reader, start=2): raw_url = (row.get("url") or "").strip() if not raw_url: continue @@ -58,7 +107,8 @@ def bootstrap_seeds(self, seed_path: Path) -> dict[str, Any]: normalized_url=normalized.normalized_url, domain=normalized.domain, accepted_by="seed", + seed_source_path=str(seed_path), + seed_source_row=row_number, ) created += int(inserted) - self.logger.bootstrap_success(seed_path) - return {"seed_path": str(seed_path), "imported": created} + return created diff --git a/crawler/crawling/decisions/rule_helpers.py b/crawler/crawling/decisions/rule_helpers.py index b7a4019..efc3e18 100644 --- a/crawler/crawling/decisions/rule_helpers.py +++ b/crawler/crawling/decisions/rule_helpers.py @@ -43,7 +43,7 @@ "/rss", "/search", } -BLOCKED_TLDS = (".gov", ".gov.cn", ".org", ".edu") +BLOCKED_TLDS = (".gov", ".gov.cn", ".edu") FILE_SUFFIX_BLOCKLIST = ( ".7z", ".css", diff --git a/doc/api-docs.md b/doc/api-docs.md index 15fca56..f5ab40d 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -74,6 +74,7 @@ Public API 由 `backend` 服务统一暴露,供 public 浏览、图谱与 inge - `POST /api/auth/logout` - `GET /api/me/label-selections` - `GET /api/blogs/catalog` +- `POST /api/blogs/user-seeds` - `POST /api/blogs/{blog_id}/user-labels` - `GET /api/blogs/lookup` - `GET /api/blogs/{blog_id}` @@ -426,6 +427,52 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 首页搜索框使用 `page=1&page_size=30&url=<输入 URL>&sort=id_desc` 查询已发现博客,并把返回项渲染为可滚动结果列表。 +#### `POST /api/blogs/user-seeds` + +用途:当首页 URL 搜索没有命中时,允许用户提交一个完整博客链接作为用户来源 seed。该接口只执行确定性规则过滤,跳过 RSS discovery 与模型共识;规则通过后会把 URL 同时写入 `blogs` 与 `seeds`。 + +请求体: + +```json +{ + "homepage_url": "https://blog.example.com" +} +``` + +成功语义: + +- URL 先按当前 identity/canonicalization 规则归一化 +- 只运行过滤链中的 rule filters;不会因为缺少 RSS、模型未加载或模型判非博客而拒绝 +- 规则通过后,`blogs.acceptance_status = ACCEPTED` +- `blogs.accepted_by = user` +- 新建或历史 `FAILED` 博客会处于 `crawl_status = WAITING`,因此可被 crawler 领取并抓取友链 +- 已经 `FINISHED` 的博客不会被强制重置为 `WAITING` +- 同一 URL 会 upsert 到 `seeds` 表,当前用 `source_path = user` 标记用户来源 + +成功响应示例: + +```json +{ + "status": "QUEUED", + "blog_id": 123, + "inserted": true, + "blog": { + "id": 123, + "blog_id": 123, + "url": "https://blog.example.com/", + "normalized_url": "https://blog.example.com/", + "domain": "blog.example.com", + "acceptance_status": "ACCEPTED", + "accepted_by": "user", + "crawl_status": "WAITING" + } +} +``` + +错误语义: + +- URL 格式无法归一化或规则过滤拒绝时返回 `422` + #### `GET /api/icons/proxy` 用途:把已知 icon URL 作为同源图片返回,供 3D 图谱 WebGL texture 加载使用。 @@ -1086,15 +1133,23 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 #### `POST /api/admin/crawl/bootstrap` -用途:从 `seed.csv` 导入种子博客。 +用途:导入种子博客。若 `seeds` 表已有记录,则直接以 `seeds` 表为来源回灌 `blogs`;仅当 `seeds` 表为空时才从 `seed.csv` 初始化。 调用链: - `backend` -> `crawler /internal/crawl/bootstrap` +持久化行为: + +- 每个 seed URL 会 upsert 到 `blogs`,并标记 `accepted_by=seed` +- 当 `seeds` 表为空时,会从 `seed.csv` 读取非空 URL,并同步 upsert 到 `seeds` 表,记录原始 URL、规范化 URL、domain、关联 `blog_id`、来源 CSV 路径与 CSV 数据行号 +- 当 `seeds` 表不为空时,导入动作直接 replay `seeds` 表记录到 `blogs`,不会读取 `seed.csv` +- `seeds.normalized_url` 唯一;重复导入同一个 seed 会刷新记录,不会创建重复 seed 行 +- 管理员数据库 reset 会保留 `seeds` 表数据,只清空其旧 `blog_id` 关联;下一次导入会重新把 seed 行关联到新建或复用的 blog + 响应字段: -- `seed_path`: 实际导入的种子文件路径 +- `seed_path`: 配置的种子 CSV 文件路径;当 `seeds` 表不为空时,该字段仅表示 fallback CSV 路径 - `imported`: 新导入的 blog 数量 #### `POST /api/admin/crawl/run` @@ -1371,7 +1426,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 ### `POST /internal/crawl/bootstrap` -用途:导入种子数据。 +用途:导入种子数据。该流程优先 replay `seeds` 表到 `blogs`;只有 `seeds` 表为空时才读取 seed CSV 并同步维护 `blogs` 与 `seeds`。 实际执行:`CrawlPipeline.bootstrap_seeds()` @@ -2080,8 +2135,9 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 管理员前端/调用方 -> `POST /api/admin/crawl/bootstrap` - `backend` -> `crawler /internal/crawl/bootstrap` -- `crawler` 读取 `seed.csv` -- `crawler` -> `persistence-api /internal/blogs/upsert` +- `crawler` -> `persistence-api /internal/seeds` 检查是否已有持久化 seed +- 若已有 seed:`crawler` replay `seeds` 表到 `blogs` +- 若没有 seed:`crawler` 读取 `seed.csv`,再通过 `persistence-api /internal/blogs/upsert` 同时写入/刷新 `blogs` 与 `seeds` - `crawler` -> 结构化日志管线 #### 单次 crawl 运行 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 573e29c..70c8949 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -151,7 +151,7 @@ beforeEach(() => { total_edges: 10, }; - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(String(input), "http://localhost"); if (url.pathname === "/api/blogs/catalog") { const page = Number(url.searchParams.get("page") || "1"); @@ -190,6 +190,27 @@ beforeEach(() => { }), ); } + if (url.pathname === "/api/blogs/user-seeds") { + const body = JSON.parse(String(init?.body ?? "{}")); + const submittedUrl = String(body.homepage_url || "https://missing-blog.example.com/"); + if (submittedUrl === "https://blog.sayori.org/") { + return new Response(JSON.stringify({ detail: "rule:blocked_tld" }), { status: 422 }); + } + const item = { + ...makeCatalogItem(444, "WAITING", "Missing Blog"), + url: submittedUrl, + normalized_url: submittedUrl, + domain: submittedUrl.replace(/^https?:\/\//, "").replace(/\/$/, ""), + }; + return new Response( + JSON.stringify({ + status: "QUEUED", + blog_id: item.id, + inserted: true, + blog: item, + }), + ); + } const blogDetailMatch = url.pathname.match(/^\/api\/blogs\/(\d+)$/); if (blogDetailMatch) { const detailItem = catalogItems.find((item) => Number(item.id) === Number(blogDetailMatch[1])); @@ -451,6 +472,69 @@ test("lets home users search normalized URLs and open the blog detail route", as expect(screen.getByText("通过 Mutual Blog 关联")).toBeInTheDocument(); }); +test("submits a user seed when home URL search has no matches", async () => { + render(); + + const input = await screen.findByPlaceholderText("输入你的博客链接,看看你的博客有没有被找到吧!"); + fireEvent.change(input, { target: { value: "missing-blog.example.com" } }); + fireEvent.click(screen.getByRole("button", { name: "搜索博客" })); + + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "当前未找到该博客,是否将该博客加入博客网络?" })).toBeInTheDocument(); + }); + expect(screen.getByText("missing-blog.example.com")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "不是" })); + + await waitFor(() => { + expect( + screen.queryByRole("dialog", { name: "当前未找到该博客,是否将该博客加入博客网络?" }), + ).not.toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "搜索博客" })); + + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "当前未找到该博客,是否将该博客加入博客网络?" })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("button", { name: "是" })); + const seedInput = screen.getByLabelText("请输入完整博客链接"); + expect(seedInput).toHaveAttribute("placeholder", "https://blog.example.com"); + fireEvent.change(seedInput, { target: { value: "https://missing-blog.example.com/" } }); + fireEvent.click(screen.getByRole("button", { name: "是" })); + + await waitFor(() => { + const submitCall = vi.mocked(fetch).mock.calls.find(([input]) => String(input).includes("/api/blogs/user-seeds")); + expect(submitCall).toBeDefined(); + expect(JSON.parse(String(submitCall![1]?.body))).toEqual({ + homepage_url: "https://missing-blog.example.com/", + }); + expect( + screen.queryByRole("dialog", { name: "当前未找到该博客,是否将该博客加入博客网络?" }), + ).not.toBeInTheDocument(); + }); +}); + +test("shows the exact rule-filter reason when user seed submission fails", async () => { + render(); + + const input = await screen.findByPlaceholderText("输入你的博客链接,看看你的博客有没有被找到吧!"); + fireEvent.change(input, { target: { value: "blog.sayori.org" } }); + fireEvent.click(screen.getByRole("button", { name: "搜索博客" })); + + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "当前未找到该博客,是否将该博客加入博客网络?" })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("button", { name: "是" })); + fireEvent.change(screen.getByLabelText("请输入完整博客链接"), { + target: { value: "https://blog.sayori.org/" }, + }); + fireEvent.click(screen.getByRole("button", { name: "是" })); + + expect(await screen.findByText("规则过滤未通过:域名后缀被屏蔽(rule:blocked_tld)")).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: "当前未找到该博客,是否将该博客加入博客网络?" })).toBeInTheDocument(); +}); + test("adds a random blog route that loads nine finished cards and refreshes them on demand", async () => { window.history.replaceState({}, "", "/random"); diff --git a/frontend/src/components/MissingBlogConfirmDialog.tsx b/frontend/src/components/MissingBlogConfirmDialog.tsx new file mode 100644 index 0000000..c7c4950 --- /dev/null +++ b/frontend/src/components/MissingBlogConfirmDialog.tsx @@ -0,0 +1,111 @@ +import { Loader2 } from "lucide-react"; +import { useState } from "react"; + +interface MissingBlogConfirmDialogProps { + url: string; + onCancel: () => void; + onSubmit: (url: string) => Promise; +} + +/** + * Render a confirmation dialog when a searched blog URL is not recorded. + * + * @param url Searched blog URL that was not found. + * @param onCancel Callback for dismissing the dialog without action. + * @param onSubmit Callback used to submit the confirmed complete blog URL. + * @returns Modal confirmation UI. + */ +export function MissingBlogConfirmDialog({ url, onCancel, onSubmit }: MissingBlogConfirmDialogProps) { + const [isConfirming, setIsConfirming] = useState(false); + const [seedUrl, setSeedUrl] = useState(url); + const [isSubmitting, setIsSubmitting] = useState(false); + + /** + * Submit the user-provided complete URL to the seed ingestion flow. + * + * @param event Form submit event. + */ + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!seedUrl.trim()) { + return; + } + setIsSubmitting(true); + try { + await onSubmit(seedUrl); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+

+ 当前未找到该博客,是否将该博客加入博客网络? +

+ {isConfirming ? ( +
+
+ + setSeedUrl(event.target.value)} + placeholder="https://blog.example.com" + disabled={isSubmitting} + className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-950 outline-none transition-colors placeholder:text-slate-400 focus:border-sky-500 focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:bg-slate-50" + /> +
+
+ + +
+
+ ) : ( + <> +
{url}
+
+ + +
+ + )} +
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 928aef7..8e85b53 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -25,6 +25,24 @@ import type { UserProfile, } from "../types/graph"; +export class ApiError extends Error { + status: number; + detail: unknown; + + /** + * Capture one failed API response with the backend detail payload intact. + * + * @param status HTTP response status. + * @param detail Backend error detail payload, when available. + */ + constructor(status: number, detail: unknown) { + super(typeof detail === "string" && detail ? detail : `api_error_${status}`); + this.name = "ApiError"; + this.status = status; + this.detail = detail; + } +} + interface BackendGraphNode { id: number; blog_id?: number; @@ -499,7 +517,14 @@ async function apiJson(path: string, init?: RequestInit): Promise { }, }); if (!response.ok) { - throw new Error(`api_error_${response.status}`); + let detail: unknown = null; + try { + const payload = await response.json(); + detail = (payload as { detail?: unknown }).detail ?? payload; + } catch { + detail = await response.text().catch(() => null); + } + throw new ApiError(response.status, detail); } return (await response.json()) as T; } @@ -778,6 +803,66 @@ export async function submitBlogInfo(data: { }); } +/** + * Submit one user seed URL so it can be accepted and queued for crawling. + * + * @param data User-provided complete blog URL. + * @returns Accepted blog seed summary. + */ +export async function submitUserSeed(data: { url: string }): Promise<{ status: string; blogId: number }> { + if (!data.url.trim()) { + throw new Error("url_required"); + } + let payload: { status: string; blog_id: number }; + try { + payload = await apiJson<{ status: string; blog_id: number }>("/api/blogs/user-seeds", { + method: "POST", + body: JSON.stringify({ + homepage_url: data.url.trim(), + }), + }); + } catch (error) { + throw new Error(describeUserSeedError(error)); + } + return { + status: payload.status, + blogId: payload.blog_id, + }; +} + +function describeUserSeedError(error: unknown): string { + if (!(error instanceof ApiError)) { + return error instanceof Error ? error.message : "提交失败:未知错误"; + } + const detail = typeof error.detail === "string" ? error.detail : ""; + const ruleReason = USER_SEED_RULE_REASON_MESSAGES[detail]; + if (ruleReason) { + return `规则过滤未通过:${ruleReason}(${detail})`; + } + if (detail === "Unsupported homepage URL") { + return "URL 无法识别:请输入完整的 http 或 https 博客首页链接。"; + } + if (detail) { + return `提交失败:${detail}`; + } + return `提交失败:接口返回 ${error.status}`; +} + +const USER_SEED_RULE_REASON_MESSAGES: Record = { + "rule:duplicate_url": "该 URL 已经存在于发现记录中", + "rule:non_http_scheme": "链接不是 http 或 https 协议", + "rule:same_domain": "链接与来源域名相同", + "rule:exact_url_blocked": "链接命中精确 URL 黑名单", + "rule:prefix_blocked": "链接命中 URL 前缀黑名单", + "rule:platform_blocked": "域名属于已屏蔽的平台站点", + "rule:domain_blocked": "域名命中自定义屏蔽列表", + "rule:blocked_tld": "域名后缀被屏蔽", + "rule:non_root_path": "链接不是博客首页根路径", + "rule:non_root_location": "链接包含查询参数或锚点", + "rule:asset_suffix": "链接指向静态资源文件", + "rule:blocked_path": "链接路径属于登录、搜索、RSS、管理页等非博客首页", +}; + export async function registerUser(data: { email: string; password: string }): Promise { const payload = await apiJson("/api/auth/register", { method: "POST", diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 592695f..49cbac4 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -2,8 +2,9 @@ import { GitBranch, Loader2, Network, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; +import { MissingBlogConfirmDialog } from "../components/MissingBlogConfirmDialog"; import { Navigation } from "../components/Navigation"; -import { fetchBlogsCatalog, fetchStats } from "../lib/api"; +import { fetchBlogsCatalog, fetchStats, submitUserSeed } from "../lib/api"; import { resolveBlogIconUrls } from "../lib/icon"; import type { BlogCatalogItem, StatsData } from "../types/graph"; @@ -59,6 +60,7 @@ export function HomePage() { const [lastSearchQuery, setLastSearchQuery] = useState(""); const [hasSearched, setHasSearched] = useState(false); const [isSearching, setIsSearching] = useState(false); + const [missingBlogUrl, setMissingBlogUrl] = useState(null); const refreshInFlightRef = useRef(false); const hasLoadedOnceRef = useRef(false); @@ -163,6 +165,7 @@ export function HomePage() { setLastSearchQuery(""); setSearchResults([]); setSearchTotalItems(0); + setMissingBlogUrl(null); return; } @@ -178,6 +181,7 @@ export function HomePage() { setSearchTotalItems(page.totalItems); setLastSearchQuery(query); setHasSearched(true); + setMissingBlogUrl(page.items.length === 0 ? query : null); } catch { toast.error("博客搜索失败,请稍后重试。"); } finally { @@ -194,6 +198,23 @@ export function HomePage() { navigate(`/blogs/${blog.id}`); } + /** + * Submit a user-confirmed missing blog URL as an accepted crawler seed. + * + * @param url Complete blog URL typed by the user. + * @returns Promise resolved after the submission is persisted. + */ + async function handleSubmitMissingBlog(url: string) { + try { + await submitUserSeed({ url }); + toast.success("已加入博客网络,等待爬虫抓取友链。"); + setMissingBlogUrl(null); + } catch (error) { + const message = error instanceof Error ? error.message : "提交失败:未知错误"; + toast.error(message); + } + } + if (isInitialLoading) { return (
@@ -208,6 +229,13 @@ export function HomePage() { return (
+ {missingBlogUrl ? ( + setMissingBlogUrl(null)} + onSubmit={handleSubmitMissingBlog} + /> + ) : null}
diff --git a/persistence_api/main.py b/persistence_api/main.py index c2001a5..4a01594 100644 --- a/persistence_api/main.py +++ b/persistence_api/main.py @@ -50,6 +50,8 @@ class UpsertBlogRequest(BaseModel): email: str | None = None feed_url: str | None = None accepted_by: str | None = None + seed_source_path: str | None = None + seed_source_row: int | None = None class CreateIngestionRequest(BaseModel): @@ -57,6 +59,10 @@ class CreateIngestionRequest(BaseModel): email: str +class CreateUserSeedRequest(BaseModel): + homepage_url: str + + class UserAuthRequest(BaseModel): email: str password: str @@ -514,6 +520,13 @@ def create_ingestion_request(payload: CreateIngestionRequest) -> dict[str, Any]: status_code=422, ) + @app.post("/internal/user-seeds") + def create_user_seed(payload: CreateUserSeedRequest) -> dict[str, Any]: + return _call_with_value_error_http_translation( + lambda: get_state().repository.create_user_seed(**payload.model_dump()), + status_code=422, + ) + @app.get("/internal/ingestion-requests/{request_id}") def get_ingestion_request(request_id: int, request_token: str) -> dict[str, Any]: return _require_payload( @@ -567,6 +580,11 @@ def upsert_blog(payload: UpsertBlogRequest) -> dict[str, Any]: blog_id, inserted = get_state().repository.upsert_blog(**payload.model_dump()) return {"id": blog_id, "inserted": inserted} + @app.get("/internal/seeds") + def list_seeds() -> list[dict[str, Any]]: + """Return durable seed rows for crawler bootstrap replay.""" + return get_state().repository.list_seeds() + @app.get("/internal/blogs/by-normalized-url") def find_blog_by_normalized_url(normalized_url: str) -> dict[str, int | None]: """Return the existing blog id for one normalized URL.""" diff --git a/persistence_api/models.py b/persistence_api/models.py index 691bcde..f7ddae3 100644 --- a/persistence_api/models.py +++ b/persistence_api/models.py @@ -69,6 +69,31 @@ class BlogModel(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) +class SeedModel(Base): + """Seed URL imported from a configured seed CSV file. + + Args: + None. SQLAlchemy constructs model instances from mapped keyword + arguments. + + Returns: + One durable seed record keyed by normalized URL and linked to the blog + row created or reused during CSV bootstrap. + """ + + __tablename__ = "seeds" + + id: Mapped[int] = mapped_column(primary_key=True) + url: Mapped[str] = mapped_column(Text, nullable=False) + normalized_url: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True) + domain: Mapped[str] = mapped_column(Text, nullable=False) + source_path: Mapped[str | None] = mapped_column(Text, nullable=True) + source_row: Mapped[int | None] = mapped_column(Integer, nullable=True) + blog_id: Mapped[int | None] = mapped_column(ForeignKey("blogs.blog_id", ondelete="SET NULL"), nullable=True) + imported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + class IngestionRequestModel(Base): """User-triggered priority ingestion request.""" diff --git a/persistence_api/repository.py b/persistence_api/repository.py index 5b8b48d..80131aa 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -47,10 +47,12 @@ from persistence_api.models import EdgeModel from persistence_api.models import IngestionRequestModel from persistence_api.models import RawDiscoveredUrlModel +from persistence_api.models import SeedModel from persistence_api.models import UserModel from persistence_api.models import UserSessionModel from persistence_api.recommendations import collect_friends_of_friends_candidates from crawler.crawling.decisions.chain import build_url_decision_chain +from crawler.crawling.decisions.base import UrlCandidateContext from crawler.crawling.normalization import IDENTITY_RULESET_VERSION from crawler.crawling.normalization import BlogIdentityResolution from crawler.crawling.normalization import normalize_url @@ -642,6 +644,38 @@ def ensure_legacy_compat_schema(engine: Any) -> None: "ON ingestion_requests (identity_key)" ) ) + if "seeds" not in existing_tables: + if connection.dialect.name == "postgresql": + connection.execute( + text( + "CREATE TABLE seeds (" + "id SERIAL PRIMARY KEY, " + "url TEXT NOT NULL, " + "normalized_url TEXT NOT NULL UNIQUE, " + "domain TEXT NOT NULL, " + "source_path TEXT, " + "source_row INTEGER, " + "blog_id INTEGER REFERENCES blogs(blog_id) ON DELETE SET NULL, " + "imported_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, " + "updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL)" + ) + ) + else: + connection.execute( + text( + "CREATE TABLE seeds (" + "id INTEGER PRIMARY KEY, " + "url TEXT NOT NULL, " + "normalized_url TEXT NOT NULL UNIQUE, " + "domain TEXT NOT NULL, " + "source_path TEXT, " + "source_row INTEGER, " + "blog_id INTEGER REFERENCES blogs(blog_id) ON DELETE SET NULL, " + "imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL)" + ) + ) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_seeds_normalized_url ON seeds (normalized_url)")) if "blog_labels" not in existing_tables: if connection.dialect.name == "postgresql": connection.execute( @@ -1239,6 +1273,29 @@ def _edge_payload(model: EdgeModel) -> dict[str, Any]: } +def _seed_payload(model: SeedModel) -> dict[str, Any]: + """Serialize one durable seed row for crawler bootstrap. + + Args: + model: Seed ORM row to serialize. + + Returns: + Plain JSON-compatible seed payload. + """ + + return { + "id": int(model.id), + "url": str(model.url), + "normalized_url": str(model.normalized_url), + "domain": str(model.domain), + "source_path": model.source_path, + "source_row": model.source_row, + "blog_id": model.blog_id, + "imported_at": _iso(model.imported_at), + "updated_at": _iso(model.updated_at), + } + + def _ingestion_request_payload( model: IngestionRequestModel, *, @@ -1682,14 +1739,20 @@ def upsert_blog( email: str | None = None, feed_url: str | None = None, accepted_by: str | None = None, + seed_source_path: str | None = None, + seed_source_row: int | None = None, ) -> tuple[int, bool]: ... + def list_seeds(self) -> list[dict[str, Any]]: ... + def get_next_waiting_blog(self, *, include_priority: bool = True) -> dict[str, Any] | None: ... def get_next_priority_blog(self) -> dict[str, Any] | None: ... def create_ingestion_request(self, *, homepage_url: str, email: str) -> dict[str, Any]: ... + def create_user_seed(self, *, homepage_url: str) -> dict[str, Any]: ... + def register_user(self, *, email: str, password: str) -> dict[str, Any]: ... def login_user(self, *, email: str, password: str) -> dict[str, Any]: ... @@ -2532,6 +2595,8 @@ def upsert_blog( email: str | None = None, feed_url: str | None = None, accepted_by: str | None = None, + seed_source_path: str | None = None, + seed_source_row: int | None = None, ) -> tuple[int, bool]: with session_scope(self.session_factory) as session: blog, inserted = self._upsert_blog_in_session( @@ -2543,8 +2608,88 @@ def upsert_blog( feed_url=feed_url, accepted_by=accepted_by, ) + if accepted_by == "seed": + self._upsert_seed_in_session( + session, + url=url, + normalized_url=str(blog.normalized_url), + domain=str(blog.domain), + blog_id=int(_business_blog_id(blog)), + source_path=seed_source_path, + source_row=seed_source_row, + ) return int(_business_blog_id(blog)), inserted + def _upsert_seed_in_session( + self, + session: Session, + *, + url: str, + normalized_url: str, + domain: str, + blog_id: int, + source_path: str | None = None, + source_row: int | None = None, + ) -> SeedModel: + """Create or refresh the durable seed row for one imported URL. + + Args: + session: Active SQLAlchemy session that already contains the blog + upsert for the same import. + url: Original URL from the seed CSV row. + normalized_url: Stored blog normalized URL after identity + canonicalization. + domain: Stored blog domain. + blog_id: Business blog identifier linked from the seed row. + source_path: Optional CSV path used for traceability. + source_row: Optional one-based CSV data row number. + + Returns: + The created or updated seed row. + """ + + imported_at = now_utc() + seed = session.scalar(select(SeedModel).where(SeedModel.normalized_url == normalized_url)) + if seed is None: + seed = SeedModel( + url=url, + normalized_url=normalized_url, + domain=domain, + source_path=source_path, + source_row=source_row, + blog_id=blog_id, + imported_at=imported_at, + updated_at=imported_at, + ) + session.add(seed) + session.flush() + return seed + + seed.url = url + seed.domain = domain + seed.blog_id = blog_id + seed.source_path = source_path + seed.source_row = source_row + seed.updated_at = imported_at + return seed + + def list_seeds(self) -> list[dict[str, Any]]: + """Return all durable seed rows in deterministic insertion order. + + Args: + None. + + Returns: + Seed payloads ordered by primary key for bootstrap replay. + """ + + with session_scope(self.session_factory) as session: + return self._ordered_row_payloads( + session, + statement=select(SeedModel).order_by(SeedModel.id.asc()), + serializer=_seed_payload, + ) + def create_ingestion_request(self, *, homepage_url: str, email: str) -> dict[str, Any]: requested_url, normalized_url, domain, identity_key, reason_codes, ruleset_version = normalize_homepage_url( homepage_url @@ -2653,6 +2798,67 @@ def create_ingestion_request(self, *, homepage_url: str, email: str) -> dict[str serializer=_ingestion_request_payload, ) + def create_user_seed(self, *, homepage_url: str) -> dict[str, Any]: + """Accept one user-submitted URL as a crawler seed after rule checks. + + Args: + homepage_url: Complete blog homepage URL provided by a public user. + + Returns: + Payload describing the accepted blog and whether a row was inserted. + + Raises: + ValueError: Raised when URL normalization fails or deterministic + rule filters reject the URL. + """ + + requested_url, normalized_url, domain, _identity_key, _reason_codes, _ruleset_version = normalize_homepage_url( + homepage_url + ) + settings = self._decision_scan_settings() + decision_chain = build_url_decision_chain(settings) + candidate = UrlCandidateContext( + source_blog_id=0, + source_domain="", + normalized_url=normalized_url, + link_text="user", + context_text="user-submitted seed", + ) + for rule_filter in decision_chain.rule_filters: + decision = rule_filter.apply(candidate) + if not decision.accepted: + raise ValueError(str(decision.status or "rule_filter_rejected")) + + with session_scope(self.session_factory) as session: + blog, inserted = self._upsert_blog_in_session( + session, + url=requested_url, + normalized_url=normalized_url, + domain=domain, + accepted_by="user", + ) + if blog.crawl_status == CrawlStatus.FAILED: + blog.crawl_status = CrawlStatus.WAITING + blog.crawl_error_kind = None + blog.crawl_error_message = None + blog.updated_at = now_utc() + self._upsert_seed_in_session( + session, + url=requested_url, + normalized_url=str(blog.normalized_url), + domain=str(blog.domain), + blog_id=int(_business_blog_id(blog)), + source_path="user", + source_row=None, + ) + blog_view = _BlogPayloadView.from_model(blog) + return { + "status": "QUEUED" if blog.crawl_status == CrawlStatus.WAITING else "EXISTING", + "blog_id": int(_business_blog_id(blog)), + "inserted": inserted, + "blog": blog_view.as_blog_payload() if blog_view is not None else None, + } + def _create_user_session_payload(self, session: Session, user: UserModel) -> dict[str, Any]: """Create one session row and return the auth response payload. @@ -4562,24 +4768,17 @@ def reset(self) -> dict[str, Any]: user_labels_preserved = _count_selectable_rows(session, BlogUserLabelModel) user_label_selections_preserved = _count_selectable_rows(session, BlogUserLabelSelectionModel) label_tags_preserved = _count_selectable_rows(session, BlogLabelTagModel) + seeds_preserved = _count_selectable_rows(session, SeedModel) raw_urls_deleted = _count_selectable_rows(session, RawDiscoveredUrlModel) scan_items_deleted = _count_selectable_rows(session, BlogDedupScanRunItemModel) scan_runs_deleted = _count_selectable_rows(session, BlogDedupScanRunModel) - if self.dialect_name == "postgresql": - session.execute( - text( - "TRUNCATE TABLE blog_dedup_scan_run_items, blog_dedup_scan_runs, " - "raw_discovered_urls, ingestion_requests, edges, blogs " - "RESTART IDENTITY CASCADE" - ) - ) - else: - session.query(BlogDedupScanRunItemModel).delete() - session.query(BlogDedupScanRunModel).delete() - session.query(RawDiscoveredUrlModel).delete() - session.query(IngestionRequestModel).delete() - session.query(EdgeModel).delete() - session.query(BlogModel).delete() + session.query(SeedModel).update({SeedModel.blog_id: None}) + session.query(BlogDedupScanRunItemModel).delete() + session.query(BlogDedupScanRunModel).delete() + session.query(RawDiscoveredUrlModel).delete() + session.query(IngestionRequestModel).delete() + session.query(EdgeModel).delete() + session.query(BlogModel).delete() return { "ok": True, "blogs_deleted": blogs_deleted, @@ -4596,6 +4795,7 @@ def reset(self) -> dict[str, Any]: "blog_label_subjects_preserved": 0, "blog_link_labels_preserved": labels_preserved, "blog_label_tags_preserved": label_tags_preserved, + "seeds_preserved": seeds_preserved, "raw_discovered_urls_deleted": raw_urls_deleted, "blog_dedup_scan_items_deleted": scan_items_deleted, "blog_dedup_scan_runs_deleted": scan_runs_deleted, diff --git a/shared/http_clients/persistence_http.py b/shared/http_clients/persistence_http.py index ef6629f..fd67961 100644 --- a/shared/http_clients/persistence_http.py +++ b/shared/http_clients/persistence_http.py @@ -171,6 +171,8 @@ def upsert_blog( email: str | None = None, feed_url: str | None = None, accepted_by: str | None = None, + seed_source_path: str | None = None, + seed_source_row: int | None = None, ) -> tuple[int, bool]: payload = self._post( "/internal/blogs/upsert", @@ -181,10 +183,24 @@ def upsert_blog( "email": email, "feed_url": feed_url, "accepted_by": accepted_by, + "seed_source_path": seed_source_path, + "seed_source_row": seed_source_row, }, ) return int(payload["id"]), bool(payload["inserted"]) + def list_seeds(self) -> list[dict[str, Any]]: + """Fetch durable seed rows from persistence in replay order. + + Args: + None. + + Returns: + Seed payloads ordered by insertion ID. + """ + + return self._get("/internal/seeds") + def create_ingestion_request(self, *, homepage_url: str, email: str) -> dict[str, Any]: return self._post( "/internal/ingestion-requests", @@ -194,6 +210,23 @@ def create_ingestion_request(self, *, homepage_url: str, email: str) -> dict[str }, ) + def create_user_seed(self, *, homepage_url: str) -> dict[str, Any]: + """Create or refresh a user-submitted crawler seed. + + Args: + homepage_url: Complete user-submitted blog homepage URL. + + Returns: + Accepted seed payload returned by persistence. + """ + + return self._post( + "/internal/user-seeds", + { + "homepage_url": homepage_url, + }, + ) + def register_user(self, *, email: str, password: str) -> dict[str, Any]: """Create a user account through persistence. diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 78507d0..1ea75a5 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -5,6 +5,7 @@ from typing import Callable import pytest +from sqlalchemy import func from sqlalchemy import select from crawler.crawling.fetching.base import FetchAttempt @@ -13,6 +14,7 @@ from persistence_api.db import session_scope from persistence_api.models import BlogModel from persistence_api.models import RawDiscoveredUrlModel +from persistence_api.models import SeedModel from persistence_api.repository import Repository from shared.config import Settings @@ -112,6 +114,83 @@ def seed_blog(repository: Repository) -> dict[str, Any]: return blog +def test_bootstrap_seeds_persists_seed_rows_with_blog_links(tmp_path: Path) -> None: + """Seed CSV bootstrap should maintain a durable seed table alongside blogs.""" + pipeline, repository = build_pipeline(tmp_path) + seed_path = tmp_path / "seed.csv" + seed_path.write_text( + "url\nhttps://one.example.com/\n\nhttps://two.example.com/\n", + encoding="utf-8", + ) + + result = pipeline.bootstrap_seeds(seed_path) + + assert result == {"seed_path": str(seed_path), "imported": 2} + with session_scope(repository.session_factory) as session: + seeds = session.scalars(select(SeedModel).order_by(SeedModel.id)).all() + assert [(seed.url, seed.normalized_url, seed.source_path, seed.source_row) for seed in seeds] == [ + ( + "https://one.example.com/", + "https://one.example.com/", + str(seed_path), + 2, + ), + ( + "https://two.example.com/", + "https://two.example.com/", + str(seed_path), + 3, + ), + ] + assert all(seed.blog_id is not None for seed in seeds) + + +def test_bootstrap_seeds_does_not_reread_csv_after_seed_table_exists(tmp_path: Path) -> None: + """Re-importing after seed table initialization should not read CSV again.""" + pipeline, repository = build_pipeline(tmp_path) + seed_path = tmp_path / "seed.csv" + seed_path.write_text("url\nhttps://one.example.com/\n", encoding="utf-8") + assert pipeline.bootstrap_seeds(seed_path)["imported"] == 1 + + seed_path.write_text("url\nhttps://one.example.com/?utm_source=ignored\n", encoding="utf-8") + result = pipeline.bootstrap_seeds(seed_path) + + assert result == {"seed_path": str(seed_path), "imported": 0} + with session_scope(repository.session_factory) as session: + seeds = session.scalars(select(SeedModel)).all() + assert len(seeds) == 1 + assert seeds[0].url == "https://one.example.com/" + assert seeds[0].normalized_url == "https://one.example.com/" + assert session.scalar(select(func.count()).select_from(BlogModel)) == 1 + + +def test_bootstrap_seeds_replays_seed_table_before_reading_csv(tmp_path: Path) -> None: + """When durable seeds exist, bootstrap should use them instead of the CSV file.""" + pipeline, repository = build_pipeline(tmp_path) + seed_path = tmp_path / "seed.csv" + seed_path.write_text("url\nhttps://csv-only.example.com/\n", encoding="utf-8") + blog_id, inserted = repository.upsert_blog( + url="https://table-seed.example.com/", + normalized_url="https://table-seed.example.com/", + domain="table-seed.example.com", + accepted_by="seed", + seed_source_path="seed.csv", + seed_source_row=2, + ) + assert inserted is True + repository.reset() + + result = pipeline.bootstrap_seeds(seed_path) + + assert result == {"seed_path": str(seed_path), "imported": 1} + with session_scope(repository.session_factory) as session: + blog_urls = session.scalars(select(BlogModel.normalized_url).order_by(BlogModel.id)).all() + assert blog_urls == ["https://table-seed.example.com/"] + seed = session.scalar(select(SeedModel)) + assert seed is not None + assert seed.blog_id == blog_id + + def test_pipeline_persists_only_valid_friend_links(tmp_path: Path) -> None: """Only validated friend links from extracted sections should become edges.""" pipeline, repository = build_pipeline(tmp_path) diff --git a/tests/test_repository.py b/tests/test_repository.py index 54360ba..1e70e6f 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -6,6 +6,7 @@ import pyarrow.parquet as pq import pytest from sqlalchemy import event +from sqlalchemy import func from sqlalchemy import select sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) @@ -19,6 +20,7 @@ from persistence_api.models import BlogModel from persistence_api.models import IngestionRequestModel from persistence_api.models import RawDiscoveredUrlModel +from persistence_api.models import SeedModel from shared.contracts.enums import CrawlStatus from shared.config import Settings @@ -74,13 +76,16 @@ def fake_repository( } -def test_repository_reset_clears_data_and_restarts_ids(tmp_path: Path) -> None: - """Reset should wipe graph data and restart primary keys.""" +def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) -> None: + """Reset should wipe graph data while retaining durable seed records.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") first_blog_id, inserted = repository.upsert_blog( url="https://blog.example.com/", normalized_url="https://blog.example.com/", domain="blog.example.com", + accepted_by="seed", + seed_source_path="seed.csv", + seed_source_row=2, ) assert inserted is True second_blog_id, inserted = repository.upsert_blog( @@ -108,6 +113,7 @@ def test_repository_reset_clears_data_and_restarts_ids(tmp_path: Path) -> None: assert result["blogs_deleted"] == 2 assert result["edges_deleted"] == 1 assert result["logs_deleted"] == 0 + assert result["seeds_preserved"] == 1 assert result["ingestion_requests_deleted"] == 0 assert result["blog_link_labels_deleted"] == 0 assert result["blog_label_tags_deleted"] == 0 @@ -118,6 +124,11 @@ def test_repository_reset_clears_data_and_restarts_ids(tmp_path: Path) -> None: assert repository.list_logs() == [] assert repository.stats()["total_blogs"] == 0 assert repository.stats()["total_edges"] == 0 + with session_scope(repository.session_factory) as session: + seed = session.scalar(select(SeedModel)) + assert seed is not None + assert seed.normalized_url == "https://blog.example.com/" + assert seed.blog_id is None new_blog_id, inserted = repository.upsert_blog( url="https://reset.example.com/", @@ -1106,6 +1117,52 @@ def test_repository_claims_waiting_blogs_in_id_order(tmp_path: Path) -> None: assert second_claim["id"] == second_blog_id +def test_repository_creates_user_seed_as_accepted_waiting_blog(tmp_path: Path) -> None: + """User seeds should be accepted as blogs while remaining crawlable.""" + repository = repository_module.build_repository( + db_path=tmp_path / "db.sqlite", + settings=Settings( + db_path=tmp_path / "db.sqlite", + seed_path=tmp_path / "seed.csv", + export_dir=tmp_path / "exports", + decision_model_consensus_enabled=False, + ), + ) + + result = repository.create_user_seed(homepage_url="https://user-blog.example.com/") + + assert result["status"] == "QUEUED" + blog = repository.get_blog(result["blog_id"]) + assert blog is not None + assert blog["acceptance_status"] == "ACCEPTED" + assert blog["accepted_by"] == "user" + assert blog["crawl_status"] == "WAITING" + seeds = repository.list_seeds() + assert len(seeds) == 1 + assert seeds[0]["normalized_url"] == "https://user-blog.example.com/" + assert seeds[0]["source_path"] == "user" + assert seeds[0]["blog_id"] == result["blog_id"] + claimed = repository.get_next_waiting_blog() + assert claimed is not None + assert claimed["id"] == result["blog_id"] + + +def test_repository_user_seed_runs_rule_filters_only(tmp_path: Path) -> None: + """User seed submission should reject deterministic rule failures.""" + repository = repository_module.build_repository( + db_path=tmp_path / "db.sqlite", + settings=Settings( + db_path=tmp_path / "db.sqlite", + seed_path=tmp_path / "seed.csv", + export_dir=tmp_path / "exports", + decision_model_consensus_enabled=False, + ), + ) + + with pytest.raises(ValueError, match="rule:non_root_path"): + repository.create_user_seed(homepage_url="https://user-blog.example.com/posts/1") + + def test_repository_claims_priority_blogs_by_request_priority(tmp_path: Path) -> None: """Priority queue claiming should follow ingestion priority before request age.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") diff --git a/tests/test_service_split.py b/tests/test_service_split.py index 4f83f78..12cd068 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -1453,6 +1453,21 @@ def test_backend_service_preserves_supported_public_api_shape(monkeypatch) -> No "matched_blog": None, "blog": None, }, + "create_user_seed": lambda self, homepage_url: { + "status": "QUEUED", + "blog_id": 44, + "inserted": True, + "blog": { + "id": 44, + "blog_id": 44, + "url": homepage_url, + "normalized_url": homepage_url, + "domain": "queued-user.example", + "acceptance_status": "ACCEPTED", + "accepted_by": "user", + "crawl_status": "WAITING", + }, + }, "get_ingestion_request": lambda self, request_id, request_token: { "id": request_id, "request_id": request_id, @@ -1723,6 +1738,15 @@ def fake_get(url: str, **kwargs: object) -> httpx.Response: assert ingestion_status.status_code == 200 assert ingestion_status.json()["status"] == "QUEUED" + user_seed = client.post( + "/api/blogs/user-seeds", + json={"homepage_url": "https://queued-user.example/"}, + ) + assert user_seed.status_code == 200 + assert user_seed.json()["blog_id"] == 44 + assert user_seed.json()["blog"]["accepted_by"] == "user" + assert user_seed.json()["blog"]["crawl_status"] == "WAITING" + priority_ingestion = client.get("/api/ingestion-requests") assert priority_ingestion.status_code == 200 assert priority_ingestion.json()[0]["request_id"] == 9 @@ -2042,6 +2066,11 @@ def list_priority_ingestion_requests(self) -> list[dict[str, object]]: response = httpx.Response(503, request=request, json={"detail": "upstream_unavailable"}) raise httpx.HTTPStatusError("boom", request=request, response=response) + def create_user_seed(self, *, homepage_url: str) -> dict[str, object]: + request = httpx.Request("POST", "http://persistence/internal/user-seeds") + response = httpx.Response(422, request=request, json={"detail": "rule:blocked_tld"}) + raise httpx.HTTPStatusError("boom", request=request, response=response) + def get_blog(self, blog_id: int) -> None: return None @@ -2085,6 +2114,10 @@ def reset(self) -> dict[str, object]: assert priority.status_code == 503 assert priority.json()["detail"] == "upstream_unavailable" + user_seed = client.post("/api/blogs/user-seeds", json={"homepage_url": "https://blog.sayori.org/"}) + assert user_seed.status_code == 422 + assert user_seed.json()["detail"] == "rule:blocked_tld" + def test_backend_graph_neighbors_surfaces_upstream_not_found() -> None: """Public graph neighborhood endpoint should preserve upstream 404 errors.""" From 911f8d0f8f6218a04fc17d84475fac595b89b8c7 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 11:46:20 +0100 Subject: [PATCH 16/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=8D=9A=E5=AE=A2?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E6=96=B0=E5=A2=9E=E2=80=9C=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E8=B7=AF=E5=BE=84=E2=80=9D=E2=80=9C=E5=8D=9A=E5=AE=A2?= =?UTF-8?q?=E5=85=B3=E8=81=94=E5=9B=BE=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/api-docs.md | 9 + frontend/package-lock.json | 75 ++++ frontend/package.json | 1 + frontend/src/App.test.tsx | 160 ++++++- frontend/src/lib/api.ts | 133 +++++- frontend/src/pages/BlogDetailPage.tsx | 503 +++++++++++++++++++---- frontend/src/pages/VisualizationPage.tsx | 18 + frontend/src/types/graph.ts | 36 ++ persistence_api/repository.py | 227 ++++++++++ tests/test_repository.py | 222 ++++++++++ 10 files changed, 1283 insertions(+), 101 deletions(-) diff --git a/doc/api-docs.md b/doc/api-docs.md index f5ab40d..323b261 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -596,12 +596,16 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 若 blog 不存在,返回 `404` - 返回内容基于单 blog 记录扩展了 `incoming_edges` 与 `outgoing_edges` +- 返回 `discovery_path` 描述该博客进入网络的路径:手动 seed/user 添加,或由 crawler 沿友链逐级发现 +- 返回 `relation_graphs` 描述详情页“博客关联”模块使用的两层入链/出链关系图;两层深度内的入链/出链关系完整返回,不按节点或边数量裁剪 额外字段: - `incoming_edges`: 所有 `to_blog_id == blog_id` 的边,每条边额外携带 `neighbor_blog` - `outgoing_edges`: 所有 `from_blog_id == blog_id` 的边,每条边额外携带 `neighbor_blog` - `recommended_blogs`: “朋友的朋友”推荐列表,规则是“当前博客的友链认识、但当前博客还没直接认识的博客” +- `discovery_path`: 发现路径。`mode=manual` 表示该博客由 `accepted_by=seed/user` 手动进入网络;`mode=crawled` 表示通过 `raw_discovered_urls` 从当前博客逐级追溯 source blog,直到 seed/user 源头、无法继续追溯或检测到循环;正常长路径会完整返回,不按固定深度截断 +- `relation_graphs`: `{ incoming, outgoing }`,两个图默认各包含从当前博客出发的 2 层关系;`incoming` 沿入链向上追溯,`outgoing` 沿出链向下展开;两层深度内不按节点或边数量裁剪 其中 `neighbor_blog` 是详情页使用的邻居摘要,字段为: @@ -2025,12 +2029,17 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 | `incoming_edges` | `BlogRelationRecord[]` | 指向当前博客的关系列表 | | `outgoing_edges` | `BlogRelationRecord[]` | 当前博客指向外部的关系列表 | | `recommended_blogs` | `BlogRecommendationRecord[]` | “朋友的朋友”推荐列表 | +| `discovery_path` | `BlogDiscoveryPath` | 发现路径摘要,从源头博客到当前博客的有序步骤 | +| `relation_graphs` | `{ incoming, outgoing }` | 两层入链/出链关系图,供详情页“博客关联”模块展示;两层深度内不按节点或边数量裁剪 | 其中: - `BlogRelationRecord = EdgeRecord + { neighbor_blog: BlogNeighborSummary \| null }` - `BlogRecommendationRecord = { blog, reason, mutual_connection_count, via_blogs }` - `BlogNeighborSummary` 字段为 `id`、`domain`、`title`、`icon_url` +- `BlogDiscoveryPath = { mode, origin_source, origin_label, target_source, truncated, steps }`,其中 `truncated` 为历史兼容字段,当前始终为 `false` +- `BlogDiscoveryStep` 包含 `blog` 邻居摘要、`blog_id`、`url`、`domain`、`accepted_by`、`accepted_label`、`raw_id`、`raw_source_blog_id`、`raw_accepted_by`、`discovered_at` +- `BlogRelationGraph = { direction, focus_blog_id, depth, nodes, edges }`,其中 `direction` 为 `incoming` 或 `outgoing`,`depth` 默认是 `2` ### 5.4 EdgeRecord diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 303682f..6458c7d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "lucide-react": "^0.487.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-force-graph-2d": "^1.29.1", "react-force-graph-3d": "^1.29.0", "react-router-dom": "^7.7.1", "sonner": "^2.0.3", @@ -2517,6 +2518,16 @@ "node": ">= 0.6.0" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/bubblesets-js": { "version": "2.3.4", "resolved": "https://registry.npmmirror.com/bubblesets-js/-/bubblesets-js-2.3.4.tgz", @@ -2533,6 +2544,18 @@ "node": ">=8" } }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", @@ -3357,6 +3380,32 @@ "node": ">=12" } }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmmirror.com/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -3470,6 +3519,15 @@ "node": ">=8" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", @@ -4205,6 +4263,23 @@ "react": "^19.2.4" } }, + "node_modules/react-force-graph-2d": { + "version": "1.29.1", + "resolved": "https://registry.npmmirror.com/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz", + "integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==", + "license": "MIT", + "dependencies": { + "force-graph": "^1.51", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-force-graph-3d": { "version": "1.29.0", "resolved": "https://registry.npmmirror.com/react-force-graph-3d/-/react-force-graph-3d-1.29.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c9eee0d..4286310 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "lucide-react": "^0.487.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-force-graph-2d": "^1.29.1", "react-force-graph-3d": "^1.29.0", "react-router-dom": "^7.7.1", "sonner": "^2.0.3", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 70c8949..2399107 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,10 +1,14 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; const { forceGraphProps } = vi.hoisted(() => ({ forceGraphProps: [] as Record[], })); +const { forceGraph2DProps } = vi.hoisted(() => ({ + forceGraph2DProps: [] as Record[], +})); + vi.mock("react-force-graph-3d", () => ({ default: (props: Record) => { forceGraphProps.push(props); @@ -12,6 +16,37 @@ vi.mock("react-force-graph-3d", () => ({ }, })); +vi.mock("react-force-graph-2d", async () => { + const React = await vi.importActual("react"); + return { + default: React.forwardRef((props: Record, ref) => { + React.useImperativeHandle(ref, () => ({ + d3Force: () => ({ + distance: vi.fn(), + strength: vi.fn(), + }), + d3ReheatSimulation: vi.fn(), + zoomToFit: vi.fn(), + })); + forceGraph2DProps.push(props); + return ( +
+ {props.graphData.nodes.map((node: Record) => ( +
+ ); + }), + }; +}); + import App from "./App"; function makeCatalogItem(id: number, crawlStatus: string, title: string) { @@ -42,6 +77,7 @@ function makeCatalogItem(id: number, crawlStatus: string, title: string) { function makeDetailPayload(item: Record) { const relatedBlog = makeCatalogItem(88, "FINISHED", "Related Blog"); + const downstreamBlog = makeCatalogItem(87, "FINISHED", "Downstream Blog"); const recommendedBlog = makeCatalogItem(89, "FINISHED", "Recommended Blog"); const viaBlog = makeCatalogItem(90, "FINISHED", "Mutual Blog"); return { @@ -66,6 +102,14 @@ function makeDetailPayload(item: Record) { link_url_raw: relatedBlog.url, neighbor_blog: relatedBlog, }, + { + id: "outgoing-2", + from_blog_id: item.id, + to_blog_id: downstreamBlog.id, + link_text: "next", + link_url_raw: downstreamBlog.url, + neighbor_blog: downstreamBlog, + }, ], recommended_blogs: [ { @@ -73,6 +117,78 @@ function makeDetailPayload(item: Record) { via_blogs: [viaBlog], }, ], + discovery_path: { + mode: "crawled", + origin_source: "seed", + origin_label: "种子导入", + target_source: "rss", + truncated: false, + steps: [ + { + blog: relatedBlog, + blog_id: relatedBlog.id, + url: relatedBlog.url, + domain: relatedBlog.domain, + accepted_by: "seed", + accepted_label: "种子导入", + raw_id: null, + raw_source_blog_id: null, + raw_accepted_by: null, + discovered_at: null, + }, + { + blog: item, + blog_id: item.id, + url: String(item.url), + domain: String(item.domain), + accepted_by: null, + accepted_label: "RSS 判定", + raw_id: 22, + raw_source_blog_id: relatedBlog.id, + raw_accepted_by: "rss", + discovered_at: "2026-04-20T10:00:00Z", + }, + ], + }, + relation_graphs: { + incoming: { + direction: "incoming", + focus_blog_id: item.id, + depth: 2, + nodes: [item, relatedBlog], + edges: [ + { + id: "incoming-1", + from_blog_id: relatedBlog.id, + to_blog_id: item.id, + link_text: "friend", + link_url_raw: item.url, + }, + ], + }, + outgoing: { + direction: "outgoing", + focus_blog_id: item.id, + depth: 2, + nodes: [item, relatedBlog, downstreamBlog], + edges: [ + { + id: "outgoing-1", + from_blog_id: item.id, + to_blog_id: relatedBlog.id, + link_text: "blogroll", + link_url_raw: relatedBlog.url, + }, + { + id: "outgoing-2", + from_blog_id: item.id, + to_blog_id: downstreamBlog.id, + link_text: "next", + link_url_raw: downstreamBlog.url, + }, + ], + }, + }, }; } @@ -138,6 +254,7 @@ beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.stubGlobal("ResizeObserver", TestResizeObserver); forceGraphProps.length = 0; + forceGraph2DProps.length = 0; window.history.replaceState({}, "", "/"); catalogItems = [...baseCatalogItems, makeCatalogItem(33, "PROCESSING", "Newest Processing Blog")]; window.localStorage.clear(); @@ -449,9 +566,12 @@ test("lets home users search normalized URLs and open the blog detail route", as expect(screen.getByText("1 个匹配")).toBeInTheDocument(); expect(screen.getByText("Finished Blog")).toBeInTheDocument(); expect(screen.getByText("https://finished-blog.example.com/")).toBeInTheDocument(); - expect(screen.getByAltText("finished-blog.example.com icon")).toHaveAttribute( - "src", - "https://finished-blog.example.com/favicon.ico", + expect(screen.getAllByAltText("finished-blog.example.com icon")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + src: "https://finished-blog.example.com/favicon.ico", + }), + ]), ); fireEvent.click(screen.getByRole("button", { name: /Finished Blog/i })); @@ -463,13 +583,33 @@ test("lets home users search normalized URLs and open the blog detail route", as await waitFor(() => { expect(screen.getByRole("heading", { name: "Finished Blog" })).toBeInTheDocument(); }); - expect(screen.getByAltText("finished-blog.example.com icon")).toHaveAttribute( - "src", - "https://finished-blog.example.com/favicon.ico", + expect(screen.getAllByAltText("finished-blog.example.com icon")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + src: "https://finished-blog.example.com/favicon.ico", + }), + ]), ); - expect(screen.getByRole("heading", { name: "直接相关博客" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "推荐博客" })).toBeInTheDocument(); - expect(screen.getByText("通过 Mutual Blog 关联")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "博客关联" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "发现路径" })).toBeInTheDocument(); + expect(screen.getByText("https://related-blog.example.com/")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "入链关系" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "出链关系" })).toBeInTheDocument(); + const relatedNode = screen.getByLabelText("Related Blog https://related-blog.example.com/"); + expect(relatedNode).toBeInTheDocument(); + fireEvent.mouseEnter(relatedNode); + const tooltip = screen.getByRole("tooltip"); + expect(within(tooltip).getByText("Related Blog")).toBeInTheDocument(); + expect(within(tooltip).getByText("https://related-blog.example.com/")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "出链关系" })); + expect(screen.getByLabelText("Downstream Blog https://downstream-blog.example.com/")).toBeInTheDocument(); + expect(screen.queryByText("种子导入")).not.toBeInTheDocument(); + expect(screen.queryByText("RSS 判定")).not.toBeInTheDocument(); + expect(screen.queryByText(/源头/)).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "直接相关博客" })).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "推荐博客" })).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "基础信息" })).not.toBeInTheDocument(); + expect(screen.queryByText("通过 Mutual Blog 关联")).not.toBeInTheDocument(); }); test("submits a user seed when home URL search has no matches", async () => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8e85b53..f85b647 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -132,10 +132,45 @@ interface BackendRecommendedBlog extends BackendGraphNode { via_blogs?: BackendNeighborSummary[]; } +interface BackendBlogDiscoveryStep { + blog: BackendNeighborSummary | null; + blog_id: number; + url: string; + domain: string; + accepted_by: string | null; + accepted_label: string | null; + raw_id: number | null; + raw_source_blog_id: number | null; + raw_accepted_by: string | null; + discovered_at: string | null; +} + +interface BackendBlogDiscoveryPath { + mode: "manual" | "crawled"; + origin_source: string | null; + origin_label: string; + target_source: string | null; + truncated: boolean; + steps: BackendBlogDiscoveryStep[]; +} + +interface BackendBlogRelationGraph { + direction: "incoming" | "outgoing"; + focus_blog_id: number; + depth: number; + nodes: BackendGraphNode[]; + edges: BackendGraphEdge[]; +} + interface BackendBlogDetail extends BackendGraphNode { incoming_edges: BackendBlogRelation[]; outgoing_edges: BackendBlogRelation[]; recommended_blogs: BackendRecommendedBlog[]; + discovery_path?: BackendBlogDiscoveryPath | null; + relation_graphs?: { + incoming: BackendBlogRelationGraph; + outgoing: BackendBlogRelationGraph; + }; } interface BackendStatsPayload { @@ -363,6 +398,28 @@ function toBlogCatalogItem(node: BackendGraphNode): BlogCatalogItem { }; } +/** + * Convert one backend relation graph into frontend graph coordinates. + * + * @param graph Backend directional relation graph. + * @returns Normalized relation graph. + */ +function toBlogRelationGraph(graph: BackendBlogRelationGraph) { + return { + direction: graph.direction, + focusBlogId: graph.focus_blog_id, + depth: graph.depth, + nodes: graph.nodes.map(toGraphNode), + edges: graph.edges.map((edge) => ({ + id: String(edge.id ?? `${edge.from_blog_id}-${edge.to_blog_id}`), + source: edge.from_blog_id, + target: edge.to_blog_id, + linkText: edge.link_text, + linkUrlRaw: edge.link_url_raw, + })), + }; +} + /** * Convert one backend blog label tag into the frontend admin tag shape. * @@ -630,9 +687,16 @@ export async function fetchBlogDetail(blogId: number): Promise { .filter((neighbor): neighbor is BackendNeighborSummary => neighbor !== null) .map(toGraphNode); const outgoingNeighbors = payload.outgoing_edges - .map((edge) => edge.neighbor_blog) - .filter((neighbor): neighbor is BackendNeighborSummary => neighbor !== null) - .map(toGraphNode); + .map((edge) => { + if (!edge.neighbor_blog) { + return null; + } + return { + ...toGraphNode(edge.neighbor_blog), + url: edge.link_url_raw, + }; + }) + .filter((neighbor): neighbor is GraphNode => neighbor !== null); const relatedNodesById = new Map(); [...incomingNeighbors, ...outgoingNeighbors].forEach((node) => { relatedNodesById.set(node.id, node); @@ -641,12 +705,75 @@ export async function fetchBlogDetail(blogId: number): Promise { ...toGraphNode(blog), viaBlogs: (blog.via_blogs ?? []).map(toGraphNode), })); + const discoveryPath = payload.discovery_path + ? { + mode: payload.discovery_path.mode, + originSource: payload.discovery_path.origin_source, + originLabel: payload.discovery_path.origin_label, + targetSource: payload.discovery_path.target_source, + truncated: payload.discovery_path.truncated, + steps: payload.discovery_path.steps.map((step) => ({ + blog: step.blog + ? { + id: step.blog.blog_id ?? step.blog.id, + domain: step.blog.domain, + title: step.blog.title, + iconUrl: step.blog.icon_url, + } + : null, + blogId: step.blog_id, + url: step.url, + domain: step.domain, + acceptedBy: step.accepted_by, + acceptedLabel: step.accepted_label, + rawId: step.raw_id, + rawSourceBlogId: step.raw_source_blog_id, + rawAcceptedBy: step.raw_accepted_by, + discoveredAt: step.discovered_at, + })), + } + : null; return { ...toGraphNode(payload), incomingLinks: payload.incoming_edges.length, outgoingLinks: payload.outgoing_edges.length, relatedNodes: Array.from(relatedNodesById.values()), + outgoingNodes: outgoingNeighbors, recommendedBlogs, + discoveryPath, + relationGraphs: payload.relation_graphs + ? { + incoming: toBlogRelationGraph(payload.relation_graphs.incoming), + outgoing: toBlogRelationGraph(payload.relation_graphs.outgoing), + } + : { + incoming: { + direction: "incoming", + focusBlogId: payload.blog_id ?? payload.id, + depth: 2, + nodes: [toGraphNode(payload), ...incomingNeighbors], + edges: payload.incoming_edges.map((edge) => ({ + id: String(edge.id), + source: edge.from_blog_id, + target: edge.to_blog_id, + linkText: edge.link_text, + linkUrlRaw: edge.link_url_raw, + })), + }, + outgoing: { + direction: "outgoing", + focusBlogId: payload.blog_id ?? payload.id, + depth: 2, + nodes: [toGraphNode(payload), ...outgoingNeighbors], + edges: payload.outgoing_edges.map((edge) => ({ + id: String(edge.id), + source: edge.from_blog_id, + target: edge.to_blog_id, + linkText: edge.link_text, + linkUrlRaw: edge.link_url_raw, + })), + }, + }, }; } diff --git a/frontend/src/pages/BlogDetailPage.tsx b/frontend/src/pages/BlogDetailPage.tsx index bd034a1..4fc72da 100644 --- a/frontend/src/pages/BlogDetailPage.tsx +++ b/frontend/src/pages/BlogDetailPage.tsx @@ -1,11 +1,22 @@ -import { ArrowLeft, ArrowRight, ArrowUpRight, GitBranch, Loader2, Network, Sparkles } from "lucide-react"; -import { useEffect, useState } from "react"; +import { + ArrowLeft, + ArrowRight, + ArrowUpRight, + Loader2, + Network, + Route, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ForceGraph2D, { type ForceGraphMethods } from "react-force-graph-2d"; import { Link, useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import { Navigation } from "../components/Navigation"; import { fetchBlogDetail } from "../lib/api"; -import { resolveBlogIconUrls } from "../lib/icon"; -import type { BlogDetail, GraphNode, RecommendedBlog } from "../types/graph"; +import { resolveBlogIconUrls, resolveIconProxyUrl } from "../lib/icon"; +import type { BlogDetail, BlogDiscoveryPath, BlogDiscoveryStep, BlogRelationGraph, GraphNode } from "../types/graph"; + +const RELATION_GRAPH_LINK_DISTANCE = 78; +const RELATION_GRAPH_CHARGE_STRENGTH = -260; /** * Format a numeric count for compact detail cards. @@ -17,27 +28,6 @@ function formatCount(value: number) { return new Intl.NumberFormat("zh-CN").format(value); } -/** - * Render one compact blog link in related and recommendation lists. - * - * @param props Blog row and optional supporting copy. - * @returns Clickable blog summary row. - */ -function BlogListItem({ blog, helperText }: { blog: GraphNode | RecommendedBlog; helperText?: string }) { - return ( - -
-
{blog.title || blog.domain}
-
{blog.domain}
- {helperText ?
{helperText}
: null} -
- - ); -} - /** * Render a detail page hero icon with favicon fallbacks. * @@ -71,6 +61,403 @@ function BlogHeroIcon({ detail }: { detail: BlogDetail }) { ); } +/** + * Render one compact card for a historical discovery path step. + * + * @param props Discovery step returned by the blog detail API. + * @returns Clickable blog card with title, icon, and URL. + */ +function DiscoveryPathCard({ step }: { step: BlogDiscoveryStep }) { + const blog = { + id: step.blogId, + url: step.url, + domain: step.domain, + title: step.blog?.title ?? null, + iconUrl: step.blog?.iconUrl ?? null, + }; + const iconUrls = resolveBlogIconUrls(blog); + const [iconIndex, setIconIndex] = useState(0); + const iconUrl = iconUrls[iconIndex]; + + useEffect(() => { + setIconIndex(0); + }, [step.blogId, step.url, step.domain, step.blog?.iconUrl]); + + return ( + +
+ {iconUrl ? ( + {`${step.domain} setIconIndex((currentIndex) => currentIndex + 1)} + /> + ) : ( + {(step.domain || "?").slice(0, 1).toUpperCase()} + )} +
+
+
{step.blog?.title || step.domain}
+
{step.url}
+
+ + ); +} + +/** + * Render only the historical discovery path, without outgoing branches. + * + * @param props Discovery path payload. + * @returns Historical discovery path section or null when unavailable. + */ +function DiscoveryPathSection({ path }: { path: BlogDiscoveryPath | null }) { + if (!path || path.steps.length === 0) { + return null; + } + + return ( +
+
+ +

发现路径

+
+
+
+ {path.steps.map((step, index) => ( +
+ + {index < path.steps.length - 1 ? ( +
+
+ +
+ ) : null} +
+ ))} +
+
+
+ ); +} + +interface RelationRenderNode extends Omit { + id: string; + blogId: number; + original: GraphNode; + label: string; + iconUrls: string[]; + radius: number; +} + +interface RelationRenderLink { + id: string; + source: string | RelationRenderNode; + target: string | RelationRenderNode; +} + +interface RelationRenderGraph { + nodes: RelationRenderNode[]; + links: RelationRenderLink[]; +} + +/** + * Build force-graph render data from the blog relation API payload. + * + * @param graph Directional relation graph payload. + * @returns Render nodes and links for react-force-graph-2d. + */ +function buildRelationRenderGraph(graph: BlogRelationGraph): RelationRenderGraph { + const nodes = graph.nodes.map((node) => { + const iconUrls = resolveBlogIconUrls(node).map(resolveIconProxyUrl); + return { + ...node, + id: String(node.id), + blogId: node.id, + original: node, + label: node.title?.trim() || node.domain || node.url, + iconUrls, + radius: node.id === graph.focusBlogId ? 18 : 13, + }; + }); + const nodeIds = new Set(nodes.map((node) => node.id)); + return { + nodes, + links: graph.edges + .map((edge) => ({ + id: edge.id, + source: String(edge.source), + target: String(edge.target), + })) + .filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target)), + }; +} + +/** + * Resolve a force-graph link endpoint id after d3 mutates links. + * + * @param endpoint Link source or target value. + * @returns Stable render node id. + */ +function relationEndpointId(endpoint: string | RelationRenderNode): string { + return typeof endpoint === "object" ? endpoint.id : String(endpoint); +} + +/** + * Draw a relation graph node on a 2D force-graph canvas. + * + * @param node Render node to draw. + * @param context Canvas context from react-force-graph-2d. + * @param imageCache Loaded icon cache keyed by proxied icon URL. + * @param focusBlogId Current detail blog id. + * @param hoveredBlogId Hovered blog id, if any. + */ +function paintRelationNode( + node: RelationRenderNode, + context: CanvasRenderingContext2D, + imageCache: Map, + focusBlogId: number, + hoveredBlogId: number | null, +) { + const x = node.x ?? 0; + const y = node.y ?? 0; + const isFocus = node.blogId === focusBlogId; + const isHovered = node.blogId === hoveredBlogId; + const radius = node.radius + (isHovered ? 3 : 0); + const icon = node.iconUrls.map((url) => imageCache.get(url)).find((image) => image?.complete && image.naturalWidth > 0); + + context.save(); + context.beginPath(); + context.arc(x, y, radius + (isFocus ? 5 : 3), 0, Math.PI * 2); + context.fillStyle = isFocus ? "rgba(14, 165, 233, 0.2)" : "rgba(148, 163, 184, 0.18)"; + context.fill(); + + context.beginPath(); + context.arc(x, y, radius, 0, Math.PI * 2); + context.fillStyle = icon ? "#ffffff" : isFocus ? "#bae6fd" : "#cbd5e1"; + context.fill(); + context.lineWidth = isFocus ? 3 : 1.5; + context.strokeStyle = isFocus ? "#0284c7" : "#ffffff"; + context.stroke(); + + if (icon) { + context.save(); + context.beginPath(); + context.arc(x, y, radius - 1, 0, Math.PI * 2); + context.clip(); + context.drawImage(icon, x - radius, y - radius, radius * 2, radius * 2); + context.restore(); + } + context.restore(); +} + +/** + * Paint the clickable pointer area for one relation graph node. + * + * @param node Render node to cover. + * @param paintColor Hidden pointer-picking color supplied by force graph. + * @param context Canvas context from react-force-graph-2d. + */ +function paintRelationPointerArea(node: RelationRenderNode, paintColor: string, context: CanvasRenderingContext2D) { + const radius = node.radius + 5; + context.fillStyle = paintColor; + context.beginPath(); + context.arc(node.x ?? 0, node.y ?? 0, radius, 0, Math.PI * 2); + context.fill(); +} + +/** + * Render one paged blog relation graph as an interactive 2D force graph. + * + * @param props Directional relation graph payload. + * @returns 2D force-graph relation view. + */ +function RelationGraphView({ graph }: { graph: BlogRelationGraph }) { + const navigate = useNavigate(); + const graphRef = useRef | undefined>(undefined); + const containerRef = useRef(null); + const imageCacheRef = useRef(new Map()); + const [size, setSize] = useState({ width: 960, height: 360 }); + const [isMeasured, setIsMeasured] = useState(false); + const [hoveredBlog, setHoveredBlog] = useState(null); + const [iconPaintVersion, setIconPaintVersion] = useState(0); + const renderGraph = useMemo(() => buildRelationRenderGraph(graph), [graph]); + const hoveredBlogId = hoveredBlog?.id ?? null; + const fitGraphToView = useCallback((durationMs = 500) => { + graphRef.current?.zoomToFit(durationMs, 44); + }, []); + + useEffect(() => { + if (!containerRef.current) { + return undefined; + } + const observer = new ResizeObserver(([entry]) => { + setSize({ + width: Math.max(320, Math.floor(entry.contentRect.width)), + height: Math.max(320, Math.floor(entry.contentRect.height)), + }); + setIsMeasured(true); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const graphInstance = graphRef.current; + if (!isMeasured || !graphInstance) { + return undefined; + } + graphInstance.d3Force("center", null); + const chargeForce = graphInstance.d3Force("charge") as { strength?: (value: number) => unknown } | undefined; + chargeForce?.strength?.(RELATION_GRAPH_CHARGE_STRENGTH); + const linkForce = graphInstance.d3Force("link") as { distance?: (value: number) => unknown } | undefined; + linkForce?.distance?.(RELATION_GRAPH_LINK_DISTANCE); + graphInstance.d3ReheatSimulation(); + const firstFitTimer = window.setTimeout(() => fitGraphToView(450), 120); + const settledFitTimer = window.setTimeout(() => fitGraphToView(450), 620); + return () => { + window.clearTimeout(firstFitTimer); + window.clearTimeout(settledFitTimer); + }; + }, [fitGraphToView, isMeasured, renderGraph, size.height, size.width]); + + useEffect(() => { + let isDisposed = false; + const urls = Array.from(new Set(renderGraph.nodes.flatMap((node) => node.iconUrls))); + urls.forEach((url) => { + if (imageCacheRef.current.has(url)) { + return; + } + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + if (!isDisposed) { + imageCacheRef.current.set(url, image); + setIconPaintVersion((version) => version + 1); + } + }; + image.onerror = () => { + imageCacheRef.current.delete(url); + }; + image.src = url; + imageCacheRef.current.set(url, image); + }); + return () => { + isDisposed = true; + }; + }, [renderGraph.nodes]); + + const nodeCanvasObject = useCallback( + (node: RelationRenderNode, context: CanvasRenderingContext2D) => { + paintRelationNode(node, context, imageCacheRef.current, graph.focusBlogId, hoveredBlogId); + }, + [graph.focusBlogId, hoveredBlogId, iconPaintVersion], + ); + + return ( +
+ {isMeasured ? ( + + ref={graphRef} + graphData={renderGraph} + nodeId="id" + width={size.width} + height={size.height} + backgroundColor="#f8fafc" + nodeLabel={(node) => `${node.label}\n${node.url}`} + nodeVal={(node) => node.radius} + nodeCanvasObjectMode={() => "replace"} + nodeCanvasObject={nodeCanvasObject} + nodePointerAreaPaint={paintRelationPointerArea} + linkSource="source" + linkTarget="target" + linkColor={() => (graph.direction === "incoming" ? "rgba(2, 132, 199, 0.58)" : "rgba(5, 150, 105, 0.58)")} + linkWidth={() => 1.7} + linkDirectionalArrowLength={5} + linkDirectionalArrowRelPos={1} + linkDirectionalArrowColor={() => (graph.direction === "incoming" ? "#0284c7" : "#059669")} + enableNodeDrag={false} + enablePointerInteraction + cooldownTicks={90} + d3VelocityDecay={0.34} + d3AlphaDecay={0.04} + onNodeHover={(node) => setHoveredBlog(node?.original ?? null)} + onNodeClick={(node) => navigate(`/blogs/${node.blogId}`)} + showPointerCursor={(item) => Boolean(item && "blogId" in item)} + /> + ) : null} +
+ {renderGraph.nodes.map((node) => ( + {`${node.label} ${node.url}`} + ))} +
+ {hoveredBlog ? ( +
+
{hoveredBlog.title || hoveredBlog.domain}
+
{hoveredBlog.url || hoveredBlog.domain}
+
+ ) : null} +
+ ); +} + +/** + * Render the paged blog association module. + * + * @param props Incoming and outgoing relation graphs. + * @returns Blog association section with two graph pages. + */ +function BlogAssociationSection({ detail }: { detail: BlogDetail }) { + const [activeGraph, setActiveGraph] = useState<"incoming" | "outgoing">("incoming"); + const graph = detail.relationGraphs[activeGraph]; + + return ( +
+
+ +

博客关联

+
+
+ + +
+ {graph.nodes.length > 1 ? ( + + ) : ( +
+ 暂无{activeGraph === "incoming" ? "入链" : "出链"}关联。 +
+ )} +
+ ); +} + /** * Render the public blog detail page. * @@ -83,8 +470,6 @@ export function BlogDetailPage() { const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); const numericBlogId = Number(blogId); - const relatedBlogs = detail?.relatedNodes ?? []; - const recommendedBlogs = detail?.recommendedBlogs ?? []; useEffect(() => { let isDisposed = false; @@ -196,71 +581,13 @@ export function BlogDetailPage() {
直接相关博客
-
{formatCount(relatedBlogs.length)}
+
{formatCount(detail.relatedNodes.length)}
-
-
-
- -

直接相关博客

-
- {relatedBlogs.length > 0 ? ( -
- {relatedBlogs.map((blog) => ( - - ))} -
- ) : ( -
暂无直接相关博客。
- )} -
+ - -
+
) : null} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 49cbac4..51bfd46 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,7 +1,7 @@ import { GitBranch, Loader2, Network, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; +import { BlogDetailLink } from "../components/BlogDetailLink"; import { MissingBlogConfirmDialog } from "../components/MissingBlogConfirmDialog"; import { Navigation } from "../components/Navigation"; import { fetchBlogsCatalog, fetchStats, submitUserSeed } from "../lib/api"; @@ -10,6 +10,7 @@ import type { BlogCatalogItem, StatsData } from "../types/graph"; const HOME_REFRESH_INTERVAL_MS = 5000; const HOME_SEARCH_PAGE_SIZE = 30; +const HOME_SEARCH_ENTRANCE_KIND = "home_search_result"; /** * Render the icon used in one homepage search result row. @@ -50,7 +51,6 @@ function SearchResultIcon({ blog }: { blog: BlogCatalogItem }) { * @returns Home route UI. */ export function HomePage() { - const navigate = useNavigate(); const [stats, setStats] = useState({ totalNodes: 0, totalEdges: 0 }); const [isInitialLoading, setIsInitialLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); @@ -189,15 +189,6 @@ export function HomePage() { } } - /** - * Navigate to the temporary blog detail route. - * - * @param blog Selected search result. - */ - function openBlogDetail(blog: BlogCatalogItem) { - navigate(`/blogs/${blog.id}`); - } - /** * Submit a user-confirmed missing blog URL as an accepted crawler seed. * @@ -280,10 +271,11 @@ export function HomePage() { {searchResults.length > 0 ? (
{searchResults.map((blog) => ( - + ))}
) : ( diff --git a/frontend/src/pages/RandomBlogPage.tsx b/frontend/src/pages/RandomBlogPage.tsx index 4390797..01dfd7b 100644 --- a/frontend/src/pages/RandomBlogPage.tsx +++ b/frontend/src/pages/RandomBlogPage.tsx @@ -1,14 +1,21 @@ import { Eye, Loader2, RefreshCw } from "lucide-react"; import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { BlogCard } from "../components/BlogCard"; +import { BlogDetailLink } from "../components/BlogDetailLink"; import { Navigation } from "../components/Navigation"; import { readStoredAuthSession } from "../lib/auth"; -import { fetchBlogsCatalog, postBlogUserLabel } from "../lib/api"; +import { fetchRandomBlogBatch, postBlogUserLabel } from "../lib/api"; +import { + blogInteractionTarget, + getBlogInteractionSessionId, + getBlogInteractionVisitorId, + recordBlogInteraction, +} from "../lib/blogInteractions"; import type { BlogCatalogItem } from "../types/graph"; const RANDOM_BLOG_COUNT = 9; +const RANDOM_PAGE_ENTRANCE_KIND = "random_blog_page"; const RANDOM_LABELS = [ { slug: "blog", label: "博客" }, { slug: "company", label: "公司" }, @@ -22,7 +29,6 @@ const RANDOM_LABELS = [ * @returns Random finished-blog discovery page. */ export function RandomBlogPage() { - const navigate = useNavigate(); const [blogs, setBlogs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); @@ -52,11 +58,15 @@ export function RandomBlogPage() { } else { setIsRefreshing(true); } - const response = await fetchBlogsCatalog({ - page: 1, - pageSize: RANDOM_BLOG_COUNT, - status: "FINISHED", - sort: "random", + const session = readStoredAuthSession(); + const response = await fetchRandomBlogBatch({ + count: RANDOM_BLOG_COUNT, + visitorId: getBlogInteractionVisitorId(), + sessionId: getBlogInteractionSessionId(), + source: "random_page", + pageUrl: window.location.href, + context: { refresh_kind: showInitialLoading ? "initial" : "manual" }, + token: session?.token, }); setBlogs(response.items); } catch { @@ -90,6 +100,15 @@ export function RandomBlogPage() { ...current, [blog.normalizedUrl]: label, })); + recordBlogInteraction( + blogInteractionTarget(blog), + "label_select", + { + entranceKind: RANDOM_PAGE_ENTRANCE_KIND, + entranceUrl: window.location.href, + }, + { label, previous_label: selectedLabel ?? null }, + ); toast.success("已记录,谢谢标注。"); } catch { toast.error("标注保存失败,请稍后再试。"); @@ -98,15 +117,6 @@ export function RandomBlogPage() { } } - /** - * Open the internal blog detail route for a random blog card. - * - * @param blog Blog selected from the random catalog. - */ - function openBlogDetail(blog: BlogCatalogItem) { - navigate(`/blogs/${blog.id}`); - } - if (isLoading) { return (
@@ -146,15 +156,21 @@ export function RandomBlogPage() {
{blogs.map((blog) => ( - - +
{RANDOM_LABELS.map((label) => { const isSaving = savingLabelKey === `${blog.id}:${label.slug}`; diff --git a/frontend/src/types/graph.ts b/frontend/src/types/graph.ts index 4727f40..b5105d4 100644 --- a/frontend/src/types/graph.ts +++ b/frontend/src/types/graph.ts @@ -122,6 +122,9 @@ export interface StatusData { export interface BlogCatalogItem extends GraphNode { normalizedUrl: string; + requestUuid?: string; + impressionId?: number; + position?: number; identityKey: string; identityReasonCodes: string[]; identityRulesetVersion: string; @@ -150,6 +153,35 @@ export interface BlogCatalogPage { sort: string; } +export interface RandomRecommendationBatch { + requestUuid: string; + surface: string; + strategy: string; + strategyVersion: string; + visitorId: string; + sessionId: string; + requestedCount: number; + servedCount: number; + createdAt: string | null; + items: BlogCatalogItem[]; +} + +export interface RecommendationEventInput { + eventUuid: string; + eventType: string; + blogId: number; + visitorId: string; + sessionId: string; + entranceKind: string; + entranceUrl: string; + requestUuid?: string; + impressionId?: number; + position?: number; + interactionOrder?: number; + clientEventAt?: string; + attributes?: Record; +} + export interface UserProfile { id: number; email: string; diff --git a/memory/MEMORY.md b/memory/MEMORY.md deleted file mode 100644 index 81f0e75..0000000 --- a/memory/MEMORY.md +++ /dev/null @@ -1,3 +0,0 @@ -# Memory Index - -- [Filter chain two-phase architecture](filter-chain-two-phase-architecture.md) — rule AND-gate + success OR-group (RSS then model); URL-refilter feature deleted diff --git a/memory/filter-chain-two-phase-architecture.md b/memory/filter-chain-two-phase-architecture.md deleted file mode 100644 index 715f2fa..0000000 --- a/memory/filter-chain-two-phase-architecture.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: filter-chain-two-phase-architecture -description: How the crawler URL filter chain works after the 2026-05-30 RSS refactor (rule AND-gate + success OR-group) -metadata: - type: project ---- - -As of 2026-05-30 the crawler URL decision chain ([crawler/crawling/decisions/chain.py](crawler/crawling/decisions/chain.py)) is two-phase, not the old pure-AND chain: - -- **Phase 1 — `rule` filters (AND-gate):** every deterministic hard rule must accept; first rejection wins and returns verbatim. `decider_role == "rule"`. -- **Phase 2 — `success` deciders (ordered OR-group):** run after rules. First decider that *confirms* (returns `FilterDecision.confirmed=True`) keeps the candidate immediately, carrying any `feed_url`. A decider that *abstains* (accepts without confirming) defers to the next. If none confirm but at least one rejected, the last rejection wins. `decider_role == "success"`. - -Success deciders in default order: `rss_discovery` then `model_consensus`. - -- **RSS layer** ([crawler/crawling/decisions/rss.py](crawler/crawling/decisions/rss.py)): fetches the candidate homepage, parses `` feed links, probes common feed paths, validates with `feedparser`. Confirms + records the feed URL. **Needs a live fetcher** threaded via `UrlCandidateContext.fetcher` (+ `fetch_deadline`). Offline callers (dedup scan, funnel stats) pass no fetcher, so RSS abstains and stays network-free. Flag: `HEYBLOG_RSS_DISCOVERY_ENABLED` (default on). -- RSS-absence is an **abstain**, never a rejection — many blogs lack feeds, so they fall through to model consensus. With RSS off, behavior is identical to the legacy chain. - -`feed_url` is persisted on `blogs.feed_url` via `upsert_blog(..., feed_url=...)` (only set on insert or when existing feed is empty; never overwritten with null). - -**The offline URL-refilter feature was deleted entirely** in the same change (per user request): all `url_refilter` repository methods/models/endpoints/HTTP-client methods/frontend UI, plus `_backup_sqlite_database`, `_handle_refilter_*`, `_filter_chain_version`. The blog **dedup scan** is a separate feature and was kept — it still uses `decision_chain.decide()`, `_decision_scan_settings`, `_decision_scan_ruleset_version`, `_delete_blog_graph`. Migration `20260530_02` drops the refilter tables; `20260530_01` adds `feed_url`. Related: [[heyblog-service-boundaries]]. diff --git a/persistence_api/main.py b/persistence_api/main.py index 4a01594..d2f353a 100644 --- a/persistence_api/main.py +++ b/persistence_api/main.py @@ -116,6 +116,33 @@ class IncrementBlogUserLabelRequest(BaseModel): user_id: int | None = None +class CreateRandomRecommendationBatchRequest(BaseModel): + count: int = 9 + visitor_id: str + session_id: str + user_id: int | None = None + source: str | None = None + page_url: str | None = None + context: dict[str, Any] | None = None + + +class RecordBlogInteractionRequest(BaseModel): + event_uuid: str + event_type: str + blog_id: int + visitor_id: str + session_id: str + entrance_kind: str + entrance_url: str + request_uuid: str | None = None + impression_id: int | None = None + position: int | None = None + interaction_order: int = 1 + user_id: int | None = None + client_event_at: str | None = None + attributes: dict[str, Any] | None = None + + class CreateBlogLabelTagRequest(BaseModel): name: str @@ -345,6 +372,38 @@ def lookup_blog_candidates(url: str) -> dict[str, Any]: status_code=422, ) + @app.post("/internal/recommendations/random-blog-batches") + def create_random_recommendation_batch(payload: CreateRandomRecommendationBatchRequest) -> dict[str, Any]: + return _call_with_http_exception_translation( + lambda: get_state().repository.create_random_recommendation_batch(**payload.model_dump()), + exception_translations=( + (ValueError, 422, None), + (UserAuthError, 401, None), + ), + ) + + @app.post("/internal/recommendation-events") + def record_blog_interaction(payload: RecordBlogInteractionRequest) -> dict[str, Any]: + return _call_with_http_exception_translation( + lambda: get_state().repository.record_blog_interaction(**payload.model_dump()), + exception_translations=( + (ValueError, 422, None), + (BlogLabelingNotFoundError, 404, None), + (UserAuthError, 401, None), + ), + ) + + @app.get("/internal/blogs/{blog_id}/recommendation-stats") + def get_blog_recommendation_stats(blog_id: int) -> dict[str, Any]: + return _require_payload( + get_state().repository.get_blog_recommendation_stats(blog_id), + detail="blog_not_found", + ) + + @app.get("/internal/recommendation-stats") + def get_recommendation_strategy_stats() -> dict[str, Any]: + return get_state().repository.get_recommendation_strategy_stats() + @app.get("/internal/ingestion-requests") def list_priority_ingestion_requests() -> list[dict[str, Any]]: return get_state().repository.list_priority_ingestion_requests() diff --git a/persistence_api/models.py b/persistence_api/models.py index f7ddae3..e4f66bf 100644 --- a/persistence_api/models.py +++ b/persistence_api/models.py @@ -280,6 +280,115 @@ class RawDiscoveredUrlModel(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) +class RecommendationRequestModel(Base): + """One recommendation-serving request shown to a visitor. + + Args: + None. SQLAlchemy constructs model instances from mapped keyword + arguments. + + Returns: + Recommendation request row that groups one ordered impression set. + """ + + __tablename__ = "recommendation_requests" + __table_args__ = ( + Index("ix_recommendation_requests_surface_created", "surface", "created_at"), + Index("ix_recommendation_requests_strategy_created", "strategy", "strategy_version", "created_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + request_uuid: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True) + surface: Mapped[str] = mapped_column(Text, nullable=False, index=True) + strategy: Mapped[str] = mapped_column(Text, nullable=False) + strategy_version: Mapped[str] = mapped_column(Text, nullable=False, default="v1") + visitor_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + session_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + source: Mapped[str | None] = mapped_column(Text, nullable=True) + page_url: Mapped[str | None] = mapped_column(Text, nullable=True) + requested_count: Mapped[int] = mapped_column(Integer, nullable=False) + served_count: Mapped[int] = mapped_column(Integer, nullable=False) + context_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class RecommendationImpressionModel(Base): + """One ordered blog impression inside a recommendation request. + + Args: + None. SQLAlchemy constructs model instances from mapped keyword + arguments. + + Returns: + Impression row linking a request to one shown blog and position. + """ + + __tablename__ = "recommendation_impressions" + __table_args__ = ( + UniqueConstraint("request_id", "position", name="uq_recommendation_impression_request_position"), + UniqueConstraint("request_id", "blog_id", name="uq_recommendation_impression_request_blog"), + Index("ix_recommendation_impressions_blog_created", "blog_id", "created_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + request_id: Mapped[int] = mapped_column( + ForeignKey("recommendation_requests.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + blog_id: Mapped[int] = mapped_column(ForeignKey("blogs.blog_id", ondelete="CASCADE"), nullable=False, index=True) + normalized_url: Mapped[str] = mapped_column(Text, nullable=False, index=True) + position: Mapped[int] = mapped_column(Integer, nullable=False) + score: Mapped[int | None] = mapped_column(Integer, nullable=True) + reason_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class BlogInteractionModel(Base): + """One idempotent visitor interaction with a blog or impression. + + Args: + None. SQLAlchemy constructs model instances from mapped keyword + arguments. + + Returns: + Raw immutable event row used for attribution and statistics. + """ + + __tablename__ = "blog_interactions" + __table_args__ = ( + Index("ix_blog_interactions_blog_event_created", "blog_id", "event_type", "created_at"), + Index("ix_blog_interactions_request_event", "request_id", "event_type"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + event_uuid: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True) + request_id: Mapped[int | None] = mapped_column( + ForeignKey("recommendation_requests.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + impression_id: Mapped[int | None] = mapped_column( + ForeignKey("recommendation_impressions.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + blog_id: Mapped[int] = mapped_column(ForeignKey("blogs.blog_id", ondelete="CASCADE"), nullable=False, index=True) + normalized_url: Mapped[str] = mapped_column(Text, nullable=False, index=True) + event_type: Mapped[str] = mapped_column(Text, nullable=False, index=True) + position: Mapped[int | None] = mapped_column(Integer, nullable=True) + entrance_kind: Mapped[str] = mapped_column(Text, nullable=False, index=True) + entrance_url: Mapped[str] = mapped_column(Text, nullable=False, index=True) + interaction_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + visitor_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + session_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + client_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + attributes_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + class BlogDedupScanRunModel(Base): """Administrative full-library dedup scan summary.""" diff --git a/persistence_api/repository.py b/persistence_api/repository.py index d818de0..4a8604e 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -39,6 +39,7 @@ from persistence_api.models import Base from persistence_api.models import BlogLabelModel from persistence_api.models import BlogLabelTagModel +from persistence_api.models import BlogInteractionModel from persistence_api.models import BlogUserLabelModel from persistence_api.models import BlogUserLabelSelectionModel from persistence_api.models import BlogModel @@ -47,6 +48,8 @@ from persistence_api.models import EdgeModel from persistence_api.models import IngestionRequestModel from persistence_api.models import RawDiscoveredUrlModel +from persistence_api.models import RecommendationImpressionModel +from persistence_api.models import RecommendationRequestModel from persistence_api.models import SeedModel from persistence_api.models import UserModel from persistence_api.models import UserSessionModel @@ -95,6 +98,12 @@ BLOG_LABEL_BLOG_ID = BLOG_LABEL_NAME_TO_ID["blog"] RAW_DISCOVERED_URL_DUPLICATE_STATUS = "rule:duplicate_url" RAW_DISCOVERED_URL_SUCCESS_STATUS = "success" +RANDOM_RECOMMENDATION_SURFACE = "random_blog_page" +RANDOM_RECOMMENDATION_STRATEGY = "weighted_random" +RANDOM_RECOMMENDATION_STRATEGY_VERSION = "v1" +RECOMMENDATION_EVENT_TYPES = frozenset( + {"click", "detail_open", "external_open", "label_select", "refresh", "dismiss", "copy_url"} +) REPOSITORY_LOGGER_NAME = "heyblog.repository" LOGGER = get_logger(REPOSITORY_LOGGER_NAME) INGESTION_REQUEST_STATUS_RECEIVED = "RECEIVED" @@ -276,6 +285,80 @@ def _iso(value: datetime | None) -> str | None: return value.isoformat() if value is not None else None +def _clean_event_text(value: str, *, field: str, max_length: int = 256) -> str: + """Return a non-empty event text field or raise a stable validation error. + + Args: + value: Raw event field value supplied by a caller. + field: Field name included in the validation error. + max_length: Maximum accepted character length. + + Returns: + Trimmed field value. + + Raises: + ValueError: Raised when the value is blank or too long. + """ + + cleaned = str(value or "").strip() + if not cleaned: + raise ValueError(f"{field}_required") + if len(cleaned) > max_length: + raise ValueError(f"{field}_too_long") + return cleaned + + +def _coerce_json_object(value: dict[str, Any] | None) -> dict[str, Any]: + """Return a JSON object payload with unsupported values normalized by JSON. + + Args: + value: Optional JSON-like mapping supplied by a caller. + + Returns: + A JSON-serializable dictionary. + + Raises: + ValueError: Raised when the mapping cannot be encoded as JSON. + """ + + if value is None: + return {} + try: + return json.loads(json.dumps(value, ensure_ascii=True, default=str)) + except (TypeError, ValueError) as exc: + raise ValueError("invalid_json_attributes") from exc + + +def _parse_event_datetime(value: str | datetime | None) -> datetime | None: + """Return an optional timezone-aware client event timestamp. + + Args: + value: ISO datetime string, datetime instance, or `None`. + + Returns: + Parsed datetime with UTC timezone when supplied. + + Raises: + ValueError: Raised when the value cannot be parsed. + """ + + if value is None or isinstance(value, datetime): + parsed = value + else: + normalized = str(value).strip() + if not normalized: + return None + try: + parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("invalid_client_event_at") from exc + if parsed is None: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + def _business_blog_id(model: BlogModel | None) -> int | None: """Return the stable business blog identifier for one blog row.""" if model is None: @@ -808,6 +891,26 @@ def ensure_legacy_compat_schema(engine: Any) -> None: ) ) existing_tables.add("blog_user_label_selections") + if "blog_interactions" in existing_tables: + interaction_columns = {column["name"] for column in inspector.get_columns("blog_interactions")} + if "entrance_kind" not in interaction_columns: + connection.execute( + text("ALTER TABLE blog_interactions ADD COLUMN entrance_kind TEXT NOT NULL DEFAULT 'legacy_unknown'") + ) + if connection.dialect.name == "postgresql": + connection.execute(text("ALTER TABLE blog_interactions ALTER COLUMN entrance_kind DROP DEFAULT")) + if "entrance_url" not in interaction_columns: + connection.execute( + text("ALTER TABLE blog_interactions ADD COLUMN entrance_url TEXT NOT NULL DEFAULT 'legacy_unknown'") + ) + if connection.dialect.name == "postgresql": + connection.execute(text("ALTER TABLE blog_interactions ALTER COLUMN entrance_url DROP DEFAULT")) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_blog_interactions_entrance_kind ON blog_interactions (entrance_kind)") + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_blog_interactions_entrance_url ON blog_interactions (entrance_url)") + ) if "blog_label_tags" not in existing_tables: connection.execute( text( @@ -1856,6 +1959,41 @@ def list_blogs_catalog( acceptance_status: str | None = BLOG_ACCEPTANCE_ACCEPTED, ) -> dict[str, Any]: ... + def create_random_recommendation_batch( + self, + *, + count: int = 9, + visitor_id: str, + session_id: str, + user_id: int | None = None, + source: str | None = None, + page_url: str | None = None, + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: ... + + def record_blog_interaction( + self, + *, + event_uuid: str, + event_type: str, + blog_id: int, + visitor_id: str, + session_id: str, + entrance_kind: str, + entrance_url: str, + request_uuid: str | None = None, + impression_id: int | None = None, + position: int | None = None, + interaction_order: int = 1, + user_id: int | None = None, + client_event_at: str | datetime | None = None, + attributes: dict[str, Any] | None = None, + ) -> dict[str, Any]: ... + + def get_blog_recommendation_stats(self, blog_id: int) -> dict[str, Any] | None: ... + + def get_recommendation_strategy_stats(self) -> dict[str, Any]: ... + def list_blog_labeling_candidates( self, *, @@ -3947,6 +4085,362 @@ def list_blogs_catalog( }, ) + def create_random_recommendation_batch( + self, + *, + count: int = 9, + visitor_id: str, + session_id: str, + user_id: int | None = None, + source: str | None = None, + page_url: str | None = None, + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Persist one random-blog recommendation request and its impressions. + + Args: + count: Number of cards requested by the frontend. + visitor_id: Stable anonymous visitor identifier. + session_id: Stable browser-session identifier. + user_id: Optional authenticated user ID for attribution. + source: Optional caller surface detail. + page_url: Optional page URL where the batch was shown. + context: Optional JSON metadata associated with the serving event. + + Returns: + Recommendation batch payload containing request metadata and ordered + catalog items with impression attribution fields. + """ + + if count < 1 or count > 50: + raise ValueError("count_out_of_range") + clean_visitor_id = _clean_event_text(visitor_id, field="visitor_id") + clean_session_id = _clean_event_text(session_id, field="session_id") + request_uuid = token_urlsafe(24) + with session_scope(self.session_factory) as session: + if user_id is not None and session.scalar(select(UserModel.id).where(UserModel.id == user_id)) is None: + raise UserAuthError("user_not_found") + statement, _ = self._blog_select() + statement = statement.where( + BlogModel.crawl_status == CrawlStatus.FINISHED, + BlogModel.acceptance_status == BLOG_ACCEPTANCE_ACCEPTED, + ) + rows = session.execute(self._random_blog_catalog_statement(statement).limit(count)).all() + recommendation = RecommendationRequestModel( + request_uuid=request_uuid, + surface=RANDOM_RECOMMENDATION_SURFACE, + strategy=RANDOM_RECOMMENDATION_STRATEGY, + strategy_version=RANDOM_RECOMMENDATION_STRATEGY_VERSION, + visitor_id=clean_visitor_id, + user_id=user_id, + session_id=clean_session_id, + source=(source or "").strip() or None, + page_url=(page_url or "").strip() or None, + requested_count=count, + served_count=len(rows), + context_json=_coerce_json_object(context), + ) + session.add(recommendation) + session.flush() + items: list[dict[str, Any]] = [] + for position, row in enumerate(rows, start=1): + blog = row[0] + blog_id = _business_blog_id(blog) + impression = RecommendationImpressionModel( + request_id=recommendation.id, + blog_id=int(blog_id), + normalized_url=str(blog.normalized_url), + position=position, + score=None, + reason_json={"strategy": RANDOM_RECOMMENDATION_STRATEGY}, + ) + session.add(impression) + session.flush() + items.append( + self._row_blog_payload(row) + | { + "request_uuid": request_uuid, + "impression_id": impression.id, + "position": position, + } + ) + session.flush() + return { + "request_uuid": request_uuid, + "surface": RANDOM_RECOMMENDATION_SURFACE, + "strategy": RANDOM_RECOMMENDATION_STRATEGY, + "strategy_version": RANDOM_RECOMMENDATION_STRATEGY_VERSION, + "visitor_id": clean_visitor_id, + "session_id": clean_session_id, + "user_id": user_id, + "source": recommendation.source, + "page_url": recommendation.page_url, + "requested_count": count, + "served_count": len(items), + "created_at": _iso(recommendation.created_at), + "items": items, + } + + def _blog_interaction_payload(self, interaction: BlogInteractionModel) -> dict[str, Any]: + """Serialize one immutable blog interaction event row. + + Args: + interaction: Persisted interaction model. + + Returns: + JSON-ready event payload with attribution identifiers. + """ + + return { + "id": interaction.id, + "event_uuid": interaction.event_uuid, + "request_id": interaction.request_id, + "impression_id": interaction.impression_id, + "blog_id": interaction.blog_id, + "normalized_url": interaction.normalized_url, + "event_type": interaction.event_type, + "position": interaction.position, + "entrance_kind": interaction.entrance_kind, + "entrance_url": interaction.entrance_url, + "interaction_order": interaction.interaction_order, + "visitor_id": interaction.visitor_id, + "user_id": interaction.user_id, + "session_id": interaction.session_id, + "client_event_at": _iso(interaction.client_event_at), + "attributes": interaction.attributes_json, + "created_at": _iso(interaction.created_at), + } + + def record_blog_interaction( + self, + *, + event_uuid: str, + event_type: str, + blog_id: int, + visitor_id: str, + session_id: str, + entrance_kind: str, + entrance_url: str, + request_uuid: str | None = None, + impression_id: int | None = None, + position: int | None = None, + interaction_order: int = 1, + user_id: int | None = None, + client_event_at: str | datetime | None = None, + attributes: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Persist one idempotent recommendation interaction event. + + Args: + event_uuid: Client-generated idempotency key. + event_type: Interaction type such as ``detail_open``. + blog_id: Public/business blog ID receiving the event. + visitor_id: Stable anonymous visitor identifier. + session_id: Stable browser-session identifier. + entrance_kind: Stable entrance category such as ``random_blog_card``. + entrance_url: Raw URL for the entrance context. + request_uuid: Optional serving request UUID for attribution. + impression_id: Optional impression row ID for attribution. + position: Optional card position displayed to the visitor. + interaction_order: Monotonic client-side order within the session. + user_id: Optional authenticated user ID. + client_event_at: Optional client timestamp. + attributes: Optional JSON metadata for the event. + + Returns: + Serialized event payload plus a ``duplicate`` flag. + """ + + clean_event_uuid = _clean_event_text(event_uuid, field="event_uuid") + clean_event_type = _clean_event_text(event_type, field="event_type", max_length=64) + if clean_event_type not in RECOMMENDATION_EVENT_TYPES: + raise ValueError("unsupported_recommendation_event_type") + clean_visitor_id = _clean_event_text(visitor_id, field="visitor_id") + clean_session_id = _clean_event_text(session_id, field="session_id") + clean_entrance_kind = _clean_event_text(entrance_kind, field="entrance_kind", max_length=128) + clean_entrance_url = _clean_event_text(entrance_url, field="entrance_url", max_length=2048) + if interaction_order < 1: + raise ValueError("interaction_order_out_of_range") + with session_scope(self.session_factory) as session: + existing = session.scalar( + select(BlogInteractionModel).where(BlogInteractionModel.event_uuid == clean_event_uuid) + ) + if existing is not None: + return self._blog_interaction_payload(existing) | {"duplicate": True} + blog = self._get_blog_by_business_id(session, blog_id) + if blog is None: + raise BlogLabelingNotFoundError("blog_not_found") + if user_id is not None and session.scalar(select(UserModel.id).where(UserModel.id == user_id)) is None: + raise UserAuthError("user_not_found") + recommendation: RecommendationRequestModel | None = None + if request_uuid is not None: + recommendation = session.scalar( + select(RecommendationRequestModel).where( + RecommendationRequestModel.request_uuid == request_uuid + ) + ) + if recommendation is None: + raise ValueError("recommendation_request_not_found") + impression: RecommendationImpressionModel | None = None + if impression_id is not None: + impression = session.get(RecommendationImpressionModel, impression_id) + if impression is None: + raise ValueError("recommendation_impression_not_found") + if int(impression.blog_id) != int(blog_id): + raise ValueError("recommendation_impression_blog_mismatch") + if recommendation is not None and int(impression.request_id) != int(recommendation.id): + raise ValueError("recommendation_impression_request_mismatch") + if position is None: + position = int(impression.position) + interaction = BlogInteractionModel( + event_uuid=clean_event_uuid, + request_id=recommendation.id if recommendation is not None else None, + impression_id=impression.id if impression is not None else None, + blog_id=int(blog_id), + normalized_url=str(blog.normalized_url), + event_type=clean_event_type, + position=position, + entrance_kind=clean_entrance_kind, + entrance_url=clean_entrance_url, + interaction_order=interaction_order, + visitor_id=clean_visitor_id, + user_id=user_id, + session_id=clean_session_id, + client_event_at=_parse_event_datetime(client_event_at), + attributes_json=_coerce_json_object(attributes), + ) + session.add(interaction) + session.flush() + return self._blog_interaction_payload(interaction) | {"duplicate": False} + + def get_blog_recommendation_stats(self, blog_id: int) -> dict[str, Any] | None: + """Return recommendation exposure and interaction stats for one blog. + + Args: + blog_id: Public/business blog ID. + + Returns: + Stats payload, or ``None`` when the blog does not exist. + """ + + with session_scope(self.session_factory) as session: + blog = self._get_blog_by_business_id(session, blog_id) + if blog is None: + return None + impressions = int( + session.scalar( + select(func.count(RecommendationImpressionModel.id)).where( + RecommendationImpressionModel.blog_id == blog_id + ) + ) + or 0 + ) + event_counts = { + str(event_type): int(count or 0) + for event_type, count in session.execute( + select(BlogInteractionModel.event_type, func.count(BlogInteractionModel.id)) + .where(BlogInteractionModel.blog_id == blog_id) + .group_by(BlogInteractionModel.event_type) + ).all() + } + unique_visitors = int( + session.scalar( + select(func.count(func.distinct(BlogInteractionModel.visitor_id))).where( + BlogInteractionModel.blog_id == blog_id + ) + ) + or 0 + ) + last_interaction_at = session.scalar( + select(func.max(BlogInteractionModel.created_at)).where(BlogInteractionModel.blog_id == blog_id) + ) + clicks = int(event_counts.get("click", 0)) + detail_opens = int(event_counts.get("detail_open", 0)) + external_opens = int(event_counts.get("external_open", 0)) + label_selects = int(event_counts.get("label_select", 0)) + return { + "blog_id": blog_id, + "normalized_url": blog.normalized_url, + "impressions": impressions, + "clicks": clicks, + "detail_opens": detail_opens, + "external_opens": external_opens, + "label_selects": label_selects, + "unique_visitors": unique_visitors, + "ctr": (clicks + detail_opens + external_opens) / impressions if impressions else 0.0, + "last_interaction_at": _iso(last_interaction_at), + "by_event_type": event_counts, + } + + def get_recommendation_strategy_stats(self) -> dict[str, Any]: + """Return aggregate recommendation request, impression, and event stats. + + Args: + None. + + Returns: + Strategy-grouped aggregate stats for admin dashboards. + """ + + with session_scope(self.session_factory) as session: + total_requests = int(session.scalar(select(func.count(RecommendationRequestModel.id))) or 0) + total_impressions = int(session.scalar(select(func.count(RecommendationImpressionModel.id))) or 0) + total_interactions = int(session.scalar(select(func.count(BlogInteractionModel.id))) or 0) + click_counts = { + int(request_id): int(count or 0) + for request_id, count in session.execute( + select(BlogInteractionModel.request_id, func.count(BlogInteractionModel.id)) + .where( + BlogInteractionModel.request_id.is_not(None), + BlogInteractionModel.event_type.in_(("click", "detail_open", "external_open")), + ) + .group_by(BlogInteractionModel.request_id) + ).all() + } + grouped_rows = session.execute( + select( + RecommendationRequestModel.surface, + RecommendationRequestModel.strategy, + RecommendationRequestModel.strategy_version, + func.count(RecommendationRequestModel.id), + func.coalesce(func.sum(RecommendationRequestModel.served_count), 0), + func.count(func.distinct(RecommendationRequestModel.visitor_id)), + ).group_by( + RecommendationRequestModel.surface, + RecommendationRequestModel.strategy, + RecommendationRequestModel.strategy_version, + ) + ).all() + by_strategy: list[dict[str, Any]] = [] + for surface, strategy, strategy_version, request_count, served_count, visitor_count in grouped_rows: + request_ids = session.scalars( + select(RecommendationRequestModel.id).where( + RecommendationRequestModel.surface == surface, + RecommendationRequestModel.strategy == strategy, + RecommendationRequestModel.strategy_version == strategy_version, + ) + ).all() + clicks = sum(click_counts.get(int(request_id), 0) for request_id in request_ids) + impressions = int(served_count or 0) + by_strategy.append( + { + "surface": surface, + "strategy": strategy, + "strategy_version": strategy_version, + "requests": int(request_count or 0), + "impressions": impressions, + "clicks": clicks, + "unique_visitors": int(visitor_count or 0), + "ctr": clicks / impressions if impressions else 0.0, + } + ) + return { + "total_requests": total_requests, + "total_impressions": total_impressions, + "total_interactions": total_interactions, + "by_strategy": by_strategy, + } + def list_blog_labeling_candidates( self, *, @@ -4997,9 +5491,15 @@ def reset(self) -> dict[str, Any]: label_tags_preserved = _count_selectable_rows(session, BlogLabelTagModel) seeds_preserved = _count_selectable_rows(session, SeedModel) raw_urls_deleted = _count_selectable_rows(session, RawDiscoveredUrlModel) + recommendation_interactions_deleted = _count_selectable_rows(session, BlogInteractionModel) + recommendation_impressions_deleted = _count_selectable_rows(session, RecommendationImpressionModel) + recommendation_requests_deleted = _count_selectable_rows(session, RecommendationRequestModel) scan_items_deleted = _count_selectable_rows(session, BlogDedupScanRunItemModel) scan_runs_deleted = _count_selectable_rows(session, BlogDedupScanRunModel) session.query(SeedModel).update({SeedModel.blog_id: None}) + session.query(BlogInteractionModel).delete() + session.query(RecommendationImpressionModel).delete() + session.query(RecommendationRequestModel).delete() session.query(BlogDedupScanRunItemModel).delete() session.query(BlogDedupScanRunModel).delete() session.query(RawDiscoveredUrlModel).delete() @@ -5024,6 +5524,9 @@ def reset(self) -> dict[str, Any]: "blog_label_tags_preserved": label_tags_preserved, "seeds_preserved": seeds_preserved, "raw_discovered_urls_deleted": raw_urls_deleted, + "recommendation_interactions_deleted": recommendation_interactions_deleted, + "recommendation_impressions_deleted": recommendation_impressions_deleted, + "recommendation_requests_deleted": recommendation_requests_deleted, "blog_dedup_scan_items_deleted": scan_items_deleted, "blog_dedup_scan_runs_deleted": scan_runs_deleted, } diff --git a/shared/http_clients/persistence_http.py b/shared/http_clients/persistence_http.py index fd67961..df41c76 100644 --- a/shared/http_clients/persistence_http.py +++ b/shared/http_clients/persistence_http.py @@ -201,6 +201,129 @@ def list_seeds(self) -> list[dict[str, Any]]: return self._get("/internal/seeds") + def create_random_recommendation_batch( + self, + *, + count: int = 9, + visitor_id: str, + session_id: str, + user_id: int | None = None, + source: str | None = None, + page_url: str | None = None, + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Create and persist one random-blog recommendation batch. + + Args: + count: Number of random cards requested. + visitor_id: Stable anonymous visitor identifier. + session_id: Stable browser-session identifier. + user_id: Optional authenticated user ID. + source: Optional caller/source label. + page_url: Optional frontend page URL. + context: Optional JSON metadata. + + Returns: + Recommendation batch payload returned by persistence. + """ + + return self._post( + "/internal/recommendations/random-blog-batches", + { + "count": count, + "visitor_id": visitor_id, + "session_id": session_id, + "user_id": user_id, + "source": source, + "page_url": page_url, + "context": context, + }, + ) + + def record_blog_interaction( + self, + *, + event_uuid: str, + event_type: str, + blog_id: int, + visitor_id: str, + session_id: str, + entrance_kind: str, + entrance_url: str, + request_uuid: str | None = None, + impression_id: int | None = None, + position: int | None = None, + interaction_order: int = 1, + user_id: int | None = None, + client_event_at: str | None = None, + attributes: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Persist one random-blog recommendation interaction event. + + Args: + event_uuid: Client idempotency key. + event_type: Interaction type. + blog_id: Public/business blog ID. + visitor_id: Stable anonymous visitor identifier. + session_id: Stable browser-session identifier. + entrance_kind: Stable entrance category for the UI location. + entrance_url: Raw URL for the entrance context. + request_uuid: Optional recommendation request UUID. + impression_id: Optional impression ID. + position: Optional displayed card position. + interaction_order: Client-side event order. + user_id: Optional authenticated user ID. + client_event_at: Optional client timestamp. + attributes: Optional JSON metadata. + + Returns: + Interaction payload returned by persistence. + """ + + return self._post( + "/internal/recommendation-events", + { + "event_uuid": event_uuid, + "event_type": event_type, + "blog_id": blog_id, + "visitor_id": visitor_id, + "session_id": session_id, + "entrance_kind": entrance_kind, + "entrance_url": entrance_url, + "request_uuid": request_uuid, + "impression_id": impression_id, + "position": position, + "interaction_order": interaction_order, + "user_id": user_id, + "client_event_at": client_event_at, + "attributes": attributes, + }, + ) + + def get_blog_recommendation_stats(self, blog_id: int) -> dict[str, Any]: + """Load recommendation stats for one blog. + + Args: + blog_id: Public/business blog ID. + + Returns: + Stats payload returned by persistence. + """ + + return self._get(f"/internal/blogs/{blog_id}/recommendation-stats") + + def get_recommendation_strategy_stats(self) -> dict[str, Any]: + """Load aggregate recommendation strategy stats. + + Args: + None. + + Returns: + Aggregate stats payload returned by persistence. + """ + + return self._get("/internal/recommendation-stats") + def create_ingestion_request(self, *, homepage_url: str, email: str) -> dict[str, Any]: return self._post( "/internal/ingestion-requests", diff --git a/tests/test_repository.py b/tests/test_repository.py index a57ba4f..e894175 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -16,9 +16,12 @@ from persistence_api.db import session_scope from persistence_api.models import BlogLabelModel from persistence_api.models import BlogLabelTagModel +from persistence_api.models import BlogInteractionModel from persistence_api.models import BlogModel from persistence_api.models import IngestionRequestModel from persistence_api.models import RawDiscoveredUrlModel +from persistence_api.models import RecommendationImpressionModel +from persistence_api.models import RecommendationRequestModel from persistence_api.models import SeedModel from shared.contracts.enums import CrawlStatus from shared.config import Settings @@ -118,6 +121,9 @@ def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) - assert result["blog_label_tags_deleted"] == 0 assert result["blog_dedup_scan_items_deleted"] == 0 assert result["blog_dedup_scan_runs_deleted"] == 0 + assert result["recommendation_interactions_deleted"] == 0 + assert result["recommendation_impressions_deleted"] == 0 + assert result["recommendation_requests_deleted"] == 0 assert repository.list_blogs() == [] assert repository.list_edges() == [] assert repository.list_logs() == [] @@ -1436,6 +1442,84 @@ def test_repository_random_catalog_filters_admin_non_blog_and_saves_user_labels( assert raw_kept not in [item["id"] for item in admin_labeled["items"]] +def test_repository_persists_random_recommendation_batch_and_interaction_stats(tmp_path: Path) -> None: + """Random recommendation batches should persist request, impression, event, and stat rows.""" + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + for index in range(3): + blog_id, inserted = repository.upsert_blog( + url=f"https://recommend-{index}.example/", + normalized_url=f"https://recommend-{index}.example/", + domain=f"recommend-{index}.example", + accepted_by="rss", + ) + assert inserted is True + repository.mark_blog_result( + blog_id=blog_id, + crawl_status="FINISHED", + status_code=200, + friend_links_count=index, + metadata_captured=True, + title=f"Recommend {index}", + icon_url=None, + ) + + batch = repository.create_random_recommendation_batch( + count=2, + visitor_id="visitor-1", + session_id="session-1", + source="test", + page_url="http://localhost/random", + ) + + assert batch["requested_count"] == 2 + assert batch["served_count"] == 2 + assert [item["position"] for item in batch["items"]] == [1, 2] + first = batch["items"][0] + event = repository.record_blog_interaction( + event_uuid="event-1", + event_type="detail_open", + blog_id=first["id"], + visitor_id="visitor-1", + session_id="session-1", + entrance_kind="test_detail", + entrance_url="http://localhost/random", + request_uuid=first["request_uuid"], + impression_id=first["impression_id"], + interaction_order=1, + client_event_at="2026-06-07T12:00:00Z", + attributes={"button": "detail"}, + ) + duplicate = repository.record_blog_interaction( + event_uuid="event-1", + event_type="detail_open", + blog_id=first["id"], + visitor_id="visitor-1", + session_id="session-1", + entrance_kind="test_detail", + entrance_url="http://localhost/random", + ) + stats = repository.get_blog_recommendation_stats(first["id"]) + strategy_stats = repository.get_recommendation_strategy_stats() + + assert event["duplicate"] is False + assert event["entrance_kind"] == "test_detail" + assert event["entrance_url"] == "http://localhost/random" + assert duplicate["duplicate"] is True + assert stats is not None + assert stats["impressions"] == 1 + assert stats["detail_opens"] == 1 + assert stats["unique_visitors"] == 1 + assert stats["ctr"] == 1.0 + assert strategy_stats["total_requests"] == 1 + assert strategy_stats["total_impressions"] == 2 + assert strategy_stats["total_interactions"] == 1 + assert strategy_stats["by_strategy"][0]["clicks"] == 1 + with session_scope(repository.session_factory) as session: + assert session.scalar(select(RecommendationRequestModel).limit(1)) is not None + assert session.scalar(select(RecommendationImpressionModel).limit(1)) is not None + assert session.scalar(select(BlogInteractionModel).limit(1)) is not None + + def test_repository_blog_catalog_uses_display_identity_fallbacks_for_legacy_rows(tmp_path: Path) -> None: """Catalog should keep title fallback but not synthesize unverified icons.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") diff --git a/tests/test_service_split.py b/tests/test_service_split.py index 12cd068..ca8e917 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -947,6 +947,178 @@ def post(self, path: str, json: dict[str, object], **kwargs: object) -> StubResp assert ("/internal/users/7/label-stats", None) in stub.get_calls +def test_persistence_http_client_can_manage_recommendation_data() -> None: + """The split-service HTTP client should expose recommendation data helpers.""" + + class StubResponse: + def __init__(self, payload: object) -> None: + self.payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> object: + return self.payload + + class StubClient: + def __init__(self) -> None: + self.get_calls: list[tuple[str, dict[str, object] | None]] = [] + self.post_calls: list[tuple[str, dict[str, object]]] = [] + + def get(self, path: str, params: dict[str, object] | None = None, **kwargs: object) -> StubResponse: + del kwargs + self.get_calls.append((path, params)) + return StubResponse({"ok": True}) + + def post(self, path: str, json: dict[str, object], **kwargs: object) -> StubResponse: + del kwargs + self.post_calls.append((path, json)) + return StubResponse({"ok": True, "items": []}) + + client = PersistenceHttpClient("http://persistence.internal") + stub = StubClient() + client.client = stub # type: ignore[assignment] + + client.create_random_recommendation_batch( + count=9, + visitor_id="visitor-1", + session_id="session-1", + source="random_page", + ) + client.record_blog_interaction( + event_uuid="event-1", + event_type="detail_open", + blog_id=42, + visitor_id="visitor-1", + session_id="session-1", + entrance_kind="test_detail", + entrance_url="http://localhost/random", + request_uuid="request-1", + impression_id=12, + position=1, + ) + assert client.get_blog_recommendation_stats(42) == {"ok": True} + assert client.get_recommendation_strategy_stats() == {"ok": True} + + assert stub.post_calls == [ + ( + "/internal/recommendations/random-blog-batches", + { + "count": 9, + "visitor_id": "visitor-1", + "session_id": "session-1", + "user_id": None, + "source": "random_page", + "page_url": None, + "context": None, + }, + ), + ( + "/internal/recommendation-events", + { + "event_uuid": "event-1", + "event_type": "detail_open", + "blog_id": 42, + "visitor_id": "visitor-1", + "session_id": "session-1", + "entrance_kind": "test_detail", + "entrance_url": "http://localhost/random", + "request_uuid": "request-1", + "impression_id": 12, + "position": 1, + "interaction_order": 1, + "user_id": None, + "client_event_at": None, + "attributes": None, + }, + ), + ] + assert stub.get_calls == [ + ("/internal/blogs/42/recommendation-stats", None), + ("/internal/recommendation-stats", None), + ] + + +def test_backend_routes_forward_recommendation_data_with_optional_user() -> None: + """Backend public recommendation routes should preserve attribution fields.""" + + class RecommendationPersistenceStub: + def __init__(self) -> None: + self.batch_payload: dict[str, object] | None = None + self.event_payload: dict[str, object] | None = None + + def get_user_by_session_token(self, *, token: str) -> dict[str, object] | None: + assert token == "session-token" + return {"id": 7, "email": "user@example.com"} + + def create_random_recommendation_batch(self, **kwargs: object) -> dict[str, object]: + self.batch_payload = kwargs + return {"request_uuid": "request-1", "items": []} + + def record_blog_interaction(self, **kwargs: object) -> dict[str, object]: + self.event_payload = kwargs + return {"event_uuid": kwargs["event_uuid"], "duplicate": False} + + def get_blog_recommendation_stats(self, blog_id: int) -> dict[str, object]: + return {"blog_id": blog_id, "impressions": 1} + + def get_recommendation_strategy_stats(self) -> dict[str, object]: + return {"total_requests": 1, "by_strategy": []} + + persistence = RecommendationPersistenceStub() + app = create_backend_app( + BackendState( + persistence=persistence, + crawler=StubCrawler(), + search=StubSearch(), + admin_token="secret-token", + ) + ) + client = TestClient(app) + + batch_response = client.post( + "/api/recommendations/random-blog-batches", + headers={"authorization": "Bearer session-token"}, + json={ + "count": 9, + "visitor_id": "visitor-1", + "session_id": "session-1", + "source": "random_page", + }, + ) + event_response = client.post( + "/api/recommendation-events", + headers={"authorization": "Bearer session-token"}, + json={ + "event_uuid": "event-1", + "event_type": "detail_open", + "blog_id": 42, + "visitor_id": "visitor-1", + "session_id": "session-1", + "entrance_kind": "test_detail", + "entrance_url": "http://localhost/random", + "request_uuid": "request-1", + "impression_id": 12, + "position": 1, + }, + ) + blog_stats = client.get("/api/blogs/42/stats") + admin_stats = client.get("/api/admin/recommendation-stats", headers=admin_headers()) + + assert batch_response.status_code == 200 + assert event_response.status_code == 200 + assert blog_stats.json() == {"blog_id": 42, "impressions": 1} + assert admin_stats.json() == {"total_requests": 1, "by_strategy": []} + assert persistence.batch_payload is not None + assert persistence.batch_payload["user_id"] == 7 + assert persistence.batch_payload["visitor_id"] == "visitor-1" + assert persistence.event_payload is not None + assert persistence.event_payload["user_id"] == 7 + assert persistence.event_payload["event_type"] == "detail_open" + assert persistence.event_payload["entrance_kind"] == "test_detail" + assert persistence.event_payload["entrance_url"] == "http://localhost/random" + + def test_settings_can_enable_postgres_runtime(tmp_path: Path, monkeypatch) -> None: """Environment loading should allow the split runtime to point at Postgres.""" monkeypatch.setenv("HEYBLOG_DB_DSN", "postgresql://heyblog:heyblog@persistence-db:5432/heyblog") From b9b51b7d1ec068cc512bc1d51e64a850eb7a8939 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 16:28:48 +0100 Subject: [PATCH 21/35] =?UTF-8?q?=F0=9F=8E=88=20perf:=20=E7=8E=B0=E5=9C=A8?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E5=8D=9A=E5=AE=A2=E7=95=8C=E9=9D=A2=E6=89=93?= =?UTF-8?q?=E5=BC=80=E5=8D=9A=E5=AE=A2=E8=AF=A6=E6=83=85=E4=BC=9A=E5=8F=A6?= =?UTF-8?q?=E5=BC=80=E4=B8=80=E4=B8=AA=E6=A0=87=E7=AD=BE=E9=A1=B5=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.test.tsx | 11 ++++++----- frontend/src/components/BlogDetailLink.tsx | 5 ++++- frontend/src/lib/blogInteractions.ts | 9 ++++++++- frontend/src/pages/RandomBlogPage.tsx | 1 + 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 652d2ac..8726b98 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -758,8 +758,10 @@ test("adds a random blog route that loads nine finished cards and refreshes them }); }); -test("lets random blog users open one blog detail route", async () => { +test("lets random blog users open one blog detail route in a new tab", async () => { window.history.replaceState({}, "", "/random"); + const openMock = vi.fn(); + vi.stubGlobal("open", openMock); render(); @@ -769,9 +771,6 @@ test("lets random blog users open one blog detail route", async () => { fireEvent.click(screen.getAllByRole("button", { name: "查看详情" })[0]); - await waitFor(() => { - expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/api/blogs/32"), expect.anything()); - }); await waitFor(() => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining("/api/recommendation-events"), @@ -791,7 +790,9 @@ test("lets random blog users open one blog detail route", async () => { String(init?.body).includes('"entrance_url"'), ), ).toBe(true); - expect(await screen.findByRole("heading", { name: "Extra Blog 32" })).toBeInTheDocument(); + expect(openMock).toHaveBeenCalledWith("/blogs/32", "_blank", "noopener,noreferrer"); + expect(window.location.pathname).toBe("/random"); + expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining("/api/blogs/32"), expect.anything()); }); test("records random blog external URL opens as recommendation interactions", async () => { diff --git a/frontend/src/components/BlogDetailLink.tsx b/frontend/src/components/BlogDetailLink.tsx index be67d30..75a3f0e 100644 --- a/frontend/src/components/BlogDetailLink.tsx +++ b/frontend/src/components/BlogDetailLink.tsx @@ -9,6 +9,7 @@ interface BlogDetailLinkProps extends Omit; + openInNewTab?: boolean; } /** @@ -19,6 +20,7 @@ interface BlogDetailLinkProps extends Omit { - openTrackedBlogDetail(navigate, blog, entrance, eventAttributes); + openTrackedBlogDetail(navigate, blog, entrance, eventAttributes, { newTab: openInNewTab }); }} > {children} diff --git a/frontend/src/lib/blogInteractions.ts b/frontend/src/lib/blogInteractions.ts index 086208f..4a07bf6 100644 --- a/frontend/src/lib/blogInteractions.ts +++ b/frontend/src/lib/blogInteractions.ts @@ -121,13 +121,20 @@ export function recordBlogInteraction( * @param blog Blog target whose detail route should open. * @param entrance Required entry-point metadata for later aggregation. * @param attributes Optional event metadata. + * @param options Optional browser navigation behavior. */ export function openTrackedBlogDetail( navigate: NavigateFunction, blog: BlogCatalogItem | GraphNode, entrance: BlogInteractionEntrance, attributes?: Record, + options?: { newTab?: boolean }, ) { recordBlogInteraction(blogInteractionTarget(blog), "detail_open", entrance, attributes); - navigate(`/blogs/${blog.id}`); + const detailPath = `/blogs/${blog.id}`; + if (options?.newTab) { + window.open(detailPath, "_blank", "noopener,noreferrer"); + return; + } + navigate(detailPath); } diff --git a/frontend/src/pages/RandomBlogPage.tsx b/frontend/src/pages/RandomBlogPage.tsx index 01dfd7b..40a0567 100644 --- a/frontend/src/pages/RandomBlogPage.tsx +++ b/frontend/src/pages/RandomBlogPage.tsx @@ -166,6 +166,7 @@ export function RandomBlogPage() { blog={blog} entranceKind={RANDOM_PAGE_ENTRANCE_KIND} entranceUrl={window.location.href} + openInNewTab className="mb-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border border-slate-200 bg-slate-950 px-3 text-sm text-white transition-colors hover:bg-slate-800" > From 2aaa8dab658af7f7fd7f9fa48aae9bf72c899020 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 16:36:37 +0100 Subject: [PATCH 22/35] =?UTF-8?q?=F0=9F=8E=88=20perf:=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E5=8D=9A=E5=AE=A2=E6=9D=83=E9=87=8D=E4=B8=BA?= =?UTF-8?q?=E4=B8=8D=E8=80=83=E8=99=91blog=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/api-docs.md | 2 +- persistence_api/repository.py | 4 +-- tests/test_repository.py | 58 +++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/doc/api-docs.md b/doc/api-docs.md index 70a5e3a..11c546e 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -688,7 +688,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - `label` 只接受随机博客页使用的四类标签:`blog`、`company`、`other`、`unknown` - `previous_label` 可选;用于随机博客页内的单 URL 单选择切换。若传入且与 `label` 不同,服务端会先把旧 label 计数减 `1`,再把新 label 计数加 `1` - 前端同一张 URL 卡片重复点击已选中的 label 不会再次请求接口,也不会重复累加计数 -- 随机博客加权时,所有 URL 默认权重为 `10`;设用户表中 `blog` 计数为 `x`、非 `blog` 计数为 `y`,权重为 `(10 + x) / (1 + y)`,最高不超过 `10` +- 随机博客加权时,所有 URL 默认权重为 `10`;设用户表中非 `blog` 计数为 `y`,权重为 `10 / (1 + y)`;`blog` 正反馈不再提升随机权重 - 权重只影响 `sort=random` 的随机排序;管理员训练标签只负责过滤非 blog,不会被用户标注改变 错误语义: diff --git a/persistence_api/repository.py b/persistence_api/repository.py index 4a8604e..eb1f34c 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -2397,10 +2397,8 @@ def _random_blog_catalog_statement(self, statement: Any) -> Any: """ admin_non_blog = _non_blog_label_count_expr(BlogLabelModel.label_id) - user_blog_count = _json_label_count_expr(BlogUserLabelModel.label_id, BLOG_LABEL_BLOG_ID) user_non_blog_count = _non_blog_label_count_expr(BlogUserLabelModel.label_id) - raw_weight = cast(10 + user_blog_count, Float) / cast(1 + user_non_blog_count, Float) - random_weight = case((raw_weight > 10, 10.0), else_=raw_weight) + random_weight = cast(10, Float) / cast(1 + user_non_blog_count, Float) return ( statement.outerjoin(BlogLabelModel, BlogLabelModel.normalized_url == BlogModel.normalized_url) .outerjoin(BlogUserLabelModel, BlogUserLabelModel.normalized_url == BlogModel.normalized_url) diff --git a/tests/test_repository.py b/tests/test_repository.py index e894175..fcb5733 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1442,6 +1442,64 @@ def test_repository_random_catalog_filters_admin_non_blog_and_saves_user_labels( assert raw_kept not in [item["id"] for item in admin_labeled["items"]] +def test_repository_random_catalog_only_demotes_user_non_blog_feedback(tmp_path: Path) -> None: + """Random catalog weighting should ignore blog votes and demote non-blog votes.""" + repository = repository_module.build_repository(db_path=tmp_path / "heyblog.sqlite") + repository.create_blog_label_tag(name="blog") + repository.create_blog_label_tag(name="other") + + if repository.engine.dialect.name == "sqlite": + def fixed_random(dbapi_connection: object, _connection_record: object) -> None: + dbapi_connection.create_function("random", 0, lambda: 1) + + event.listen(repository.engine, "connect", fixed_random) + repository.engine.dispose() + + boosted_id, boosted_inserted = repository.upsert_blog( + url="https://boosted.example/", + normalized_url="https://boosted.example/", + domain="boosted.example", + ) + baseline_id, baseline_inserted = repository.upsert_blog( + url="https://baseline.example/", + normalized_url="https://baseline.example/", + domain="baseline.example", + ) + demoted_id, demoted_inserted = repository.upsert_blog( + url="https://demoted.example/", + normalized_url="https://demoted.example/", + domain="demoted.example", + ) + assert boosted_inserted is True + assert baseline_inserted is True + assert demoted_inserted is True + for blog_id, title in ( + (boosted_id, "Boosted"), + (baseline_id, "Baseline"), + (demoted_id, "Demoted"), + ): + repository.mark_blog_result( + blog_id=blog_id, + crawl_status="FINISHED", + status_code=200, + friend_links_count=1, + metadata_captured=True, + title=title, + icon_url=None, + ) + + repository.increment_blog_user_label(blog_id=boosted_id, label="blog") + repository.increment_blog_user_label(blog_id=demoted_id, label="other") + + random_page = repository.list_blogs_catalog(status="finished", sort="random", page_size=10) + + assert [item["url"] for item in random_page["items"]] == [ + "https://baseline.example/", + "https://boosted.example/", + "https://demoted.example/", + ] + + def test_repository_persists_random_recommendation_batch_and_interaction_stats(tmp_path: Path) -> None: """Random recommendation batches should persist request, impression, event, and stat rows.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") From 242d5b75b3004f6251fe3b0c1daa188edf94d66f Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 17:10:31 +0100 Subject: [PATCH 23/35] =?UTF-8?q?=F0=9F=90=B3=20chore:=20=E4=BF=AE?= =?UTF-8?q?=E6=94=B9seed.csv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- seed.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seed.csv b/seed.csv index 804edf2..4fb78b1 100644 --- a/seed.csv +++ b/seed.csv @@ -1,3 +1,3 @@ url https://www.qladgk.com/ -https://baka.fun/ +https://moondvsted.space/ From adfcc441eb029f7984e18f473f1d5d420b0c1967 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 17:19:00 +0100 Subject: [PATCH 24/35] =?UTF-8?q?=F0=9F=A6=84=20refactor:=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=E6=8E=89=E6=97=A9=E6=9C=9F=E7=9A=84=E2=80=9C=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E6=B7=BB=E5=8A=A0=E5=8D=9A=E5=AE=A2=E2=80=9D=E2=80=9C?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E8=BF=87=E6=BB=A4=E9=93=BE=E2=80=9D=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E4=BB=A3=E7=A0=81=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0260423_02_add_blog_business_key_schema.py | 3 - ...p_deprecated_ingestion_and_dedup_tables.py | 129 +++ backend/main.py | 183 ---- crawler/README.md | 2 +- crawler/crawling/pipeline.py | 70 +- crawler/main.py | 1 - crawler/runtime/service.py | 56 +- doc/api-docs.md | 290 +----- doc/crawler-url-filtering.md | 10 +- doc/public-admin-boundary.md | 10 +- doc/service-architecture.md | 3 +- frontend/src/components/SubmitBlogDialog.tsx | 26 +- frontend/src/lib/api.ts | 69 -- frontend/src/pages/AdminPage.tsx | 26 +- frontend/src/types/graph.ts | 11 - persistence_api/main.py | 81 +- persistence_api/models.py | 73 -- persistence_api/repository.py | 852 +----------------- shared/config.py | 11 - shared/http_clients/persistence_http.py | 75 +- tests/test_repository.py | 478 ---------- tests/test_runtime.py | 59 +- tests/test_service_split.py | 435 +-------- 23 files changed, 201 insertions(+), 2752 deletions(-) create mode 100644 alembic/versions/20260607_03_drop_deprecated_ingestion_and_dedup_tables.py diff --git a/alembic/versions/20260423_02_add_blog_business_key_schema.py b/alembic/versions/20260423_02_add_blog_business_key_schema.py index 6fa077b..c002400 100644 --- a/alembic/versions/20260423_02_add_blog_business_key_schema.py +++ b/alembic/versions/20260423_02_add_blog_business_key_schema.py @@ -20,10 +20,7 @@ BLOG_FK_REWRITES = ( ("edges", "edges_from_blog_id_fkey", "from_blog_id", "CASCADE"), ("edges", "edges_to_blog_id_fkey", "to_blog_id", "CASCADE"), - ("ingestion_requests", "ingestion_requests_seed_blog_id_fkey", "seed_blog_id", "SET NULL"), - ("ingestion_requests", "ingestion_requests_matched_blog_id_fkey", "matched_blog_id", "SET NULL"), ("blog_label_assignments", "blog_label_assignments_blog_id_fkey", "blog_id", "CASCADE"), - ("blog_dedup_scan_run_items", "blog_dedup_scan_run_items_survivor_blog_id_fkey", "survivor_blog_id", "SET NULL"), ) diff --git a/alembic/versions/20260607_03_drop_deprecated_ingestion_and_dedup_tables.py b/alembic/versions/20260607_03_drop_deprecated_ingestion_and_dedup_tables.py new file mode 100644 index 0000000..3ac83f6 --- /dev/null +++ b/alembic/versions/20260607_03_drop_deprecated_ingestion_and_dedup_tables.py @@ -0,0 +1,129 @@ +"""Drop deprecated ingestion request and blog dedup scan tables. + +Revision ID: 20260607_03 +Revises: 20260607_02 +Create Date: 2026-06-07 16:30:00 BST +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260607_03" +down_revision = "20260607_02" +branch_labels = None +depends_on = None + + +def _table_names() -> set[str]: + """Return the current database table names. + + Args: + None. + + Returns: + Set of table names visible to the active migration connection. + """ + + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def upgrade() -> None: + """Remove persistence tables for deprecated ingestion and dedup scan features. + + Args: + None. + + Returns: + None. Existing deprecated tables are dropped when present. + """ + + tables = _table_names() + for table_name in ( + "blog_dedup_scan_run_items", + "blog_dedup_scan_runs", + "ingestion_requests", + ): + if table_name in tables: + op.drop_table(table_name) + + +def downgrade() -> None: + """Recreate the deprecated tables with their final historical schema. + + Args: + None. + + Returns: + None. The removed tables are recreated for migration rollback only. + """ + + tables = _table_names() + if "ingestion_requests" not in tables: + op.create_table( + "ingestion_requests", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("requested_url", sa.Text(), nullable=False), + sa.Column("normalized_url", sa.Text(), nullable=False), + sa.Column("identity_key", sa.Text(), nullable=True), + sa.Column("identity_reason_codes", sa.Text(), nullable=True), + sa.Column("identity_ruleset_version", sa.Text(), nullable=True), + sa.Column("requester_email", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("priority", sa.Integer(), nullable=False, server_default="100"), + sa.Column("seed_blog_id", sa.Integer(), nullable=True), + sa.Column("matched_blog_id", sa.Integer(), nullable=True), + sa.Column("request_token", sa.Text(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["seed_blog_id"], ["blogs.blog_id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["matched_blog_id"], ["blogs.blog_id"], ondelete="SET NULL"), + ) + op.create_index("ix_ingestion_requests_identity_key", "ingestion_requests", ["identity_key"]) + op.create_index("ix_ingestion_requests_status", "ingestion_requests", ["status"]) + op.create_index("ix_ingestion_requests_seed_blog_id", "ingestion_requests", ["seed_blog_id"]) + op.create_index("ix_ingestion_requests_matched_blog_id", "ingestion_requests", ["matched_blog_id"]) + + if "blog_dedup_scan_runs" not in tables: + op.create_table( + "blog_dedup_scan_runs", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("ruleset_version", sa.Text(), nullable=False), + sa.Column("total_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("scanned_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("removed_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("kept_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("crawler_was_running", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("crawler_restart_attempted", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("crawler_restart_succeeded", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("search_reindexed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("duration_ms", sa.Integer(), nullable=True), + ) + op.create_index("ix_blog_dedup_scan_runs_status", "blog_dedup_scan_runs", ["status"]) + + if "blog_dedup_scan_run_items" not in tables: + op.create_table( + "blog_dedup_scan_run_items", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("run_id", sa.Integer(), nullable=False), + sa.Column("survivor_blog_id", sa.Integer(), nullable=True), + sa.Column("removed_blog_id", sa.Integer(), nullable=True), + sa.Column("survivor_identity_key", sa.Text(), nullable=True), + sa.Column("removed_identity_key", sa.Text(), nullable=True), + sa.Column("removed_url", sa.Text(), nullable=False), + sa.Column("reason_code", sa.Text(), nullable=False), + sa.Column("reason_codes", sa.Text(), nullable=True), + sa.Column("survivor_selection_basis", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["run_id"], ["blog_dedup_scan_runs.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["survivor_blog_id"], ["blogs.blog_id"], ondelete="SET NULL"), + ) + op.create_index("ix_blog_dedup_scan_run_items_run_id", "blog_dedup_scan_run_items", ["run_id"]) diff --git a/backend/main.py b/backend/main.py index bc6c078..93398f8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -48,11 +48,6 @@ class RunBatchRequest(BaseModel): max_nodes: int -class CreateIngestionRequest(BaseModel): - homepage_url: str - email: str - - class CreateUserSeedRequest(BaseModel): homepage_url: str @@ -406,98 +401,6 @@ def build_backend_state(settings: Settings | None = None) -> BackendState: ) -def _execute_blog_dedup_scan_in_background( - state: BackendState, - *, - run_id: int, - crawler_was_running: bool, -) -> None: - restart_attempted = False - restart_succeeded = False - search_reindexed = False - error_message: str | None = None - try: - state.persistence.execute_blog_dedup_scan_run(run_id=run_id) - search_reindexed = _best_effort_search_reindex(state.search) - except httpx.HTTPStatusError as exc: - error_message = str(_upstream_error_detail(exc)) - except Exception as exc: # noqa: BLE001 - error_message = str(exc) - finally: - if crawler_was_running: - restart_attempted = True - try: - state.crawler.start() - restart_succeeded = True - except Exception: # noqa: BLE001 - restart_succeeded = False - try: - state.persistence.finalize_blog_dedup_scan_run( - run_id=run_id, - crawler_restart_attempted=restart_attempted, - crawler_restart_succeeded=restart_succeeded, - search_reindexed=search_reindexed, - error_message=error_message, - ) - except Exception: # noqa: BLE001 - pass - state.maintenance_in_progress = False - - -def _start_maintenance_background_task( - state: BackendState, - *, - prepare_run: Callable[[bool], tuple[dict[str, Any], dict[str, Any]]], - on_http_error: Callable[[httpx.HTTPStatusError], NoReturn], - on_http_exception: Callable[[HTTPException], NoReturn], - on_unexpected_error: Callable[[Exception], NoReturn], - target: Callable[..., None], -) -> dict[str, Any]: - """Start one maintenance-mode background task with shared exception handling.""" - crawler_was_running = _enter_maintenance(state) - try: - payload, thread_kwargs = prepare_run(crawler_was_running) - except httpx.HTTPStatusError as exc: - on_http_error(exc) - except HTTPException as exc: - on_http_exception(exc) - except Exception as exc: # noqa: BLE001 - on_unexpected_error(exc) - Thread( - target=target, - kwargs={"state": state, **thread_kwargs}, - daemon=True, - ).start() - return payload - - -def _build_maintenance_start_error_handlers( - *, - cleanup: Callable[[str], None], - unexpected_detail: str, -) -> tuple[ - Callable[[httpx.HTTPStatusError], NoReturn], - Callable[[HTTPException], NoReturn], - Callable[[Exception], NoReturn], -]: - """Build the shared error-handler skeleton for maintenance start routes.""" - - def on_http_error(exc: httpx.HTTPStatusError) -> NoReturn: - detail = _upstream_error_detail(exc) - cleanup(str(detail)) - _raise_upstream_http_error(exc, detail_override=detail) - - def on_http_exception(exc: HTTPException) -> NoReturn: - cleanup(str(exc.detail)) - raise exc - - def on_unexpected_error(exc: Exception) -> NoReturn: - cleanup(str(exc)) - raise HTTPException(status_code=500, detail=unexpected_detail) from exc - - return on_http_error, on_http_exception, on_unexpected_error - - def create_app(state: BackendState | None = None) -> FastAPI: """Create the public backend app.""" settings = Settings.from_env() @@ -897,21 +800,6 @@ def run_crawl(max_nodes: int | None = None, _: None = Depends(require_admin_acce lambda: state.crawler.run(max_nodes=max_nodes), ) - @app.post("/api/ingestion-requests") - def create_ingestion_request(payload: CreateIngestionRequest) -> dict[str, Any]: - result = _call_upstream_with_http_error_translation( - lambda: get_state().persistence.create_ingestion_request(**payload.model_dump()) - ) - log_event( - LOGGER, - event="ingestion.request.created", - message="ingestion request created", - stage="ingestion", - run_id=result.get("request_id"), - url=payload.homepage_url, - ) - return result - @app.post("/api/blogs/user-seeds") def create_user_seed(payload: CreateUserSeedRequest) -> dict[str, Any]: result = _call_upstream_with_http_error_translation( @@ -927,77 +815,6 @@ def create_user_seed(payload: CreateUserSeedRequest) -> dict[str, Any]: ) return result - @app.get("/api/ingestion-requests") - def list_priority_ingestion_requests() -> list[dict[str, Any]]: - return _call_upstream_with_http_error_translation( - lambda: get_state().persistence.list_priority_ingestion_requests() - ) - - @app.get("/api/ingestion-requests/{request_id}") - def get_ingestion_request(request_id: int, request_token: str) -> dict[str, Any]: - return _call_upstream_with_http_error_translation( - lambda: get_state().persistence.get_ingestion_request( - request_id=request_id, - request_token=request_token, - ) - ) - - @app.post("/api/admin/blog-dedup-scans") - def run_blog_dedup_scan(_: None = Depends(require_admin_access)) -> dict[str, Any]: - state = get_state() - - def prepare_run(crawler_was_running: bool) -> tuple[dict[str, Any], dict[str, Any]]: - _stop_active_crawler( - state, - crawler_was_running=crawler_was_running, - wait_for_idle=ensure_runtime_idle, - ) - payload = state.persistence.create_blog_dedup_scan_run(crawler_was_running=crawler_was_running) - log_event( - LOGGER, - event="maintenance.blog_dedup.started", - message="blog dedup scan started", - stage="blog_dedup", - run_id=int(payload["id"]), - crawler_was_running=crawler_was_running, - ) - return payload, { - "run_id": int(payload["id"]), - "crawler_was_running": crawler_was_running, - } - - def cleanup(_: str) -> None: - _leave_maintenance(state) - - on_http_error, on_http_exception, on_unexpected_error = _build_maintenance_start_error_handlers( - cleanup=cleanup, - unexpected_detail="blog_dedup_scan_failed", - ) - - return _start_maintenance_background_task( - state, - prepare_run=prepare_run, - on_http_error=on_http_error, - on_http_exception=on_http_exception, - on_unexpected_error=on_unexpected_error, - target=_execute_blog_dedup_scan_in_background, - ) - - @app.get("/api/admin/blog-dedup-scans/latest") - def get_latest_blog_dedup_scan_run(_: None = Depends(require_admin_access)) -> dict[str, Any]: - return _call_upstream_with_http_error_translation( - lambda: get_state().persistence.latest_blog_dedup_scan_run() - ) - - @app.get("/api/admin/blog-dedup-scans/{run_id}/items") - def get_blog_dedup_scan_run_items( - run_id: int, - _: None = Depends(require_admin_access), - ) -> list[dict[str, Any]]: - return _call_upstream_with_http_error_translation( - lambda: get_state().persistence.list_blog_dedup_scan_run_items(run_id) - ) - @app.get("/api/admin/runtime/status") def runtime_status(_: None = Depends(require_admin_access)) -> dict[str, Any]: payload = get_state().crawler.runtime_status() diff --git a/crawler/README.md b/crawler/README.md index ab71788..f9efc20 100644 --- a/crawler/README.md +++ b/crawler/README.md @@ -325,7 +325,7 @@ crawler/ - 启动 / 停止后台抓取线程 - 维护多个 worker 的运行状态快照 -- 控制 priority queue 和 normal queue 的公平 claim +- 按等待队列领取并分发待抓取 blog - 聚合本次 runtime 的 processed / discovered / failed - 对外提供 `/internal/runtime/*` 需要的状态 diff --git a/crawler/crawling/pipeline.py b/crawler/crawling/pipeline.py index ddef372..f65ca62 100644 --- a/crawler/crawling/pipeline.py +++ b/crawler/crawling/pipeline.py @@ -133,7 +133,6 @@ def run_once( """ stats = CrawlRunStats() limit = max_nodes or self.settings.max_nodes_per_run - normal_slots_remaining = 0 stop_reason: str | None = None while stats.processed < limit: @@ -144,11 +143,7 @@ def run_once( if not capacity.allowed: stop_reason = capacity.reason break - # Claiming and processing stay in one loop so the batch result - # always reflects the same queue-fairness rules as runtime mode. - row, _claimed_priority, normal_slots_remaining = self._claim_next_scheduled_blog( - normal_slots_remaining=normal_slots_remaining - ) + row = self._get_next_waiting_blog() if row is None: break result = self.process_blog_row( @@ -195,10 +190,6 @@ def process_blog_row( failed. """ blog = BlogNode.from_row(row) - if hasattr(self.repository, "mark_ingestion_request_crawling"): - # Priority ingestion requests need a state transition before the - # actual crawl starts so UI callers can observe progress promptly. - self.repository.mark_ingestion_request_crawling(blog_id=blog.id) if on_blog_start is not None: on_blog_start(blog.callback_payload()) try: @@ -220,69 +211,14 @@ def write_exports(self) -> dict[str, Any]: """ return self.export_service.write_exports() - def _claim_next_scheduled_blog(self, *, normal_slots_remaining: int) -> tuple[dict[str, Any] | None, bool, int]: - """Claim the next eligible blog while enforcing priority fairness. - - Args: - normal_slots_remaining: Remaining count in the current fairness - window that allows normal-queue blogs after a priority claim. - - Returns: - A tuple of ``(row, claimed_priority, next_normal_slots_remaining)`` - describing the claimed blog, whether it came from the priority - queue, and the updated fairness-window counter. - """ - priority_slots = max(1, self.settings.priority_seed_normal_queue_slots) - if normal_slots_remaining <= 0: - # A priority seed wins immediately when its turn comes up. - row = self._get_next_priority_blog() - if row is not None: - return row, True, priority_slots - - include_priority = normal_slots_remaining <= 0 - row = self._get_next_waiting_blog(include_priority=include_priority) - if row is not None: - # After one priority seed is claimed, let a bounded number of normal - # queue items run before checking the priority queue again. - next_remaining = max(0, normal_slots_remaining - 1) if normal_slots_remaining > 0 else 0 - return row, False, next_remaining - - if normal_slots_remaining > 0: - # If the normal queue is empty during a fairness window, do not make - # the priority seed wait for the remaining normal slots to expire. - row = self._get_next_priority_blog() - if row is not None: - return row, True, priority_slots - return None, False, 0 - - def _get_next_priority_blog(self) -> dict[str, Any] | None: - """Return the next priority blog row if the repository supports it. - - Returns: - The claimed priority blog row, or ``None`` when no priority queue is - available or no priority blog is waiting. - """ - getter = getattr(self.repository, "get_next_priority_blog", None) - if getter is None: - return None - return getter() - - def _get_next_waiting_blog(self, *, include_priority: bool) -> dict[str, Any] | None: + def _get_next_waiting_blog(self) -> dict[str, Any] | None: """Return the next waiting blog row from the main queue. - Args: - include_priority: Whether repository implementations should allow - priority rows to be returned from the general waiting query. - Returns: The next claimed waiting blog row, or ``None`` when the queue is empty. """ - getter = self.repository.get_next_waiting_blog - try: - return getter(include_priority=include_priority) - except TypeError: - return getter() + return self.repository.get_next_waiting_blog() def _crawl_blog(self, blog: dict[str, Any]) -> int: """Crawl one blog row through the orchestrator. diff --git a/crawler/main.py b/crawler/main.py index e5665cc..06f84d0 100644 --- a/crawler/main.py +++ b/crawler/main.py @@ -72,7 +72,6 @@ def build_crawler_state(settings: Settings | None = None) -> CrawlerState: runtime=CrawlerRuntimeService( pipeline, worker_count=resolved.runtime_worker_count, - priority_seed_normal_queue_slots=resolved.priority_seed_normal_queue_slots, ), ) diff --git a/crawler/runtime/service.py b/crawler/runtime/service.py index d4d51e8..3d29b1a 100644 --- a/crawler/runtime/service.py +++ b/crawler/runtime/service.py @@ -34,8 +34,6 @@ class CrawlerRuntimeService: pipeline: Crawl pipeline used to process individual blog rows. executor: Thread launcher used for background runtime execution. worker_count: Number of runtime workers to run in parallel. - priority_seed_normal_queue_slots: Number of normal queue claims allowed - after a priority seed claim. """ def __init__( @@ -44,7 +42,6 @@ def __init__( executor: SerialRuntimeExecutor | None = None, *, worker_count: int = 1, - priority_seed_normal_queue_slots: int = 2, ) -> None: """Initialize runtime state, workers, and synchronization primitives. @@ -52,8 +49,6 @@ def __init__( pipeline: Crawl pipeline reused by synchronous and background runs. executor: Optional executor used to start the background thread. worker_count: Requested number of runtime workers. - priority_seed_normal_queue_slots: Fairness window size after a - priority queue claim. Returns: ``None``. The runtime service stores its dependencies and prepares @@ -62,14 +57,12 @@ def __init__( self.pipeline = pipeline self.executor = executor or SerialRuntimeExecutor() self.worker_count = max(1, worker_count) - self.priority_seed_normal_queue_slots = max(1, priority_seed_normal_queue_slots) self.capacity_gate = getattr(pipeline, "capacity_gate", None) if self.capacity_gate is None: self.capacity_gate = CrawlerCapacityGate( pipeline.repository, raw_discovered_url_limit=-1, ) - self._normal_slots_remaining_after_priority = 0 self._snapshot = RuntimeSnapshot( worker_count=self.worker_count, workers=[ @@ -183,7 +176,6 @@ def run_batch(self, max_nodes: int) -> dict[str, Any]: "runtime": self._snapshot.as_dict(), } self._stop_event.clear() - self._normal_slots_remaining_after_priority = 0 self._begin_run_locked("running") try: @@ -210,7 +202,6 @@ def _run_background_loop(self) -> None: """ with self._lock: self._snapshot.runner_status = "running" - self._normal_slots_remaining_after_priority = 0 try: result = self._run_worker_pool(max_nodes=None) @@ -384,61 +375,22 @@ def _release_budget_slot(self, budget: dict[str, int | None]) -> None: budget["remaining"] = remaining + 1 def _claim_next_waiting_blog(self) -> dict[str, Any] | None: - """Claim one waiting blog while enforcing runtime fairness rules. + """Claim one waiting blog from the repository. Returns: The next claimed blog row, or ``None`` when no eligible work remains. """ with self._claim_lock: - if self._normal_slots_remaining_after_priority <= 0: - priority_row = self._get_next_priority_blog() - if priority_row is not None: - # One claimed priority seed opens a bounded fairness window - # for normal queue items before the next priority check. - self._normal_slots_remaining_after_priority = self.priority_seed_normal_queue_slots - return priority_row - - row = self._get_next_waiting_blog(include_priority=self._normal_slots_remaining_after_priority <= 0) - if row is not None: - if self._normal_slots_remaining_after_priority > 0: - self._normal_slots_remaining_after_priority -= 1 - return row - - if self._normal_slots_remaining_after_priority > 0: - priority_row = self._get_next_priority_blog() - if priority_row is not None: - self._normal_slots_remaining_after_priority = self.priority_seed_normal_queue_slots - return priority_row - self._normal_slots_remaining_after_priority = 0 - return None - - def _get_next_priority_blog(self) -> dict[str, Any] | None: - """Return the next priority blog from the repository, if supported. + return self._get_next_waiting_blog() - Returns: - The next priority blog row, or ``None`` when unavailable. - """ - getter = getattr(self.pipeline.repository, "get_next_priority_blog", None) - if getter is None: - return None - return getter() - - def _get_next_waiting_blog(self, *, include_priority: bool) -> dict[str, Any] | None: + def _get_next_waiting_blog(self) -> dict[str, Any] | None: """Return the next waiting blog from the repository. - Args: - include_priority: Whether the repository should allow priority rows - in its general waiting query. - Returns: The next waiting blog row, or ``None`` when the queue is empty. """ - getter = self.pipeline.repository.get_next_waiting_blog - try: - return getter(include_priority=include_priority) - except TypeError: - return getter() + return self.pipeline.repository.get_next_waiting_blog() def _on_blog_start(self, worker_index: int, blog: dict[str, Any]) -> None: """Record that a worker has started crawling one blog. diff --git a/doc/api-docs.md b/doc/api-docs.md index 11c546e..389d962 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -63,7 +63,7 @@ ### 2.1 Public API -Public API 由 `backend` 服务统一暴露,供 public 浏览、图谱与 ingestion 流程使用: +Public API 由 `backend` 服务统一暴露,供 public 浏览、图谱与用户 seed 提交流程使用: - `GET /` - `GET /internal/health` @@ -88,9 +88,6 @@ Public API 由 `backend` 服务统一暴露,供 public 浏览、图谱与 inge - `GET /api/graph/snapshots/{version}` - `GET /api/stats` - `GET /api/filter-stats` -- `GET /api/ingestion-requests` -- `POST /api/ingestion-requests` -- `GET /api/ingestion-requests/{request_id}` 源码位置: [backend/main.py](../backend/main.py) @@ -125,9 +122,6 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - `POST /api/admin/blog-labeling/title-preview` - `PUT /api/admin/blog-labeling/labels/{blog_id}` - `GET /api/admin/recommendation-stats` -- `POST /api/admin/blog-dedup-scans` -- `GET /api/admin/blog-dedup-scans/latest` -- `GET /api/admin/blog-dedup-scans/{run_id}/items` 补充脚本: @@ -737,7 +731,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 匹配阶梯: -- 先复用 ingestion 的 canonicalization / identity 规则,把输入归一化为 `normalized_query_url` +- 先复用 blog identity canonicalization 规则,把输入归一化为 `normalized_query_url` - 优先按 canonical homepage identity 精确匹配 - 若 identity 未命中,再回退到 `normalized_url` 精确相等匹配 - 若仍未命中,则返回空数组;当前不做 substring / domain contains 型广义搜索 @@ -1224,87 +1218,9 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 当前 public API 已不再暴露 legacy 的 `/api/logs` 与 `/api/search`。 - 运行日志统一由 `shared.observability` 输出到类型目录,默认是 `logs/app/`、`logs/error/`、`logs/access/`;每个类型目录下再按服务分目录,保存 `-YYYYMMDD-HH.log` 小时切片,Docker Compose 中对应 `volumes/logs`。 - legacy `/internal/logs` 仍保留兼容入口,但当前不会把 crawl log 写入业务数据库。 -- blog dedup scan 这类维护任务的进度属于 domain event,仍通过各自 run/event 接口持久化,不混入通用 application log。 - `search` 服务仍保留为内部可重建索引组件,供 health 检查与 reindex 维护链路使用,并在缓存为空时回退到 `persistence-api /internal/search-snapshot`。 - 浏览器当前没有直接依赖的 public 搜索页;public 发现主路径已经收敛到 `catalog / lookup / detail / graph views`。 -#### `GET /api/ingestion-requests` - -用途:返回统一 discovery 页“优先处理博客清单”所需的公开优先录入请求列表。 - -返回约束: - -- 固定最多返回 `20` 条 -- inclusion rule: 先返回 active request(`QUEUED`、`CRAWLING_SEED`),再补最近创建的 terminal request,直到达到上限 -- 排序固定为 `active-first -> created_at DESC -> request_id DESC` - -公开字段: - -- `request_id` -- `requested_url` -- `normalized_url` -- `status` -- `seed_blog_id` -- `matched_blog_id` -- `blog_id` -- `error_message` -- `created_at` -- `updated_at` -- `blog` - -隐私边界: - -- 该公开列表不会返回 `email` -- 该公开列表不会返回 `request_token` - -补充说明: - -- `blog` 是裁剪后的公开摘要,至少包含 `id`、`url`、`domain`、`title`、`icon_url`、`crawl_status` -- 该列表接口服务统一页的优先队列面板,不替代单条状态查询接口 - -#### `POST /api/ingestion-requests` - -用途:当搜索未命中时,由最终用户提交博客首页 URL 与联系邮箱,触发优先录入请求。 - -请求体: - -```json -{ - "homepage_url": "https://example.com/", - "email": "owner@example.com" -} -``` - -响应分两类: - -- 已收录时:直接返回 `DEDUPED_EXISTING` 与现有 `blog_id` -- 未收录时:返回请求状态、`request_id`、`request_token`、seed blog 关联信息 - -补充说明: - -- 后端会先做 URL normalize 与 email 基础校验 -- 当前去重主键已经扩展为 `identity_key`;它会忽略 `http/https`、主页默认首页路径、`www.`,以及白名单博客别名子域(如 `blog.`) -- 活跃请求会按 `identity_key + ACTIVE_INGESTION_REQUEST_STATUSES` 复用,而不是重复创建 crawl -- `request_token` 仍作为自助提交状态查询的轻量凭证;当前账号系统暂未接管 ingestion request 的所有权 - -#### `GET /api/ingestion-requests/{request_id}?request_token=...` - -用途:查询某个自助录入请求的当前状态。 - -当前返回字段重点包括: - -- `status`: 当前请求状态,常见值有 `QUEUED`、`CRAWLING_SEED`、`COMPLETED`、`FAILED` -- `seed_blog_id`: 当前请求绑定的 seed blog -- `matched_blog_id`: 若已完成并命中 blog,则返回该 blog id -- `blog`: 当前关联 blog 的摘要信息 -- `request_token`: 创建请求时返回的状态查询 token - -补充说明: - -- 当前 ingestion request 状态查询仍依赖 `request_id + request_token` -- 若 `request_token` 不匹配,返回 `404` -- 统一 discovery 页的公开优先队列列表不会暴露该 `request_token`;只有创建者通过该单条接口查询时才会使用它 - ### 3.5 管理员爬取执行接口 #### `POST /api/admin/crawl/bootstrap` @@ -1366,81 +1282,6 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 ### 3.6 数据维护接口 -#### `POST /api/admin/blog-dedup-scans` - -用途:管理员手动触发一次基于当前 `UrlDecisionChain` 的全库已收录 blog URL 重评估扫描。 - -行为说明: - -- backend 会先读取 crawler runtime;若扫描前 crawler 正在运行,则先停爬并等待 `idle` -- 扫描期间 backend 会打开 `maintenance_in_progress` 维护锁 -- `POST` 请求现在只负责创建一个 `RUNNING` scan run 并启动后台任务,因此前端会立刻收到可轮询的 run 摘要 -- 维护窗口内新的 `POST /api/admin/runtime/start` 与 `POST /api/admin/runtime/run-batch` 会返回 `409 maintenance_in_progress` -- 当前实现会复用 crawler 的共享 `UrlDecisionChain` builder,对数据库里已存的 `blogs.url` 重新跑一遍完整 URL 过滤逻辑 -- 被当前决策链拒绝的 blog 会连同其相关 edge 一起删除,并清空相关 ingestion 引用,避免残留悬挂关系 -- 扫描 summary 中的 `total_count / scanned_count / kept_count / removed_count` 对应的是已存 blog URL 数量 -- 扫描成功后 backend 会尝试调用 search reindex -- 若扫描前 crawler 原本在运行,backend 会在结束后尝试恢复 crawler,并把恢复结果写回 run summary -- 前端可通过 `GET /api/admin/blog-dedup-scans/latest` 轮询实时进度,并在需要明细时继续请求 `GET /api/admin/blog-dedup-scans/{run_id}/items`;其中 `scanned_count / total_count` 表示“已扫描 URL / 总共 URL” - -返回字段重点包括: - -- `id` -- `status` -- `ruleset_version` -- `total_count` -- `scanned_count` -- `removed_count` -- `kept_count` -- `crawler_was_running` -- `crawler_restart_attempted` -- `crawler_restart_succeeded` -- `search_reindexed` -- `error_message` -- `started_at` / `completed_at` / `duration_ms` - -#### `GET /api/admin/blog-dedup-scans/latest` - -用途:返回最近一次扫描摘要。 - -#### `GET /api/admin/blog-dedup-scans/{run_id}/items` - -用途:返回该次扫描中被决策链移除的 blog 明细与原因。 - -每条 item 至少包含: - -- `survivor_blog_id` - 当前通常为 `null`;历史字段名保留用于兼容 -- `removed_blog_id` - 当前表示被规则重扫移除的 blog id -- `survivor_identity_key` - 当前承载被扫描 blog 的 identity key 供排查使用 -- `removed_url` -- `reason_code` -- `reason_codes` -- `survivor_selection_basis` - 当前承载 scanned blog id 与 decision score 等辅助调试信息 - -#### `POST /api/admin/blogs/requeue-failed` - -用途:把所有 `FAILED` 状态的 blog 重新放回 crawler 待处理队列。 - -行为说明: - -- 仅允许在 crawler 运行器不处于 `starting/running/stopping` 时调用 -- 若运行器忙碌,返回 `409`,错误详情为 `crawler_busy` -- 会把当前所有 `crawl_status=FAILED` 的 blog 改为 `WAITING` -- 会清空这些 blog 的 `status_code`,并更新 `updated_at` -- 若对应 ingestion request 处于 `FAILED`,会同步改回 `QUEUED` 并清空 `error_message` - -成功响应示例: - -```json -{ - "requeued": 733 -} -``` - #### `POST /api/admin/database/reset` 用途:重置数据库中的 crawler 相关数据,便于测试和开发时快速回到初始状态。 @@ -1449,7 +1290,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 仅允许在 crawler 运行器不处于 `starting/running/stopping` 时调用 - 若运行器忙碌,返回 `409`,错误详情为 `crawler_busy` -- 会清空 `blogs`、`edges`、`raw_discovered_urls`、`ingestion_requests` 和维护任务记录 +- 会清空 `blogs`、`edges`、`raw_discovered_urls` - 不会删除人工 label 相关数据:`blog_labels(normalized_url, title, label_id, created_time, updated_time)` 和 `blog_label_tags` 会被保留 - backend 在数据库重置后会尝试调用 `search /internal/search/reindex` - 即使 search 重建失败,数据库重置结果仍会返回,并附带 `search_reindexed=false` @@ -1522,7 +1363,6 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 若当前已在 `starting/running/stopping`,直接返回当前快照 - 成功启动后会创建新的 `active_run_id` -- 若 backend 当前处于 blog dedup 维护窗口,返回 `409 maintenance_in_progress` #### `POST /api/admin/runtime/stop` @@ -1539,7 +1379,6 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 补充说明: -- 若 backend 当前处于 blog dedup 维护窗口,返回 `409 maintenance_in_progress` 请求体: @@ -1791,19 +1630,8 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 行为说明: - 只从 `crawl_status = 'WAITING'` 中选择 -- 默认允许包含 priority seed;也可以通过 `include_priority=false` 只领取普通队列 - 选中后立刻更新为 `PROCESSING` -### `GET /internal/queue/priority-next` - -用途:只领取由 `ingestion_requests` 驱动的高优先级 seed blog。 - -行为说明: - -- 仅选择仍处于 `QUEUED` 的请求对应 seed -- 按 `priority DESC, created_at ASC, blog.id ASC` 领取 -- 选中后立刻把 blog 更新为 `PROCESSING` - ### `GET /internal/blogs/{blog_id}/detail` 用途:按 id 查询单个 blog,并聚合详情页所需的 `incoming_edges` / `outgoing_edges` 与邻居摘要。 @@ -2017,7 +1845,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 行为说明: -- 清空 `blogs`、`edges`、`raw_discovered_urls`、`ingestion_requests` 和维护任务记录 +- 清空 `blogs`、`edges`、`raw_discovered_urls` - 保留 URL-keyed 人工 label 数据与 tag 定义 - `logs_deleted` 固定返回 `0` - 重置主键计数器 @@ -2039,80 +1867,6 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 } ``` -补充说明: - -- 若该 blog 是某个活跃 `ingestion_request` 的 seed,写回结果时会同步推进请求状态为 `COMPLETED` 或 `FAILED` - -### `POST /internal/ingestion-requests` - -用途:创建或复用一个用户自助优先录入请求。 - -请求体: - -```json -{ - "homepage_url": "https://example.com/", - "email": "owner@example.com" -} -``` - -返回: - -- 已收录时:`DEDUPED_EXISTING` -- 新建或复用请求时:请求 payload,包含 `request_id`、`request_token`、`status`、`seed_blog_id` - -补充说明: - -- 去重与复用当前按 `identity_key` 执行,而不再只看 `normalized_url` -- 返回 payload 会附带 `identity_key`、`identity_reason_codes` 与 `identity_ruleset_version` -- 对满足“tenant-like homepage 子域”启发式的 URL,`normalized_url` 与 seed blog URL 会直接收敛到 registrable root 的 canonical URL;例如 `*.66law.cn` 会统一收敛到 `https://66law.cn/`。`*.github.io`、`*.gitee.io` 等显式排除域名不会被这样归并。 - -### `GET /internal/ingestion-requests/{request_id}` - -用途:通过 `request_id + request_token` 查询请求状态。 - -查询参数: - -- `request_token`: 创建请求时生成的查询 token - -### `GET /internal/ingestion-requests` - -用途:为 backend 提供统一 discovery 页“优先处理博客清单”所需的公开优先录入请求列表。 - -补充说明: - -- 返回范围、排序与公开字段约束与 `GET /api/ingestion-requests` 一致 -- internal/public 两层都不会在该列表 payload 中暴露 `email` 与 `request_token` - -### `POST /internal/ingestion-requests/by-blog/{blog_id}/crawling` - -用途:当 crawler 真正开始处理某个 seed blog 时,把关联请求推进到 `CRAWLING_SEED`。 - -### `POST /internal/blog-dedup-scans/runs` - -用途:创建一个 `RUNNING` 的规则重扫 run,并立即返回初始摘要,供 backend 异步编排使用。 - -查询参数: - -- `crawler_was_running`: backend 透传的预扫描 runtime 状态 - -### `POST /internal/blog-dedup-scans/{run_id}/execute` - -用途:执行指定 run 的 persistence 侧规则重扫逻辑,并在执行过程中持续更新 `total_count`、`scanned_count`、`removed_count`、`kept_count`。 -当前四个计数字段都以已存 blog URL 数为口径。 - -### `POST /internal/blog-dedup-scans/{run_id}/finalize` - -用途:由 backend 在扫描编排完成后回写 crawler 恢复和 search reindex 结果。 - -### `GET /internal/blog-dedup-scans/latest` - -用途:返回最近一次 run summary。 - -### `GET /internal/blog-dedup-scans/{run_id}/items` - -用途:返回指定 run 中被决策链移除的 blog 明细。 - ## 5. 数据模型整理 以下字段来自当前仓库实现与前端类型定义,适合作为现阶段统一理解口径。 @@ -2185,37 +1939,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 | `filters.min_connections` | `number` | 最小连接度阈值 | | `sort` | `string` | 当前生效排序;与 `filters.sort` 保持一致 | -### 5.3 IngestionRequestPayload - -来源: - -- [persistence_api/repository.py](persistence_api/repository.py) -- [frontend/src/lib/api.ts](frontend/src/lib/api.ts) - -字段: - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `id` / `request_id` | `number` | 请求主键 | -| `requested_url` | `string` | 用户提交的原始首页 URL | -| `normalized_url` | `string` | 归一化后的 URL | -| `identity_key` | `string` | 当前请求命中的 blog 身份键 | -| `identity_reason_codes` | `string[]` | 当前 identity 解析原因码 | -| `identity_ruleset_version` | `string` | 当前 identity 规则版本 | -| `email` | `string` | 用户提交的联系邮箱 | -| `status` | `string` | 请求状态 | -| `priority` | `number` | 当前固定优先级值 | -| `seed_blog_id` | `number \| null` | 绑定的 seed blog | -| `matched_blog_id` | `number \| null` | 已完成时关联的最终 blog | -| `blog_id` | `number \| null` | 便于前端跳转的当前关联 blog id | -| `request_token` | `string` | 无账号状态查询 token | -| `seed_blog` | `BlogRecord \| null` | seed blog 摘要 | -| `matched_blog` | `BlogRecord \| null` | 已匹配 blog 摘要 | -| `blog` | `BlogRecord \| null` | 前端使用的当前 blog 视图 | -| `error_message` | `string \| null` | 失败时的错误摘要 | -| `created_at` / `updated_at` | `string` | 请求创建/更新时间 | - -### 5.4 BlogDetailPayload +### 5.3 BlogDetailPayload 字段: @@ -2331,7 +2055,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 ### 6.1 读接口调用链 - 前端 -> `backend /api/*` -- `backend` -> `persistence-api` 获取 blog catalog、blog detail、graph views、graph snapshots、stats、ingestion request 与 dedup summary +- `backend` -> `persistence-api` 获取 blog catalog、blog detail、graph views、graph snapshots 与 stats - `backend` -> `crawler` 获取运行时状态 ### 6.2 写接口调用链 @@ -2369,7 +2093,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 对外协议以 `backend /api/*` 为准,前端不要直接依赖内部服务接口 - 内部服务接口已经比较清晰,但目前没有统一版本号,也没有显式 OpenAPI schema 文档归档 -- legacy 的 raw blog/edge/graph/log/search 公共读取端点已经移除,当前对外建议继续围绕 catalog、detail、graph view、ingestion 和 admin runtime 组织能力 +- legacy 的 raw blog/edge/graph/log/search 公共读取端点已经移除,当前对外建议继续围绕 catalog、detail、graph view、user seed 和 admin runtime 组织能力 - `/api/admin/crawl/run` 使用 query 参数 `max_nodes`,而 `/api/admin/runtime/run-batch` 使用 JSON body `max_nodes`,风格不完全一致,后续可统一 - `search` 当前是轻量缓存式实现,属于可重建索引,不是强一致检索服务 - `services/*` 只是兼容入口,后续文档与新开发应优先引用顶层目录 `backend/`、`crawler/`、`search/`、`persistence_api/` diff --git a/doc/crawler-url-filtering.md b/doc/crawler-url-filtering.md index b96a944..39becfd 100644 --- a/doc/crawler-url-filtering.md +++ b/doc/crawler-url-filtering.md @@ -105,15 +105,9 @@ crawler 的两种主要运行方式: 6. 每个博客结束后累计 `processed / discovered / failed` 7. 本轮结束后执行 `write_exports()` -### 3.3 队列公平策略 +### 3.3 队列领取策略 -`CrawlPipeline._claim_next_scheduled_blog()` 当前不是简单 FIFO,而是带优先队列公平窗口: - -- 优先种子队列优先级更高 -- 但不会无限饿死普通 waiting 队列 -- 参数 `priority_seed_normal_queue_slots` 控制“处理一个 priority 后,允许多少个 normal queue 项穿插执行” - -这套逻辑也被 runtime 模式复用。 +`CrawlPipeline` 与 runtime 模式都会通过 `persistence-api /internal/queue/next` 领取下一个 `WAITING` blog,并在领取时把状态切换为 `PROCESSING`。 ## 4. 单博客抓取链路 diff --git a/doc/public-admin-boundary.md b/doc/public-admin-boundary.md index c7b806d..911d488 100644 --- a/doc/public-admin-boundary.md +++ b/doc/public-admin-boundary.md @@ -19,7 +19,7 @@ Public capabilities: - browse discovered blogs - inspect blog detail and graph relationships - search by blog/site/relation clues -- submit ingestion requests and check request status +- submit user seed blog URLs for crawling ### Admin @@ -36,7 +36,6 @@ Admin capabilities: - crawler runtime control - manual crawl/bootstrap triggers - database maintenance -- dedup scans - blog labeling ## API Boundary @@ -52,9 +51,7 @@ Admin capabilities: - `GET /api/graph/snapshots/latest` - `GET /api/graph/snapshots/{version}` - `GET /api/stats` -- `POST /api/ingestion-requests` -- `GET /api/ingestion-requests` -- `GET /api/ingestion-requests/{request_id}` +- `POST /api/blogs/user-seeds` ### Admin API @@ -70,9 +67,6 @@ Admin capabilities: - `GET /api/admin/blog-labeling/tags` - `POST /api/admin/blog-labeling/tags` - `PUT /api/admin/blog-labeling/labels/{blog_id}` -- `POST /api/admin/blog-dedup-scans` -- `GET /api/admin/blog-dedup-scans/latest` -- `GET /api/admin/blog-dedup-scans/{run_id}/items` ## Auth diff --git a/doc/service-architecture.md b/doc/service-architecture.md index e979491..1f3ad44 100644 --- a/doc/service-architecture.md +++ b/doc/service-architecture.md @@ -181,7 +181,7 @@ backend health / crawl-run / runtime-run-batch / database-reset -> backend 先读取 crawler /internal/runtime/status -> 若 crawler 忙碌则返回 409 crawler_busy -> 否则 backend -> persistence-api /internal/database/reset - -> persistence-api 清空 blogs / edges 和维护任务记录 + -> persistence-api 清空 blogs / edges / raw_discovered_urls -> backend 再尽力调用 search /internal/search/reindex ``` @@ -191,7 +191,6 @@ backend health / crawl-run / runtime-run-batch / database-reset | --- | --- | --- | | `blogs` / `edges` | `persistence-api` | 系统事实来源 | | application/access/error logs | 统一日志目录 | 默认按类型和服务写到 `logs/app/`、`logs/error/`、`logs/access/` 的小时切片,Docker 中映射到 `volumes/logs` | -| maintenance run events | `persistence-api` | blog dedup scan 等后台维护进度 | | `stats` / `graph` / graph snapshots | `persistence-api` | 基于事实数据组装出的读模型 | | `search-index.json` | `search` | 可重建缓存 | | `RuntimeSnapshot` | `crawler` | 进程内内存态,不是持久化状态 | diff --git a/frontend/src/components/SubmitBlogDialog.tsx b/frontend/src/components/SubmitBlogDialog.tsx index 64e31e0..de07025 100644 --- a/frontend/src/components/SubmitBlogDialog.tsx +++ b/frontend/src/components/SubmitBlogDialog.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { AlertCircle, X } from "lucide-react"; import { toast } from "sonner"; -import { submitBlogInfo } from "../lib/api"; +import { submitUserSeed } from "../lib/api"; interface SubmitBlogDialogProps { url: string; @@ -18,7 +18,6 @@ interface SubmitBlogDialogProps { * @returns Modal dialog UI. */ export function SubmitBlogDialog({ url, onClose, onSuccess }: SubmitBlogDialogProps) { - const [email, setEmail] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); /** @@ -31,9 +30,9 @@ export function SubmitBlogDialog({ url, onClose, onSuccess }: SubmitBlogDialogPr try { setIsSubmitting(true); - await submitBlogInfo({ url, email }); + await submitUserSeed({ url }); - toast.success("提交成功,系统已记录该博客请求。"); + toast.success("提交成功,系统已将该博客加入抓取队列。"); onSuccess?.(); onClose(); } catch { @@ -51,7 +50,7 @@ export function SubmitBlogDialog({ url, onClose, onSuccess }: SubmitBlogDialogPr

博客未找到

-
该 URL 当前未收录。你可以留下邮箱,系统会创建抓取请求。
+
该 URL 当前未收录。你可以将它加入抓取队列。
-
- - setEmail(event.target.value)} - placeholder="you@example.com" - className="w-full rounded-md border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none" - /> -
-
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b73ccce..692758b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,4 @@ import type { - AdminDedupSummary, AdminBlogLabelCounts, AdminBlogLabelingCandidate, AdminBlogLabelingPage, @@ -230,12 +229,6 @@ interface BackendRandomRecommendationBatchPayload { items: BackendGraphNode[]; } -interface CreateIngestionRequestPayload { - request_id: number; - request_token: string; - status: string; -} - interface BackendUserProfile { id: number; email: string; @@ -277,17 +270,6 @@ interface BackendRuntimePayload { maintenance_in_progress?: boolean; } -interface BackendDedupSummary { - id: number; - status: string; - total_count: number; - scanned_count: number; - removed_count: number; - kept_count: number; - created_at: string; - updated_at: string; -} - interface BackendBlogLabelTag { id: number; name: string; @@ -1009,31 +991,6 @@ export async function postRecommendationEvent( }); } -/** - * Submit one ingestion request when a searched blog is missing. - * - * @param data User-provided URL and email pair. - * @returns Created ingestion request summary. - */ -export async function submitBlogInfo(data: { - url: string; - email: string; -}): Promise { - if (!data.url.trim()) { - throw new Error("url_required"); - } - if (!data.email.trim()) { - throw new Error("email_required"); - } - return apiJson("/api/ingestion-requests", { - method: "POST", - body: JSON.stringify({ - homepage_url: data.url.trim(), - email: data.email.trim(), - }), - }); -} - /** * Submit one user seed URL so it can be accepted and queued for crawling. * @@ -1190,32 +1147,6 @@ export async function fetchAdminRuntimeCurrent(adminToken: string): Promise { - try { - const payload = await apiJson("/api/admin/blog-dedup-scans/latest", { - headers: adminHeaders(adminToken), - }); - return { - id: payload.id, - status: payload.status, - totalCount: payload.total_count, - scannedCount: payload.scanned_count, - removedCount: payload.removed_count, - keptCount: payload.kept_count, - createdAt: payload.created_at, - updatedAt: payload.updated_at, - }; - } catch { - return null; - } -} - /** * Fetch one page of protected blog labeling candidates. * diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 416d32d..5432240 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -24,7 +24,6 @@ import { fetchAdminBlogLabelingCandidates, fetchAdminBlogLabelParquetStatus, fetchAdminBlogLabelTitlePreview, - fetchAdminDedupLatest, fetchAdminRuntimeCurrent, fetchAdminRuntimeStatus, fetchStats, @@ -42,7 +41,6 @@ import { import type { AdminBlogLabelingCandidate, AdminBlogLabelTag, - AdminDedupSummary, AdminRuntimeCurrent, AdminRuntimeStatus, StatsData, @@ -123,7 +121,6 @@ export function AdminPage() { const [stats, setStats] = useState({ totalNodes: 0, totalEdges: 0 }); const [runtimeStatus, setRuntimeStatus] = useState(null); const [runtimeCurrent, setRuntimeCurrent] = useState(null); - const [latestDedup, setLatestDedup] = useState(null); const [labelingCandidates, setLabelingCandidates] = useState([]); const [labelTags, setLabelTags] = useState([]); const [labelCounts, setLabelCounts] = useState({ totalLabeled: 0, byLabel: {} }); @@ -180,7 +177,6 @@ export function AdminPage() { if (!adminToken.trim()) { setRuntimeStatus(null); setRuntimeCurrent(null); - setLatestDedup(null); setLabelingCandidates([]); setLabelTags([]); setLabelCounts({ totalLabeled: 0, byLabel: {} }); @@ -191,23 +187,15 @@ export function AdminPage() { return; } - const [ - runtimeStatusResponse, - runtimeCurrentResponse, - latestDedupResponse, - labelCountResponse, - labelParquetResponse, - ] = + const [runtimeStatusResponse, runtimeCurrentResponse, labelCountResponse, labelParquetResponse] = await Promise.all([ fetchAdminRuntimeStatus(adminToken), fetchAdminRuntimeCurrent(adminToken), - fetchAdminDedupLatest(adminToken), fetchAdminBlogLabelCounts(adminToken), fetchAdminBlogLabelParquetStatus(adminToken), ]); setRuntimeStatus(runtimeStatusResponse); setRuntimeCurrent(runtimeCurrentResponse); - setLatestDedup(latestDedupResponse); setLabelCounts(labelCountResponse); setLabelParquetStatus(labelParquetResponse); setAdminError(null); @@ -233,7 +221,6 @@ export function AdminPage() { console.error(error); setRuntimeStatus(null); setRuntimeCurrent(null); - setLatestDedup(null); setLabelingCandidates([]); setLabelTags([]); setLabelCounts({ totalLabeled: 0, byLabel: {} }); @@ -915,17 +902,6 @@ export function AdminPage() { active run: {runtimeCurrent?.activeRunId ?? "-"}
-
-
latest dedup scan
-
{latestDedup?.status ?? "暂无记录"}
-
- run id: {latestDedup?.id ?? "-"} -
- scanned / total: {latestDedup ? `${latestDedup.scannedCount} / ${latestDedup.totalCount}` : "-"} -
- removed: {latestDedup?.removedCount ?? "-"} -
-
)}
diff --git a/frontend/src/types/graph.ts b/frontend/src/types/graph.ts index b5105d4..2cc6ecb 100644 --- a/frontend/src/types/graph.ts +++ b/frontend/src/types/graph.ts @@ -234,17 +234,6 @@ export interface AdminRequeueFailedBlogsResult { requeued: number; } -export interface AdminDedupSummary { - id: number; - status: string; - totalCount: number; - scannedCount: number; - removedCount: number; - keptCount: number; - createdAt: string; - updatedAt: string; -} - export interface AdminBlogLabelTag { id: number; name: string; diff --git a/persistence_api/main.py b/persistence_api/main.py index d2f353a..04454b7 100644 --- a/persistence_api/main.py +++ b/persistence_api/main.py @@ -54,11 +54,6 @@ class UpsertBlogRequest(BaseModel): seed_source_row: int | None = None -class CreateIngestionRequest(BaseModel): - homepage_url: str - email: str - - class CreateUserSeedRequest(BaseModel): homepage_url: str @@ -160,13 +155,6 @@ class BlogLabelParquetStatusResponse(BaseModel): updated_at: str | None -class FinalizeBlogDedupScanRunRequest(BaseModel): - crawler_restart_attempted: bool - crawler_restart_succeeded: bool - search_reindexed: bool - error_message: str | None = None - - _T = TypeVar("_T") _ExceptionTranslation = tuple[type[Exception], int, str | None] @@ -404,10 +392,6 @@ def get_blog_recommendation_stats(blog_id: int) -> dict[str, Any]: def get_recommendation_strategy_stats() -> dict[str, Any]: return get_state().repository.get_recommendation_strategy_stats() - @app.get("/internal/ingestion-requests") - def list_priority_ingestion_requests() -> list[dict[str, Any]]: - return get_state().repository.list_priority_ingestion_requests() - @app.post("/internal/users/register") def register_user(payload: UserAuthRequest) -> dict[str, Any]: return _call_with_http_exception_translation( @@ -554,15 +538,9 @@ def export_blog_label_training_parquet() -> Response: ) @app.get("/internal/queue/next") - def next_waiting(include_priority: bool = True) -> dict[str, Any] | None: + def next_waiting() -> dict[str, Any] | None: return _load_optional_row_as_dict( - lambda: get_state().repository.get_next_waiting_blog(include_priority=include_priority), - ) - - @app.get("/internal/queue/priority-next") - def next_priority_waiting() -> dict[str, Any] | None: - return _load_optional_row_as_dict( - lambda: get_state().repository.get_next_priority_blog(), + lambda: get_state().repository.get_next_waiting_blog(), ) @app.get("/internal/blogs/{blog_id}/detail") @@ -572,13 +550,6 @@ def get_blog_detail(blog_id: int) -> dict[str, Any]: detail="blog_not_found", ) - @app.post("/internal/ingestion-requests") - def create_ingestion_request(payload: CreateIngestionRequest) -> dict[str, Any]: - return _call_with_value_error_http_translation( - lambda: get_state().repository.create_ingestion_request(**payload.model_dump()), - status_code=422, - ) - @app.post("/internal/user-seeds") def create_user_seed(payload: CreateUserSeedRequest) -> dict[str, Any]: return _call_with_value_error_http_translation( @@ -586,54 +557,6 @@ def create_user_seed(payload: CreateUserSeedRequest) -> dict[str, Any]: status_code=422, ) - @app.get("/internal/ingestion-requests/{request_id}") - def get_ingestion_request(request_id: int, request_token: str) -> dict[str, Any]: - return _require_payload( - get_state().repository.get_ingestion_request( - request_id=request_id, - request_token=request_token, - ), - detail="ingestion_request_not_found", - ) - - @app.post("/internal/blog-dedup-scans/runs") - def create_blog_dedup_scan_run(crawler_was_running: bool = False) -> dict[str, Any]: - return get_state().repository.create_blog_dedup_scan_run(crawler_was_running=crawler_was_running) - - @app.post("/internal/blog-dedup-scans/{run_id}/execute") - def execute_blog_dedup_scan_run(run_id: int) -> dict[str, Any]: - return _call_with_value_error_http_translation( - lambda: get_state().repository.execute_blog_dedup_scan_run(run_id=run_id), - status_code=404, - ) - - @app.post("/internal/blog-dedup-scans/{run_id}/finalize") - def finalize_blog_dedup_scan_run(run_id: int, payload: FinalizeBlogDedupScanRunRequest) -> dict[str, Any]: - return _call_with_value_error_http_translation( - lambda: get_state().repository.finalize_blog_dedup_scan_run( - run_id=run_id, - **payload.model_dump(), - ), - status_code=404, - ) - - @app.get("/internal/blog-dedup-scans/latest") - def get_latest_blog_dedup_scan_run() -> dict[str, Any]: - return _require_payload( - get_state().repository.get_latest_blog_dedup_scan_run(), - detail="blog_dedup_scan_run_not_found", - ) - - @app.get("/internal/blog-dedup-scans/{run_id}/items") - def list_blog_dedup_scan_run_items(run_id: int) -> list[dict[str, Any]]: - return get_state().repository.list_blog_dedup_scan_run_items(run_id) - - @app.post("/internal/ingestion-requests/by-blog/{blog_id}/crawling") - def mark_ingestion_request_crawling(blog_id: int) -> dict[str, bool]: - return _run_action_and_return_ok( - lambda: get_state().repository.mark_ingestion_request_crawling(blog_id=blog_id), - ) - @app.post("/internal/blogs/upsert") def upsert_blog(payload: UpsertBlogRequest) -> dict[str, Any]: blog_id, inserted = get_state().repository.upsert_blog(**payload.model_dump()) diff --git a/persistence_api/models.py b/persistence_api/models.py index e4f66bf..51c8271 100644 --- a/persistence_api/models.py +++ b/persistence_api/models.py @@ -4,7 +4,6 @@ from datetime import datetime -from sqlalchemy import Boolean from sqlalchemy import DateTime from sqlalchemy import Enum from sqlalchemy import ForeignKey @@ -94,29 +93,6 @@ class SeedModel(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) -class IngestionRequestModel(Base): - """User-triggered priority ingestion request.""" - - __tablename__ = "ingestion_requests" - - id: Mapped[int] = mapped_column(primary_key=True) - requested_url: Mapped[str] = mapped_column(Text, nullable=False) - normalized_url: Mapped[str] = mapped_column(Text, nullable=False) - identity_key: Mapped[str] = mapped_column(Text, nullable=False, index=True, default="") - identity_reason_codes: Mapped[str] = mapped_column(Text, nullable=False, default="[]") - identity_ruleset_version: Mapped[str] = mapped_column(Text, nullable=False, default="") - requester_email: Mapped[str] = mapped_column(Text, nullable=False) - status: Mapped[str] = mapped_column(Text, nullable=False) - priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100) - seed_blog_id: Mapped[int | None] = mapped_column(ForeignKey("blogs.blog_id", ondelete="SET NULL"), nullable=True) - matched_blog_id: Mapped[int | None] = mapped_column(ForeignKey("blogs.blog_id", ondelete="SET NULL"), nullable=True) - request_token: Mapped[str] = mapped_column(Text, nullable=False) - expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - - class UserModel(Base): """Registered user account for public personalization features. @@ -387,52 +363,3 @@ class BlogInteractionModel(Base): client_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) attributes_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - - -class BlogDedupScanRunModel(Base): - """Administrative full-library dedup scan summary.""" - - __tablename__ = "blog_dedup_scan_runs" - - id: Mapped[int] = mapped_column(primary_key=True) - status: Mapped[str] = mapped_column(Text, nullable=False) - ruleset_version: Mapped[str] = mapped_column(Text, nullable=False) - started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - duration_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - scanned_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - removed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - kept_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - crawler_was_running: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - crawler_restart_attempted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - crawler_restart_succeeded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - search_reindexed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - - -class BlogDedupScanRunItemModel(Base): - """Detailed removal records produced by one dedup scan run.""" - - __tablename__ = "blog_dedup_scan_run_items" - - id: Mapped[int] = mapped_column(primary_key=True) - run_id: Mapped[int] = mapped_column( - ForeignKey("blog_dedup_scan_runs.id", ondelete="CASCADE"), - nullable=False, - ) - survivor_blog_id: Mapped[int] = mapped_column( - ForeignKey("blogs.blog_id", ondelete="SET NULL"), - nullable=True, - ) - removed_blog_id: Mapped[int | None] = mapped_column(nullable=True) - survivor_identity_key: Mapped[str] = mapped_column(Text, nullable=False) - removed_url: Mapped[str] = mapped_column(Text, nullable=False) - removed_normalized_url: Mapped[str] = mapped_column(Text, nullable=False) - removed_domain: Mapped[str] = mapped_column(Text, nullable=False) - reason_code: Mapped[str] = mapped_column(Text, nullable=False) - reason_codes: Mapped[str] = mapped_column(Text, nullable=False, default="[]") - survivor_selection_basis: Mapped[str] = mapped_column(Text, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/persistence_api/repository.py b/persistence_api/repository.py index eb1f34c..42a9977 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -43,10 +43,7 @@ from persistence_api.models import BlogUserLabelModel from persistence_api.models import BlogUserLabelSelectionModel from persistence_api.models import BlogModel -from persistence_api.models import BlogDedupScanRunItemModel -from persistence_api.models import BlogDedupScanRunModel from persistence_api.models import EdgeModel -from persistence_api.models import IngestionRequestModel from persistence_api.models import RawDiscoveredUrlModel from persistence_api.models import RecommendationImpressionModel from persistence_api.models import RecommendationRequestModel @@ -76,7 +73,6 @@ BLOG_CATALOG_ALLOWED_ACCEPTANCE_STATUSES = frozenset( {BLOG_ACCEPTANCE_ACCEPTED, BLOG_ACCEPTANCE_UNKNOWN, "REJECTED"} ) -INGESTION_PRIORITY_LIST_LIMIT = 20 BLOG_LABELING_DEFAULT_PAGE_SIZE = 50 BLOG_LABELING_MAX_PAGE_SIZE = 200 BLOG_LABELING_DEFAULT_SORT = "id_desc" @@ -106,20 +102,6 @@ ) REPOSITORY_LOGGER_NAME = "heyblog.repository" LOGGER = get_logger(REPOSITORY_LOGGER_NAME) -INGESTION_REQUEST_STATUS_RECEIVED = "RECEIVED" -INGESTION_REQUEST_STATUS_DEDUPED_EXISTING = "DEDUPED_EXISTING" -INGESTION_REQUEST_STATUS_QUEUED = "QUEUED" -INGESTION_REQUEST_STATUS_CRAWLING_SEED = "CRAWLING_SEED" -INGESTION_REQUEST_STATUS_COMPLETED = "COMPLETED" -INGESTION_REQUEST_STATUS_FAILED = "FAILED" -INGESTION_REQUEST_STATUS_EXPIRED = "EXPIRED" -ACTIVE_INGESTION_REQUEST_STATUSES = frozenset( - { - INGESTION_REQUEST_STATUS_RECEIVED, - INGESTION_REQUEST_STATUS_QUEUED, - INGESTION_REQUEST_STATUS_CRAWLING_SEED, - } -) EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") PASSWORD_MIN_LENGTH = 8 USER_SESSION_TTL_DAYS = 30 @@ -677,10 +659,9 @@ def ensure_legacy_compat_schema(engine: Any) -> None: """Apply additive compatibility fixes needed by existing persistence databases.""" inspector = inspect(engine) existing_tables = set(inspector.get_table_names()) - if "blogs" not in existing_tables or "ingestion_requests" not in existing_tables: + if "blogs" not in existing_tables: return blog_columns = {column["name"] for column in inspector.get_columns("blogs")} - ingestion_columns = {column["name"] for column in inspector.get_columns("ingestion_requests")} with engine.begin() as connection: if "email" not in blog_columns: connection.execute(text("ALTER TABLE blogs ADD COLUMN email TEXT")) @@ -696,37 +677,8 @@ def ensure_legacy_compat_schema(engine: Any) -> None: connection.execute( text("ALTER TABLE blogs ADD COLUMN identity_ruleset_version TEXT DEFAULT '' NOT NULL") ) - if "identity_key" not in ingestion_columns: - connection.execute(text("ALTER TABLE ingestion_requests ADD COLUMN identity_key TEXT")) - if "identity_reason_codes" not in ingestion_columns: - connection.execute( - text( - "ALTER TABLE ingestion_requests ADD COLUMN identity_reason_codes TEXT DEFAULT '[]' NOT NULL" - ) - ) - if "identity_ruleset_version" not in ingestion_columns: - connection.execute( - text( - "ALTER TABLE ingestion_requests ADD COLUMN identity_ruleset_version TEXT DEFAULT '' NOT NULL" - ) - ) - if "blog_dedup_scan_runs" in existing_tables: - run_columns = {column["name"] for column in inspector.get_columns("blog_dedup_scan_runs")} - if "total_count" not in run_columns: - connection.execute( - text("ALTER TABLE blog_dedup_scan_runs ADD COLUMN total_count INTEGER DEFAULT 0 NOT NULL") - ) if "ix_blogs_identity_key" not in {index["name"] for index in inspector.get_indexes("blogs")}: connection.execute(text("CREATE INDEX IF NOT EXISTS ix_blogs_identity_key ON blogs (identity_key)")) - if "ix_ingestion_requests_identity_key" not in { - index["name"] for index in inspector.get_indexes("ingestion_requests") - }: - connection.execute( - text( - "CREATE INDEX IF NOT EXISTS ix_ingestion_requests_identity_key " - "ON ingestion_requests (identity_key)" - ) - ) if "seeds" not in existing_tables: if connection.dialect.name == "postgresql": connection.execute( @@ -1142,40 +1094,6 @@ def ensure_legacy_compat_schema(engine: Any) -> None: "domain": str(row["domain"] or identity.domain), }, ) - ingestion_rows = connection.execute( - text( - "SELECT id, requested_url, normalized_url, identity_key, identity_ruleset_version " - "FROM ingestion_requests" - ) - ).mappings().all() - for row in ingestion_rows: - needs_refresh = ( - not row["identity_key"] - or str(row["identity_ruleset_version"] or "") != IDENTITY_RULESET_VERSION - ) - if not needs_refresh: - continue - identity = resolve_blog_identity(str(row["requested_url"]) or str(row["normalized_url"])) - storage_url = ( - identity.canonical_url - if _uses_tenant_root_canonicalization(identity.reason_codes) - else normalize_url(str(row["requested_url"]) or str(row["normalized_url"])).normalized_url - ) - connection.execute( - text( - "UPDATE ingestion_requests SET identity_key = :identity_key, " - "identity_reason_codes = :reason_codes, identity_ruleset_version = :ruleset_version, " - "normalized_url = :normalized_url " - "WHERE id = :request_id" - ), - { - "request_id": row["id"], - "identity_key": identity.identity_key, - "reason_codes": _dump_reason_codes(identity.reason_codes), - "ruleset_version": identity.ruleset_version, - "normalized_url": storage_url, - }, - ) def _resolved_blog_title(model: BlogModel) -> str: title = (model.title or "").strip() @@ -1284,87 +1202,6 @@ def as_public_summary_payload(self) -> dict[str, Any]: } -@dataclass(frozen=True, slots=True) -class _IngestionRequestPayloadView: - """Hold one ingestion request plus its related blogs and expose output slices.""" - - model: IngestionRequestModel - seed_blog_view: _BlogPayloadView | None - matched_blog_view: _BlogPayloadView | None - - @classmethod - def from_model( - cls, - model: IngestionRequestModel, - *, - seed_blog: BlogModel | None = None, - matched_blog: BlogModel | None = None, - ) -> _IngestionRequestPayloadView: - """Return the resolved request view for one ingestion request row.""" - return cls( - model=model, - seed_blog_view=_BlogPayloadView.from_model(seed_blog), - matched_blog_view=_BlogPayloadView.from_model(matched_blog), - ) - - def _resolved_blog_view(self) -> _BlogPayloadView | None: - """Return the matched blog when present, otherwise the seed blog.""" - return self.matched_blog_view or self.seed_blog_view - - def _resolved_blog_id(self) -> int | None: - """Return the business id of the resolved blog used by public payloads.""" - resolved_blog_view = self._resolved_blog_view() - return resolved_blog_view.blog_id if resolved_blog_view is not None else None - - def as_full_payload(self) -> dict[str, Any]: - """Return the full ingestion request payload used by private flows.""" - resolved_blog_view = self._resolved_blog_view() - return { - "id": int(self.model.id), - "request_id": int(self.model.id), - "requested_url": self.model.requested_url, - "normalized_url": self.model.normalized_url, - "identity_key": self.model.identity_key, - "identity_reason_codes": _load_reason_codes(self.model.identity_reason_codes), - "identity_ruleset_version": self.model.identity_ruleset_version, - "email": self.model.requester_email, - "status": self.model.status, - "priority": int(self.model.priority), - "seed_blog_id": int(self.model.seed_blog_id) if self.model.seed_blog_id is not None else None, - "matched_blog_id": int(self.model.matched_blog_id) if self.model.matched_blog_id is not None else None, - "blog_id": self._resolved_blog_id(), - "request_token": self.model.request_token, - "expires_at": _iso(self.model.expires_at), - "error_message": self.model.error_message, - "created_at": _iso(self.model.created_at), - "updated_at": _iso(self.model.updated_at), - "seed_blog": self.seed_blog_view.as_blog_payload() if self.seed_blog_view is not None else None, - "matched_blog": self.matched_blog_view.as_blog_payload() if self.matched_blog_view is not None else None, - "blog": resolved_blog_view.as_blog_payload() if resolved_blog_view is not None else None, - } - - def as_priority_payload(self) -> dict[str, Any]: - """Return the public priority-list payload with private fields removed.""" - resolved_blog_view = self._resolved_blog_view() - return { - "request_id": int(self.model.id), - "requested_url": self.model.requested_url, - "normalized_url": self.model.normalized_url, - "status": self.model.status, - "seed_blog_id": int(self.model.seed_blog_id) if self.model.seed_blog_id is not None else None, - "matched_blog_id": int(self.model.matched_blog_id) if self.model.matched_blog_id is not None else None, - "blog_id": self._resolved_blog_id(), - "error_message": self.model.error_message, - "created_at": _iso(self.model.created_at), - "updated_at": _iso(self.model.updated_at), - "blog": ( - resolved_blog_view.as_public_summary_payload() - if resolved_blog_view is not None - else None - ), - } - - def _edge_payload(model: EdgeModel) -> dict[str, Any]: return { "id": int(model.id), @@ -1399,32 +1236,6 @@ def _seed_payload(model: SeedModel) -> dict[str, Any]: } -def _ingestion_request_payload( - model: IngestionRequestModel, - *, - seed_blog: BlogModel | None = None, - matched_blog: BlogModel | None = None, -) -> dict[str, Any]: - return _IngestionRequestPayloadView.from_model( - model, - seed_blog=seed_blog, - matched_blog=matched_blog, - ).as_full_payload() - - -def _priority_ingestion_request_payload( - model: IngestionRequestModel, - *, - seed_blog: BlogModel | None = None, - matched_blog: BlogModel | None = None, -) -> dict[str, Any]: - return _IngestionRequestPayloadView.from_model( - model, - seed_blog=seed_blog, - matched_blog=matched_blog, - ).as_priority_payload() - - def _blog_lookup_payload( *, query_url: str, @@ -1640,82 +1451,6 @@ def as_payload(self) -> dict[str, Any]: } -@dataclass(frozen=True, slots=True) -class _MaintenanceRunPayloadView: - """Hold the shared lifecycle facts exposed by maintenance run summaries.""" - - run_id: int - status: str - crawler_was_running: bool - started_at: datetime | None - completed_at: datetime | None - error_message: str | None - created_at: datetime | None - updated_at: datetime | None - - @classmethod - def from_model( - cls, - model: BlogDedupScanRunModel, - ) -> _MaintenanceRunPayloadView: - """Return the shared lifecycle view for one maintenance run row.""" - return cls( - run_id=int(model.id), - status=str(model.status), - crawler_was_running=bool(model.crawler_was_running), - started_at=model.started_at, - completed_at=model.completed_at, - error_message=model.error_message, - created_at=model.created_at, - updated_at=model.updated_at, - ) - - def as_payload(self) -> dict[str, Any]: - """Return the shared lifecycle payload used by maintenance run summaries.""" - return { - "id": self.run_id, - "status": self.status, - "crawler_was_running": self.crawler_was_running, - "started_at": _iso(self.started_at), - "completed_at": _iso(self.completed_at), - "error_message": self.error_message, - "created_at": _iso(self.created_at), - "updated_at": _iso(self.updated_at), - } - - -def _blog_dedup_scan_run_payload(model: BlogDedupScanRunModel) -> dict[str, Any]: - run_view = _MaintenanceRunPayloadView.from_model(model) - return run_view.as_payload() | { - "ruleset_version": model.ruleset_version, - "duration_ms": int(model.duration_ms), - "total_count": int(model.total_count), - "scanned_count": int(model.scanned_count), - "removed_count": int(model.removed_count), - "kept_count": int(model.kept_count), - "crawler_restart_attempted": bool(model.crawler_restart_attempted), - "crawler_restart_succeeded": bool(model.crawler_restart_succeeded), - "search_reindexed": bool(model.search_reindexed), - } - - -def _blog_dedup_scan_run_item_payload(model: BlogDedupScanRunItemModel) -> dict[str, Any]: - return { - "id": int(model.id), - "run_id": int(model.run_id), - "survivor_blog_id": int(model.survivor_blog_id) if model.survivor_blog_id is not None else None, - "removed_blog_id": int(model.removed_blog_id) if model.removed_blog_id is not None else None, - "survivor_identity_key": model.survivor_identity_key, - "removed_url": model.removed_url, - "removed_normalized_url": model.removed_normalized_url, - "removed_domain": model.removed_domain, - "reason_code": model.reason_code, - "reason_codes": _load_reason_codes(model.reason_codes), - "survivor_selection_basis": model.survivor_selection_basis, - "created_at": _iso(model.created_at), - } - - def _decision_scan_ruleset_version(settings: Settings) -> str: """Describe the current URL decision-chain configuration in one string. @@ -1856,11 +1591,7 @@ def upsert_blog( def list_seeds(self) -> list[dict[str, Any]]: ... - def get_next_waiting_blog(self, *, include_priority: bool = True) -> dict[str, Any] | None: ... - - def get_next_priority_blog(self) -> dict[str, Any] | None: ... - - def create_ingestion_request(self, *, homepage_url: str, email: str) -> dict[str, Any]: ... + def get_next_waiting_blog(self) -> dict[str, Any] | None: ... def create_user_seed(self, *, homepage_url: str) -> dict[str, Any]: ... @@ -1876,21 +1607,10 @@ def list_user_label_selections(self, *, user_id: int, limit: int = 50) -> list[d def count_user_label_selections(self, *, user_id: int) -> int: ... - def get_ingestion_request( - self, - *, - request_id: int, - request_token: str, - ) -> dict[str, Any] | None: ... - - def list_priority_ingestion_requests(self, *, limit: int = INGESTION_PRIORITY_LIST_LIMIT) -> list[dict[str, Any]]: ... - def lookup_blog_candidates(self, *, url: str) -> dict[str, Any]: ... def find_blog_id_by_normalized_url(self, *, normalized_url: str) -> int | None: ... - def mark_ingestion_request_crawling(self, *, blog_id: int) -> None: ... - def mark_blog_result( self, *, @@ -2050,24 +1770,6 @@ def stats(self) -> dict[str, Any]: ... def get_filter_stats_by_chain_order(self) -> dict[str, Any]: ... - def create_blog_dedup_scan_run(self, *, crawler_was_running: bool = False) -> dict[str, Any]: ... - - def execute_blog_dedup_scan_run(self, *, run_id: int) -> dict[str, Any]: ... - - def finalize_blog_dedup_scan_run( - self, - *, - run_id: int, - crawler_restart_attempted: bool, - crawler_restart_succeeded: bool, - search_reindexed: bool, - error_message: str | None = None, - ) -> dict[str, Any]: ... - - def get_latest_blog_dedup_scan_run(self) -> dict[str, Any] | None: ... - - def list_blog_dedup_scan_run_items(self, run_id: int) -> list[dict[str, Any]]: ... - def reset(self) -> dict[str, Any]: ... @@ -2088,7 +1790,6 @@ def __post_init__(self) -> None: Base.metadata.create_all(self.engine) ensure_legacy_compat_schema(self.engine) with session_scope(self.session_factory) as session: - self._fail_orphaned_dedup_scan_runs(session) self._requeue_processing(session) @property @@ -2102,29 +1803,6 @@ def _requeue_processing(self, session: Session) -> None: BlogModel.updated_at: now_utc(), } ) - session.query(IngestionRequestModel).filter( - IngestionRequestModel.status == INGESTION_REQUEST_STATUS_CRAWLING_SEED - ).update( - { - IngestionRequestModel.status: INGESTION_REQUEST_STATUS_QUEUED, - IngestionRequestModel.updated_at: now_utc(), - } - ) - - def _fail_orphaned_dedup_scan_runs(self, session: Session) -> None: - orphaned_runs = session.scalars( - select(BlogDedupScanRunModel).where(BlogDedupScanRunModel.status == "RUNNING") - ).all() - if not orphaned_runs: - return - failed_at = now_utc() - for run in orphaned_runs: - started_at = _sortable_datetime(run.started_at) - run.status = "FAILED" - run.completed_at = failed_at - run.duration_ms = max(int((failed_at - started_at).total_seconds() * 1000), 0) - run.error_message = "orphaned_dedup_scan_run_cleaned_on_startup" - run.updated_at = failed_at def _get_blog_by_business_id(self, session: Session, blog_id: int) -> BlogModel | None: """Return one blog row by business ``blog_id``.""" @@ -2219,52 +1897,6 @@ def _row_blog_payload(self, row: Any) -> dict[str, Any]: identity_complete=bool(row.identity_complete), ) - def _serialize_ingestion_request_payload( - self, - session: Session, - request: IngestionRequestModel, - *, - serializer: Callable[..., dict[str, Any]], - ) -> dict[str, Any]: - """Resolve request blogs once and pass them to the chosen serializer.""" - seed_blog, matched_blog = self._resolve_ingestion_request_blogs(session, request) - return serializer(request, seed_blog=seed_blog, matched_blog=matched_blog) - - def _serialize_ingestion_request_payloads( - self, - session: Session, - requests: list[IngestionRequestModel], - *, - serializer: Callable[..., dict[str, Any]], - ) -> list[dict[str, Any]]: - """Resolve and serialize multiple ingestion requests using the shared serializer handoff.""" - return [ - self._serialize_ingestion_request_payload( - session, - request, - serializer=serializer, - ) - for request in requests - ] - - def _resolve_ingestion_request_blogs( - self, - session: Session, - request: IngestionRequestModel, - ) -> tuple[BlogModel | None, BlogModel | None]: - """Resolve the seed and matched blogs referenced by one ingestion request.""" - seed_blog = ( - self._get_blog_by_business_id(session, request.seed_blog_id) - if request.seed_blog_id is not None - else None - ) - matched_blog = ( - self._get_blog_by_business_id(session, request.matched_blog_id) - if request.matched_blog_id is not None - else None - ) - return seed_blog, matched_blog - def _latest_row_payload( self, session: Session, @@ -2906,9 +2538,9 @@ def _delete_blog_graph(self, session: Session, *, blog_id: int) -> None: blog_id: Blog identifier that should be removed from persistence. Returns: - ``None``. The blog, its edges, and dangling ingestion references - are removed or cleared in place. URL-keyed label assignments are - intentionally preserved across graph cleanup. + ``None``. The blog and its edges are removed in place. + URL-keyed label assignments are intentionally preserved across + graph cleanup. """ edge_ids = session.scalars( select(EdgeModel.id).where( @@ -2920,12 +2552,6 @@ def _delete_blog_graph(self, session: Session, *, blog_id: int) -> None: ).all() if edge_ids: session.query(EdgeModel).filter(EdgeModel.id.in_(edge_ids)).delete(synchronize_session=False) - session.query(IngestionRequestModel).filter( - IngestionRequestModel.seed_blog_id == blog_id - ).update({IngestionRequestModel.seed_blog_id: None}) - session.query(IngestionRequestModel).filter( - IngestionRequestModel.matched_blog_id == blog_id - ).update({IngestionRequestModel.matched_blog_id: None}) blog = self._get_blog_by_business_id(session, blog_id) if blog is not None: session.delete(blog) @@ -3040,114 +2666,6 @@ def list_seeds(self) -> list[dict[str, Any]]: serializer=_seed_payload, ) - def create_ingestion_request(self, *, homepage_url: str, email: str) -> dict[str, Any]: - requested_url, normalized_url, domain, identity_key, reason_codes, ruleset_version = normalize_homepage_url( - homepage_url - ) - normalized_email = normalize_ingestion_email(email) - with session_scope(self.session_factory) as session: - existing_blog = session.scalar( - select(BlogModel).where(BlogModel.identity_key == identity_key) - ) - if existing_blog is not None and not (existing_blog.email or "").strip(): - existing_blog.email = normalized_email - if existing_blog is not None: - if _uses_tenant_root_canonicalization(reason_codes): - existing_blog.url = normalized_url - existing_blog.normalized_url = normalized_url - existing_blog.domain = domain - existing_blog.identity_key = identity_key - existing_blog.identity_reason_codes = _dump_reason_codes(reason_codes) - existing_blog.identity_ruleset_version = ruleset_version - existing_blog.updated_at = now_utc() - - if existing_blog is not None and existing_blog.crawl_status == CrawlStatus.FINISHED: - existing_blog_view = _BlogPayloadView.from_model(existing_blog) - return { - "status": INGESTION_REQUEST_STATUS_DEDUPED_EXISTING, - "blog_id": int(_business_blog_id(existing_blog)), - "matched_blog_id": int(_business_blog_id(existing_blog)), - "request_id": None, - "request_token": None, - "blog": existing_blog_view.as_blog_payload() if existing_blog_view is not None else None, - } - - existing_request = self._oldest_ingestion_request( - session, - filters=(IngestionRequestModel.identity_key == identity_key,), - statuses=tuple(ACTIVE_INGESTION_REQUEST_STATUSES), - ) - if existing_request is not None: - if not (existing_request.requester_email or "").strip(): - existing_request.requester_email = normalized_email - if _uses_tenant_root_canonicalization(reason_codes): - existing_request.normalized_url = normalized_url - existing_request.identity_key = identity_key - existing_request.identity_reason_codes = _dump_reason_codes(reason_codes) - existing_request.identity_ruleset_version = ruleset_version - existing_request.updated_at = now_utc() - return self._serialize_ingestion_request_payload( - session, - existing_request, - serializer=_ingestion_request_payload, - ) - - if existing_blog is None: - existing_blog = BlogModel( - blog_id=None, - url=normalized_url, - normalized_url=normalized_url, - identity_key=identity_key, - identity_reason_codes=_dump_reason_codes(reason_codes), - identity_ruleset_version=ruleset_version, - domain=domain, - email=normalized_email, - acceptance_status=BLOG_ACCEPTANCE_ACCEPTED, - accepted_by="seed", - accepted_at=now_utc(), - crawl_status=CrawlStatus.WAITING, - friend_links_count=0, - created_at=now_utc(), - updated_at=now_utc(), - ) - session.add(existing_blog) - session.flush() - existing_blog.blog_id = int(existing_blog.id) - session.flush() - elif existing_blog.crawl_status == CrawlStatus.FAILED: - existing_blog.crawl_status = CrawlStatus.WAITING - existing_blog.updated_at = now_utc() - - request_status = ( - INGESTION_REQUEST_STATUS_CRAWLING_SEED - if existing_blog.crawl_status == CrawlStatus.PROCESSING - else INGESTION_REQUEST_STATUS_QUEUED - ) - request = IngestionRequestModel( - requested_url=requested_url, - normalized_url=normalized_url, - identity_key=identity_key, - identity_reason_codes=_dump_reason_codes(reason_codes), - identity_ruleset_version=ruleset_version, - requester_email=normalized_email, - status=request_status, - priority=100, - seed_blog_id=int(_business_blog_id(existing_blog)), - matched_blog_id=None, - request_token=token_urlsafe(18), - expires_at=None, - error_message=None, - created_at=now_utc(), - updated_at=now_utc(), - ) - session.add(request) - session.flush() - return self._serialize_ingestion_request_payload( - session, - request, - serializer=_ingestion_request_payload, - ) - def create_user_seed(self, *, homepage_url: str) -> dict[str, Any]: """Accept one user-submitted URL as a crawler seed after rule checks. @@ -3361,43 +2879,6 @@ def revoke_user_session(self, *, token: str) -> bool: session.flush() return True - def get_ingestion_request( - self, - *, - request_id: int, - request_token: str, - ) -> dict[str, Any] | None: - with session_scope(self.session_factory) as session: - request = session.scalar( - select(IngestionRequestModel).where(IngestionRequestModel.id == request_id) - ) - if request is None or request.request_token != request_token: - return None - return self._serialize_ingestion_request_payload( - session, - request, - serializer=_ingestion_request_payload, - ) - - def list_priority_ingestion_requests(self, *, limit: int = INGESTION_PRIORITY_LIST_LIMIT) -> list[dict[str, Any]]: - resolved_limit = max(1, min(int(limit), INGESTION_PRIORITY_LIST_LIMIT)) - active_sort = case( - (IngestionRequestModel.status.in_(tuple(ACTIVE_INGESTION_REQUEST_STATUSES)), 0), - else_=1, - ) - with session_scope(self.session_factory) as session: - requests = session.scalars( - select(IngestionRequestModel) - .where(IngestionRequestModel.priority >= 100) - .order_by(active_sort.asc(), IngestionRequestModel.created_at.desc(), IngestionRequestModel.id.desc()) - .limit(resolved_limit) - ).all() - return self._serialize_ingestion_request_payloads( - session, - requests, - serializer=_priority_ingestion_request_payload, - ) - def lookup_blog_candidates(self, *, url: str) -> dict[str, Any]: normalized = normalize_url(url) identity = resolve_blog_identity(url) @@ -3443,18 +2924,6 @@ def lookup_blog_candidates(self, *, url: str) -> dict[str, Any]: match_reason=None, ) - def mark_ingestion_request_crawling(self, *, blog_id: int) -> None: - with session_scope(self.session_factory) as session: - request = self._oldest_seed_ingestion_request( - session, - blog_id=blog_id, - statuses=(INGESTION_REQUEST_STATUS_QUEUED,), - ) - if request is None: - return - request.status = INGESTION_REQUEST_STATUS_CRAWLING_SEED - request.updated_at = now_utc() - def _claim_blog_for_statement(self, session: Session, statement: Any) -> dict[str, Any] | None: blog = session.scalar(statement) if blog is None: @@ -3471,43 +2940,26 @@ def _claim_first_matching_blog(self, session: Session, statement: Any) -> dict[s statement = statement.with_for_update(skip_locked=True) return self._claim_blog_for_statement(session, statement) - def _active_ingestion_seed_ids_statement(self) -> Any: - """Return the active ingestion seed ids used to exclude priority-backed blogs.""" - return select(IngestionRequestModel.seed_blog_id).where( - IngestionRequestModel.seed_blog_id.is_not(None), - IngestionRequestModel.status.in_(tuple(ACTIVE_INGESTION_REQUEST_STATUSES)), - ) + def get_next_waiting_blog(self) -> dict[str, Any] | None: + """Claim the next ordinary waiting blog for crawler processing. - def _oldest_ingestion_request( - self, - session: Session, - *, - filters: tuple[ColumnElement[bool], ...], - statuses: tuple[str, ...], - ) -> IngestionRequestModel | None: - """Return the oldest ingestion request matching the given filters and statuses.""" - return session.scalar( - select(IngestionRequestModel) - .where( - *filters, - IngestionRequestModel.status.in_(statuses), - ) - .order_by(IngestionRequestModel.created_at.asc(), IngestionRequestModel.id.asc()) - ) + Args: + None. - def _oldest_seed_ingestion_request( - self, - session: Session, - *, - blog_id: int, - statuses: tuple[str, ...], - ) -> IngestionRequestModel | None: - """Return the oldest ingestion request for one seed blog within the allowed statuses.""" - return self._oldest_ingestion_request( - session, - filters=(IngestionRequestModel.seed_blog_id == blog_id,), - statuses=statuses, - ) + Returns: + Serialized blog payload for the claimed row, or ``None`` when no + `WAITING` blog is available. The claimed row is immediately moved + to `PROCESSING`. + """ + + with session_scope(self.session_factory) as session: + statement = ( + select(BlogModel) + .where(BlogModel.crawl_status == CrawlStatus.WAITING) + .order_by(BlogModel.id.asc()) + .limit(1) + ) + return self._claim_first_matching_blog(session, statement) def _lookup_blog_matches( self, @@ -3533,47 +2985,6 @@ def _lookup_blog_matches( match_reason=match_reason, ) - def _priority_blog_claim_statement(self) -> Any: - """Build the priority queue statement without changing claim semantics.""" - return ( - select(BlogModel) - .join( - IngestionRequestModel, - IngestionRequestModel.seed_blog_id == BlogModel.blog_id, - ) - .where( - BlogModel.crawl_status == CrawlStatus.WAITING, - IngestionRequestModel.status == INGESTION_REQUEST_STATUS_QUEUED, - ) - .order_by( - IngestionRequestModel.priority.desc(), - IngestionRequestModel.created_at.asc(), - BlogModel.blog_id.asc(), - BlogModel.id.asc(), - ) - .limit(1) - ) - - def _waiting_blog_claim_statement(self, *, include_priority: bool) -> Any: - """Build the waiting queue statement while preserving priority exclusion semantics.""" - statement = select(BlogModel).where(BlogModel.crawl_status == CrawlStatus.WAITING) - if not include_priority: - statement = statement.where( - BlogModel.blog_id.not_in(self._active_ingestion_seed_ids_statement()) - ) - return statement.order_by(BlogModel.blog_id.asc(), BlogModel.id.asc()).limit(1) - - def get_next_priority_blog(self) -> dict[str, Any] | None: - with session_scope(self.session_factory) as session: - return self._claim_first_matching_blog(session, self._priority_blog_claim_statement()) - - def get_next_waiting_blog(self, *, include_priority: bool = True) -> dict[str, Any] | None: - with session_scope(self.session_factory) as session: - return self._claim_first_matching_blog( - session, - self._waiting_blog_claim_statement(include_priority=include_priority), - ) - def requeue_failed_blogs(self) -> dict[str, Any]: """Move every failed blog back into the waiting crawl queue. @@ -3592,17 +3003,6 @@ def requeue_failed_blogs(self) -> dict[str, Any]: blog.crawl_error_message = None blog.updated_at = timestamp - requests = session.scalars( - select(IngestionRequestModel).where( - IngestionRequestModel.seed_blog_id.in_([int(_business_blog_id(blog)) for blog in blogs]), - IngestionRequestModel.status == INGESTION_REQUEST_STATUS_FAILED, - ) - ).all() - for request in requests: - request.status = INGESTION_REQUEST_STATUS_QUEUED - request.error_message = None - request.updated_at = timestamp - return {"requeued": requeued_count} def mark_blog_result( @@ -3640,20 +3040,6 @@ def mark_blog_result( if metadata_captured: blog.title = title blog.icon_url = icon_url - request = self._oldest_seed_ingestion_request( - session, - blog_id=blog_id, - statuses=tuple(ACTIVE_INGESTION_REQUEST_STATUSES), - ) - if request is not None: - if blog.crawl_status == CrawlStatus.FINISHED: - request.status = INGESTION_REQUEST_STATUS_COMPLETED - request.matched_blog_id = int(_business_blog_id(blog)) - request.error_message = None - elif blog.crawl_status == CrawlStatus.FAILED: - request.status = INGESTION_REQUEST_STATUS_FAILED - request.error_message = "seed crawl failed" - request.updated_at = now_utc() def add_edge( self, @@ -5293,194 +4679,10 @@ def get_filter_stats_by_chain_order(self) -> dict[str, Any]: "funnel": funnel, } - def create_blog_dedup_scan_run(self, *, crawler_was_running: bool = False) -> dict[str, Any]: - started_at = now_utc() - settings = self._decision_scan_settings() - with session_scope(self.session_factory) as session: - total_count = _count_selectable_rows(session, BlogModel) - run = BlogDedupScanRunModel( - status="RUNNING", - ruleset_version=_decision_scan_ruleset_version(settings), - started_at=started_at, - completed_at=None, - duration_ms=0, - total_count=total_count, - scanned_count=0, - removed_count=0, - kept_count=0, - crawler_was_running=crawler_was_running, - crawler_restart_attempted=False, - crawler_restart_succeeded=False, - search_reindexed=False, - error_message=None, - created_at=started_at, - updated_at=started_at, - ) - session.add(run) - session.flush() - return _blog_dedup_scan_run_payload(run) - - def execute_blog_dedup_scan_run(self, *, run_id: int) -> dict[str, Any]: - started_at = now_utc() - settings = self._decision_scan_settings() - decision_chain = build_url_decision_chain(settings) - try: - with session_scope(self.session_factory) as session: - run = self._require_model( - session, - BlogDedupScanRunModel, - run_id, - not_found_error="blog_dedup_scan_run_not_found", - ) - run.status = "RUNNING" - run.started_at = run.started_at or started_at - run.completed_at = None - run.duration_ms = 0 - run.scanned_count = 0 - run.removed_count = 0 - run.kept_count = 0 - run.error_message = None - run.updated_at = started_at - blog_rows = session.execute( - select( - BlogModel.blog_id, - BlogModel.url, - BlogModel.domain, - BlogModel.identity_key, - ) - .order_by(BlogModel.blog_id.asc(), BlogModel.id.asc()) - ).all() - run.total_count = len(blog_rows) - - scanned_count = 0 - rejected_blog_count = 0 - for blog_row in blog_rows: - with session_scope(self.session_factory) as session: - run = self._require_model( - session, - BlogDedupScanRunModel, - run_id, - not_found_error="blog_dedup_scan_run_not_found", - ) - blog = self._get_blog_by_business_id(session, int(blog_row.blog_id)) - if blog is None: - continue - decision = decision_chain.decide( - str(blog.url or ""), - "", - link_text=str(blog.domain or ""), - context_text="", - ) - if not decision.accepted: - session.add( - BlogDedupScanRunItemModel( - run_id=int(run.id), - survivor_blog_id=None, - removed_blog_id=int(_business_blog_id(blog)), - survivor_identity_key=str(blog.identity_key or ""), - removed_url=str(blog.url or ""), - removed_normalized_url=str(blog.normalized_url or blog.url or ""), - removed_domain=str(blog.domain or ""), - reason_code=decision.reasons[0] if decision.reasons else "decision_rejected", - reason_codes=_dump_reason_codes(list(decision.reasons)), - survivor_selection_basis=( - f"scanned_blog_id={int(_business_blog_id(blog))}, " - f"decision_score={decision.score:.6f}" - ), - created_at=now_utc(), - ) - ) - self._delete_blog_graph(session, blog_id=int(_business_blog_id(blog))) - rejected_blog_count += 1 - - scanned_count += 1 - completed_so_far = now_utc() - run.scanned_count = scanned_count - run.removed_count = rejected_blog_count - run.kept_count = max(run.total_count - rejected_blog_count, 0) - run.duration_ms = max(int((completed_so_far - started_at).total_seconds() * 1000), 0) - run.updated_at = completed_so_far - - with session_scope(self.session_factory) as session: - run = self._require_model( - session, - BlogDedupScanRunModel, - run_id, - not_found_error="blog_dedup_scan_run_not_found", - ) - completed_at = now_utc() - final_blog_count = _count_selectable_rows(session, BlogModel) - run.status = "SUCCEEDED" - run.completed_at = completed_at - run.duration_ms = max(int((completed_at - started_at).total_seconds() * 1000), 0) - run.scanned_count = scanned_count - run.removed_count = max(run.total_count - final_blog_count, 0) - run.kept_count = final_blog_count - run.updated_at = completed_at - session.flush() - return _blog_dedup_scan_run_payload(run) - except Exception as exc: - with session_scope(self.session_factory) as session: - run = session.get(BlogDedupScanRunModel, run_id) - if run is not None: - completed_at = now_utc() - run.status = "FAILED" - run.completed_at = completed_at - run.duration_ms = max(int((completed_at - started_at).total_seconds() * 1000), 0) - run.error_message = str(exc) - run.updated_at = completed_at - raise - - def finalize_blog_dedup_scan_run( - self, - *, - run_id: int, - crawler_restart_attempted: bool, - crawler_restart_succeeded: bool, - search_reindexed: bool, - error_message: str | None = None, - ) -> dict[str, Any]: - with session_scope(self.session_factory) as session: - run = self._require_model( - session, - BlogDedupScanRunModel, - run_id, - not_found_error="blog_dedup_scan_run_not_found", - ) - run.crawler_restart_attempted = crawler_restart_attempted - run.crawler_restart_succeeded = crawler_restart_succeeded - run.search_reindexed = search_reindexed - if error_message: - run.error_message = error_message - run.updated_at = now_utc() - session.flush() - return _blog_dedup_scan_run_payload(run) - - def get_latest_blog_dedup_scan_run(self) -> dict[str, Any] | None: - with session_scope(self.session_factory) as session: - return self._latest_row_payload( - session, - statement=select(BlogDedupScanRunModel).order_by(BlogDedupScanRunModel.id.desc()).limit(1), - serializer=_blog_dedup_scan_run_payload, - ) - - def list_blog_dedup_scan_run_items(self, run_id: int) -> list[dict[str, Any]]: - with session_scope(self.session_factory) as session: - return self._ordered_row_payloads( - session, - statement=( - select(BlogDedupScanRunItemModel) - .where(BlogDedupScanRunItemModel.run_id == run_id) - .order_by(BlogDedupScanRunItemModel.id.asc()) - ), - serializer=_blog_dedup_scan_run_item_payload, - ) - def reset(self) -> dict[str, Any]: with session_scope(self.session_factory) as session: blogs_deleted = _count_selectable_rows(session, BlogModel) edges_deleted = _count_selectable_rows(session, EdgeModel) - requests_deleted = _count_selectable_rows(session, IngestionRequestModel) users_preserved = _count_selectable_rows(session, UserModel) user_sessions_preserved = _count_selectable_rows(session, UserSessionModel) labels_preserved = _count_selectable_rows(session, BlogLabelModel) @@ -5492,16 +4694,11 @@ def reset(self) -> dict[str, Any]: recommendation_interactions_deleted = _count_selectable_rows(session, BlogInteractionModel) recommendation_impressions_deleted = _count_selectable_rows(session, RecommendationImpressionModel) recommendation_requests_deleted = _count_selectable_rows(session, RecommendationRequestModel) - scan_items_deleted = _count_selectable_rows(session, BlogDedupScanRunItemModel) - scan_runs_deleted = _count_selectable_rows(session, BlogDedupScanRunModel) session.query(SeedModel).update({SeedModel.blog_id: None}) session.query(BlogInteractionModel).delete() session.query(RecommendationImpressionModel).delete() session.query(RecommendationRequestModel).delete() - session.query(BlogDedupScanRunItemModel).delete() - session.query(BlogDedupScanRunModel).delete() session.query(RawDiscoveredUrlModel).delete() - session.query(IngestionRequestModel).delete() session.query(EdgeModel).delete() session.query(BlogModel).delete() return { @@ -5509,7 +4706,6 @@ def reset(self) -> dict[str, Any]: "blogs_deleted": blogs_deleted, "edges_deleted": edges_deleted, "logs_deleted": 0, - "ingestion_requests_deleted": requests_deleted, "users_preserved": users_preserved, "user_sessions_preserved": user_sessions_preserved, "blog_link_labels_deleted": 0, @@ -5525,8 +4721,6 @@ def reset(self) -> dict[str, Any]: "recommendation_interactions_deleted": recommendation_interactions_deleted, "recommendation_impressions_deleted": recommendation_impressions_deleted, "recommendation_requests_deleted": recommendation_requests_deleted, - "blog_dedup_scan_items_deleted": scan_items_deleted, - "blog_dedup_scan_runs_deleted": scan_runs_deleted, } diff --git a/shared/config.py b/shared/config.py index af51b2f..00502e0 100644 --- a/shared/config.py +++ b/shared/config.py @@ -14,7 +14,6 @@ DEFAULT_MAX_CANDIDATE_LINKS_PER_PAGE = 50 DEFAULT_CANDIDATE_PAGE_FETCH_CONCURRENCY = 4 DEFAULT_RUNTIME_WORKER_COUNT = 3 -DEFAULT_PRIORITY_SEED_NORMAL_QUEUE_SLOTS = 2 DEFAULT_MAX_FETCHED_PAGE_BYTES = 2_000_000 DEFAULT_RAW_DISCOVERED_URL_LIMIT = 1_000_000 PROJECT_ROOT = Path(__file__).resolve().parent.parent @@ -106,7 +105,6 @@ class Settings: max_candidate_links_per_page: int = DEFAULT_MAX_CANDIDATE_LINKS_PER_PAGE candidate_page_fetch_concurrency: int = DEFAULT_CANDIDATE_PAGE_FETCH_CONCURRENCY runtime_worker_count: int = DEFAULT_RUNTIME_WORKER_COUNT - priority_seed_normal_queue_slots: int = DEFAULT_PRIORITY_SEED_NORMAL_QUEUE_SLOTS max_fetched_page_bytes: int = DEFAULT_MAX_FETCHED_PAGE_BYTES raw_discovered_url_limit: int = DEFAULT_RAW_DISCOVERED_URL_LIMIT friend_link_domain_blocklist: tuple[str, ...] = () @@ -202,15 +200,6 @@ def from_env(cls) -> "Settings": ) ), ), - priority_seed_normal_queue_slots=max( - 1, - int( - os.getenv( - "HEYBLOG_PRIORITY_SEED_NORMAL_QUEUE_SLOTS", - str(DEFAULT_PRIORITY_SEED_NORMAL_QUEUE_SLOTS), - ) - ), - ), max_fetched_page_bytes=max( 1, int( diff --git a/shared/http_clients/persistence_http.py b/shared/http_clients/persistence_http.py index df41c76..2d95764 100644 --- a/shared/http_clients/persistence_http.py +++ b/shared/http_clients/persistence_http.py @@ -324,15 +324,6 @@ def get_recommendation_strategy_stats(self) -> dict[str, Any]: return self._get("/internal/recommendation-stats") - def create_ingestion_request(self, *, homepage_url: str, email: str) -> dict[str, Any]: - return self._post( - "/internal/ingestion-requests", - { - "homepage_url": homepage_url, - "email": email, - }, - ) - def create_user_seed(self, *, homepage_url: str) -> dict[str, Any]: """Create or refresh a user-submitted crawler seed. @@ -396,20 +387,6 @@ def get_user_label_stats(self, *, user_id: int) -> dict[str, int]: return self._get(f"/internal/users/{user_id}/label-stats") - def get_ingestion_request( - self, - *, - request_id: int, - request_token: str, - ) -> dict[str, Any] | None: - return self._get( - f"/internal/ingestion-requests/{request_id}", - {"request_token": request_token}, - ) - - def list_priority_ingestion_requests(self) -> list[dict[str, Any]]: - return self._get("/internal/ingestion-requests") - def lookup_blog_candidates(self, *, url: str) -> dict[str, Any]: return self._get("/internal/blogs/lookup", {"url": url}) @@ -420,56 +397,8 @@ def find_blog_id_by_normalized_url(self, *, normalized_url: str) -> int | None: blog_id = payload.get("id") return int(blog_id) if blog_id is not None else None - def create_blog_dedup_scan_run(self, *, crawler_was_running: bool = False) -> dict[str, Any]: - return self._create_maintenance_run( - "/internal/blog-dedup-scans/runs", - crawler_was_running=crawler_was_running, - ) - - def execute_blog_dedup_scan_run(self, *, run_id: int) -> dict[str, Any]: - return self._post_maintenance_run_action( - "/internal/blog-dedup-scans", - run_id=run_id, - action="execute", - ) - - def finalize_blog_dedup_scan_run( - self, - *, - run_id: int, - crawler_restart_attempted: bool, - crawler_restart_succeeded: bool, - search_reindexed: bool, - error_message: str | None = None, - ) -> dict[str, Any]: - return self._post( - f"/internal/blog-dedup-scans/{run_id}/finalize", - { - "crawler_restart_attempted": crawler_restart_attempted, - "crawler_restart_succeeded": crawler_restart_succeeded, - "search_reindexed": search_reindexed, - "error_message": error_message, - }, - ) - - def latest_blog_dedup_scan_run(self) -> dict[str, Any]: - return self._get_latest_maintenance_run("/internal/blog-dedup-scans") - - def list_blog_dedup_scan_run_items(self, run_id: int) -> list[dict[str, Any]]: - return self._list_maintenance_run_children( - "/internal/blog-dedup-scans", - run_id=run_id, - child_resource="items", - ) - - def get_next_priority_blog(self) -> dict[str, Any] | None: - return self._get("/internal/queue/priority-next") - - def get_next_waiting_blog(self, *, include_priority: bool = True) -> dict[str, Any] | None: - return self._get("/internal/queue/next", {"include_priority": self._bool_query_value(include_priority)}) - - def mark_ingestion_request_crawling(self, *, blog_id: int) -> None: - self._post(f"/internal/ingestion-requests/by-blog/{blog_id}/crawling", {}) + def get_next_waiting_blog(self) -> dict[str, Any] | None: + return self._get("/internal/queue/next") def mark_blog_result( self, diff --git a/tests/test_repository.py b/tests/test_repository.py index fcb5733..da05eb3 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -18,7 +18,6 @@ from persistence_api.models import BlogLabelTagModel from persistence_api.models import BlogInteractionModel from persistence_api.models import BlogModel -from persistence_api.models import IngestionRequestModel from persistence_api.models import RawDiscoveredUrlModel from persistence_api.models import RecommendationImpressionModel from persistence_api.models import RecommendationRequestModel @@ -116,11 +115,8 @@ def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) - assert result["edges_deleted"] == 1 assert result["logs_deleted"] == 0 assert result["seeds_preserved"] == 1 - assert result["ingestion_requests_deleted"] == 0 assert result["blog_link_labels_deleted"] == 0 assert result["blog_label_tags_deleted"] == 0 - assert result["blog_dedup_scan_items_deleted"] == 0 - assert result["blog_dedup_scan_runs_deleted"] == 0 assert result["recommendation_interactions_deleted"] == 0 assert result["recommendation_impressions_deleted"] == 0 assert result["recommendation_requests_deleted"] == 0 @@ -299,73 +295,6 @@ def test_repository_defaults_blog_email_to_none(tmp_path: Path) -> None: assert blog["email"] is None -def test_repository_creates_ingestion_request_and_persists_blog_email(tmp_path: Path) -> None: - """Self-serve ingestion should capture the requester email onto the seed blog.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - - created = repository.create_ingestion_request( - homepage_url="https://blog.example.com/", - email="owner@example.com", - ) - - assert created["status"] == "QUEUED" - assert created["request_id"] == created["id"] - assert created["email"] == "owner@example.com" - assert created["blog"]["email"] == "owner@example.com" - - fetched = repository.get_ingestion_request( - request_id=created["request_id"], - request_token=created["request_token"], - ) - assert fetched is not None - assert fetched["normalized_url"] == "https://blog.example.com/" - assert fetched["seed_blog_id"] == created["seed_blog_id"] - assert fetched["seed_blog"]["blog_id"] == created["seed_blog_id"] - - -def test_repository_dedupes_ingestion_request_by_normalized_url(tmp_path: Path) -> None: - """Repeated requests for the same blog should reuse one active ingestion request.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - - first = repository.create_ingestion_request( - homepage_url="https://blog.example.com/?utm_source=test", - email="owner@example.com", - ) - second = repository.create_ingestion_request( - homepage_url="https://blog.example.com/", - email="owner@example.com", - ) - - assert first["request_id"] == second["request_id"] - assert len(repository.list_blogs()) == 1 - - -def test_repository_dedupes_existing_finished_blog_before_creating_request(tmp_path: Path) -> None: - """Already-finished blogs should short-circuit to a DEDUPED_EXISTING response.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - blog_id, inserted = repository.upsert_blog( - url="https://blog.example.com/", - normalized_url="https://blog.example.com/", - domain="blog.example.com", - ) - assert inserted is True - repository.mark_blog_result( - blog_id=blog_id, - crawl_status="FINISHED", - status_code=200, - friend_links_count=0, - ) - - response = repository.create_ingestion_request( - homepage_url="https://blog.example.com/", - email="owner@example.com", - ) - - assert response["status"] == "DEDUPED_EXISTING" - assert response["blog_id"] == blog_id - assert response["request_id"] is None - - def test_repository_filter_stats_follow_configured_chain_order(tmp_path: Path) -> None: """Filter stats should report remaining counts in configured filter order.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") @@ -579,165 +508,6 @@ def test_retired_label_assignment_migration_reports_single_table_rows(tmp_path: -def test_repository_dedupes_ingestion_request_by_identity_key_but_keeps_history(tmp_path: Path) -> None: - """Alias URLs should reuse one active request, but completed history must not block a new request.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - - first = repository.create_ingestion_request( - homepage_url="https://langhai.cc/", - email="owner@example.com", - ) - second = repository.create_ingestion_request( - homepage_url="http://blog.langhai.cc/index.html", - email="owner@example.com", - ) - - assert first["request_id"] == second["request_id"] - assert first["identity_key"] == "site:langhai.cc/" - - repository.mark_blog_result( - blog_id=first["seed_blog_id"], - crawl_status="FINISHED", - status_code=200, - friend_links_count=0, - ) - - third = repository.create_ingestion_request( - homepage_url="http://www.langhai.cc/", - email="owner@example.com", - ) - - assert third["request_id"] is None - assert third["status"] == "DEDUPED_EXISTING" - assert len(repository.list_blogs()) == 1 - - -def test_repository_run_blog_dedup_scan_removes_rejected_links_and_orphaned_targets( - tmp_path: Path, -) -> None: - """Admin rescan should drop persisted blog URLs rejected by the current decision chain.""" - settings = Settings( - db_path=tmp_path / "db.sqlite", - seed_path=tmp_path / "seed.csv", - export_dir=tmp_path / "exports", - friend_link_exact_url_blocklist=("https://rejected.example/",), - decision_model_consensus_enabled=False, - ) - repository = repository_module.build_repository(db_path=settings.db_path, settings=settings) - source_id, inserted = repository.upsert_blog( - url="https://source.example/", - normalized_url="https://source.example/", - domain="source.example", - ) - assert inserted is True - target_id, inserted = repository.upsert_blog( - url="https://rejected.example/", - normalized_url="https://rejected.example/", - domain="rejected.example", - ) - assert inserted is True - - with session_scope(repository.session_factory) as session: - session.add( - BlogLabelModel( - normalized_url="https://rejected.example/", - label_id={"1": 1}, - created_time=repository_module.now_utc(), - updated_time=repository_module.now_utc(), - ) - ) - - repository.add_edge( - from_blog_id=source_id, - to_blog_id=target_id, - link_url_raw="https://rejected.example/", - link_text="Rejected", - ) - - run = repository.create_blog_dedup_scan_run(crawler_was_running=True) - summary = repository.execute_blog_dedup_scan_run(run_id=int(run["id"])) - items = repository.list_blog_dedup_scan_run_items(summary["id"]) - blogs = repository.list_blogs() - - assert summary["status"] == "SUCCEEDED" - assert summary["crawler_was_running"] is True - assert summary["total_count"] == 2 - assert summary["scanned_count"] == 2 - assert summary["removed_count"] == 1 - assert summary["kept_count"] == 1 - assert repository.list_edges() == [] - assert [blog["id"] for blog in blogs] == [source_id] - assert len(items) == 1 - assert items[0]["survivor_blog_id"] is None - assert items[0]["removed_blog_id"] == target_id - assert items[0]["removed_url"] == "https://rejected.example/" - assert items[0]["reason_code"] == "exact_url_blocked" - - -def test_repository_dedup_scan_keeps_valid_blog_urls(tmp_path: Path) -> None: - """Rescan should preserve persisted blogs whose own URLs still pass the chain.""" - settings = Settings( - db_path=tmp_path / "db.sqlite", - seed_path=tmp_path / "seed.csv", - export_dir=tmp_path / "exports", - friend_link_exact_url_blocklist=("https://blocked.example/",), - decision_model_consensus_enabled=False, - ) - repository = repository_module.build_repository(db_path=settings.db_path, settings=settings) - first_source_id, inserted = repository.upsert_blog( - url="https://source-a.example/", - normalized_url="https://source-a.example/", - domain="source-a.example", - ) - assert inserted is True - second_source_id, inserted = repository.upsert_blog( - url="https://source-b.example/", - normalized_url="https://source-b.example/", - domain="source-b.example", - ) - assert inserted is True - target_id, inserted = repository.upsert_blog( - url="https://blocked.example/", - normalized_url="https://blocked.example/", - domain="blocked.example", - ) - assert inserted is True - survivor_id, inserted = repository.upsert_blog( - url="https://friend.example/", - normalized_url="https://friend.example/", - domain="friend.example", - ) - assert inserted is True - - repository.add_edge( - from_blog_id=first_source_id, - to_blog_id=survivor_id, - link_url_raw="https://friend.example/", - link_text="Canonical", - ) - repository.add_edge( - from_blog_id=second_source_id, - to_blog_id=target_id, - link_url_raw="https://blocked.example/", - link_text="Blocked", - ) - - run = repository.create_blog_dedup_scan_run(crawler_was_running=False) - summary = repository.execute_blog_dedup_scan_run(run_id=int(run["id"])) - items = repository.list_blog_dedup_scan_run_items(summary["id"]) - blogs = repository.list_blogs() - edges = repository.list_edges() - - assert summary["total_count"] == 4 - assert summary["scanned_count"] == 4 - assert summary["removed_count"] == 1 - assert summary["kept_count"] == 3 - assert len(items) == 1 - assert items[0]["removed_url"] == "https://blocked.example/" - assert [edge["link_url_raw"] for edge in edges] == ["https://friend.example/"] - assert {blog["id"] for blog in blogs} == {first_source_id, second_source_id, survivor_id} - - def test_repository_upsert_blog_collapses_tenant_like_subdomains_to_root_url(tmp_path: Path) -> None: """Tenant-like homepage subdomains should persist as one canonical root blog URL.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") @@ -766,145 +536,6 @@ def test_repository_upsert_blog_collapses_tenant_like_subdomains_to_root_url(tmp assert "tenant_subdomain_collapsed" in blog["identity_reason_codes"] -def test_repository_ingestion_request_reuses_tenant_like_root_identity(tmp_path: Path) -> None: - """Tenant-like subdomains should share one queued seed blog/request identity.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - - first = repository.create_ingestion_request( - homepage_url="https://zhuruilei.66law.cn/", - email="first@example.com", - ) - second = repository.create_ingestion_request( - homepage_url="https://lichenlvs.66law.cn/", - email="second@example.com", - ) - - assert first["status"] == "QUEUED" - assert second["status"] == "QUEUED" - assert second["request_id"] == first["request_id"] - assert second["seed_blog_id"] == first["seed_blog_id"] - assert second["identity_key"] == "site:66law.cn/" - - blog = repository.get_blog(int(first["seed_blog_id"])) - assert blog is not None - assert blog["blog_id"] == first["seed_blog_id"] - assert blog["url"] == "https://66law.cn/" - assert blog["normalized_url"] == "https://66law.cn/" - assert blog["domain"] == "66law.cn" - - -def test_repository_reused_tenant_like_ingestion_request_is_canonicalized_to_root_url(tmp_path: Path) -> None: - """Reused active requests should rewrite legacy tenant normalized_url to the registrable root URL.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - - with session_scope(repository.session_factory) as session: - seed = BlogModel( - url="https://66law.cn/", - normalized_url="https://66law.cn/", - identity_key="site:66law.cn/", - identity_reason_codes='["scheme_ignored"]', - identity_ruleset_version=repository_module.IDENTITY_RULESET_VERSION, - domain="66law.cn", - email=None, - title=None, - icon_url=None, - status_code=None, - crawl_status=CrawlStatus.WAITING, - friend_links_count=0, - created_at=repository_module.now_utc(), - updated_at=repository_module.now_utc(), - ) - session.add(seed) - session.flush() - request = IngestionRequestModel( - requested_url="https://zhuruilei.66law.cn/", - normalized_url="https://zhuruilei.66law.cn/", - identity_key="site:66law.cn/", - identity_reason_codes='["scheme_ignored"]', - identity_ruleset_version=repository_module.IDENTITY_RULESET_VERSION, - requester_email="existing@example.com", - status="QUEUED", - priority=100, - seed_blog_id=int(seed.id), - matched_blog_id=None, - request_token="legacy-token", - expires_at=None, - error_message=None, - created_at=repository_module.now_utc(), - updated_at=repository_module.now_utc(), - ) - session.add(request) - session.flush() - request_id = int(request.id) - - reused = repository.create_ingestion_request( - homepage_url="https://lichenlvs.66law.cn/", - email="next@example.com", - ) - - assert reused["request_id"] == request_id - assert reused["normalized_url"] == "https://66law.cn/" - assert reused["identity_key"] == "site:66law.cn/" - - -def test_repository_dedup_scan_uses_model_consensus_when_enabled(tmp_path: Path, monkeypatch) -> None: - """Rescan should share the same model-consensus decision layer as live crawler filtering.""" - settings = Settings( - db_path=tmp_path / "db.sqlite", - seed_path=tmp_path / "seed.csv", - export_dir=tmp_path / "exports", - decision_model_root=tmp_path / "models", - decision_model_consensus_enabled=True, - ) - repository = repository_module.build_repository(db_path=settings.db_path, settings=settings) - source_id, inserted = repository.upsert_blog( - url="https://source.example/", - normalized_url="https://source.example/", - domain="source.example", - ) - assert inserted is True - target_id, inserted = repository.upsert_blog( - url="https://maybe-blog.example/", - normalized_url="https://maybe-blog.example/", - domain="maybe-blog.example", - ) - assert inserted is True - - run_dir = settings.decision_model_root / "structured" / "2604120847" - run_dir.mkdir(parents=True) - (run_dir / "model.joblib").write_bytes(b"stub") - (run_dir / "config.json").write_text('{"model_config":{"threshold":0.5}}', encoding="utf-8") - - class StubPredictor: - threshold = 0.5 - - def predict_proba(self, samples: list[object]) -> list[float]: - probabilities: list[float] = [] - for sample in samples: - url = str(getattr(sample, "url", "")) - probabilities.append(0.9 if "source.example" in url else 0.1) - return probabilities - - monkeypatch.setattr("crawler.crawling.decisions.consensus.load_model", lambda path: StubPredictor()) - - repository.add_edge( - from_blog_id=source_id, - to_blog_id=target_id, - link_url_raw="https://maybe-blog.example/", - link_text="Maybe Blog", - ) - - run = repository.create_blog_dedup_scan_run(crawler_was_running=False) - summary = repository.execute_blog_dedup_scan_run(run_id=int(run["id"])) - items = repository.list_blog_dedup_scan_run_items(summary["id"]) - - assert summary["removed_count"] == 1 - assert summary["kept_count"] == 1 - assert repository.list_edges() == [] - assert [blog["id"] for blog in repository.list_blogs()] == [source_id] - assert items[0]["reason_code"] == "model_consensus_all_non_blog" - - def test_repository_ensure_edge_in_session_dedupes_pending_edges(tmp_path: Path) -> None: """Refilter edge creation should ignore already-pending same-direction edges.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") @@ -1009,27 +640,10 @@ def test_repository_startup_migrates_legacy_tenant_like_rows_and_merges_to_root_ migrated = repository_module.build_repository(db_path=db_path) blogs = migrated.list_blogs() - latest_run = migrated.get_latest_blog_dedup_scan_run() assert len(blogs) == 2 assert {blog["identity_key"] for blog in blogs} == {"site:66law.cn/"} assert all(blog["identity_ruleset_version"] == repository_module.IDENTITY_RULESET_VERSION for blog in blogs) - assert latest_run is None - - -def test_repository_startup_marks_orphaned_dedup_scan_run_failed(tmp_path: Path) -> None: - """Startup should not leave stale RUNNING dedup scan summaries hanging forever.""" - db_path = tmp_path / "db.sqlite" - repository = repository_module.build_repository(db_path=db_path) - run = repository.create_blog_dedup_scan_run(crawler_was_running=False) - - restarted = repository_module.build_repository(db_path=db_path) - latest_run = restarted.get_latest_blog_dedup_scan_run() - - assert latest_run is not None - assert latest_run["id"] == run["id"] - assert latest_run["status"] == "FAILED" - assert latest_run["error_message"] == "orphaned_dedup_scan_run_cleaned_on_startup" def test_repository_requeues_processing_blogs_on_restart(tmp_path: Path) -> None: @@ -1168,63 +782,6 @@ def test_repository_user_seed_runs_rule_filters_only(tmp_path: Path) -> None: repository.create_user_seed(homepage_url="https://user-blog.example.com/posts/1") -def test_repository_claims_priority_blogs_by_request_priority(tmp_path: Path) -> None: - """Priority queue claiming should follow ingestion priority before request age.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - first = repository.create_ingestion_request( - homepage_url="https://first-priority.example/", - email="owner@example.com", - ) - second = repository.create_ingestion_request( - homepage_url="https://second-priority.example/", - email="owner@example.com", - ) - - with session_scope(repository.session_factory) as session: - first_request = session.scalar( - repository_module.select(repository_module.IngestionRequestModel).where( - repository_module.IngestionRequestModel.id == first["request_id"] - ) - ) - second_request = session.scalar( - repository_module.select(repository_module.IngestionRequestModel).where( - repository_module.IngestionRequestModel.id == second["request_id"] - ) - ) - assert first_request is not None - assert second_request is not None - first_request.priority = 100 - second_request.priority = 200 - first_request.updated_at = repository_module.now_utc() - second_request.updated_at = repository_module.now_utc() - - claimed = repository.get_next_priority_blog() - - assert claimed is not None - assert claimed["id"] == second["seed_blog_id"] - - -def test_repository_waiting_queue_can_exclude_priority_seed_blogs(tmp_path: Path) -> None: - """Normal queue claiming should skip active ingestion seeds when requested.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - priority_request = repository.create_ingestion_request( - homepage_url="https://priority-seed.example/", - email="owner@example.com", - ) - normal_blog_id, inserted = repository.upsert_blog( - url="https://normal.example/", - normalized_url="https://normal.example/", - domain="normal.example", - ) - assert inserted is True - - claimed = repository.get_next_waiting_blog(include_priority=False) - - assert claimed is not None - assert claimed["id"] == normal_blog_id - assert repository.get_blog(priority_request["seed_blog_id"])["crawl_status"] == "WAITING" - - def test_repository_blog_catalog_paginates_and_filters(tmp_path: Path) -> None: """Catalog queries should paginate and filter on the server side.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") @@ -1644,41 +1201,6 @@ def test_repository_blog_catalog_has_title_filters_on_stored_title_only(tmp_path assert payload["items"][0]["title"] == "untitled.example" -def test_repository_priority_ingestion_list_hides_private_fields_and_orders_active_first(tmp_path: Path) -> None: - """Public priority list should expose queue state without leaking request secrets.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - - queued = repository.create_ingestion_request( - homepage_url="https://queued.example/", - email="owner@example.com", - ) - processing = repository.create_ingestion_request( - homepage_url="https://processing.example/", - email="runner@example.com", - ) - repository.mark_ingestion_request_crawling(blog_id=processing["seed_blog_id"]) - repository.mark_blog_result( - blog_id=processing["seed_blog_id"], - crawl_status="FINISHED", - status_code=200, - friend_links_count=0, - metadata_captured=True, - title="Processing Blog", - icon_url="https://processing.example/favicon.ico", - ) - - items = repository.list_priority_ingestion_requests() - - assert [item["status"] for item in items] == ["QUEUED", "COMPLETED"] - assert items[0]["request_id"] == queued["request_id"] - assert items[0]["requested_url"] == "https://queued.example/" - assert items[0]["blog"]["crawl_status"] == "WAITING" - assert "email" not in items[0] - assert "request_token" not in items[0] - assert "priority" not in items[0] - assert "email" not in items[0]["blog"] - - def test_repository_blog_lookup_prefers_identity_match_and_returns_reason(tmp_path: Path) -> None: """Lookup should follow the frozen identity-first match ladder.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") diff --git a/tests/test_runtime.py b/tests/test_runtime.py index c3f8058..fc4cd20 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -30,36 +30,6 @@ def stats(self) -> dict[str, int]: return {"raw_discovered_urls": self.raw_discovered_urls} -class PriorityQueueRepository: - """Support separate priority and normal queues for fairness tests.""" - - def __init__(self, *, priority_blog_ids: list[int], normal_blog_ids: list[int]) -> None: - self.priority_blog_ids = list(priority_blog_ids) - self.normal_blog_ids = list(normal_blog_ids) - self.lock = Lock() - self.claim_order: list[int] = [] - - def get_next_priority_blog(self) -> dict[str, object] | None: - with self.lock: - if not self.priority_blog_ids: - return None - blog_id = self.priority_blog_ids.pop(0) - self.claim_order.append(blog_id) - return {"id": blog_id, "url": f"https://priority{blog_id}.example.com/"} - - def get_next_waiting_blog(self, *, include_priority: bool = True) -> dict[str, object] | None: - with self.lock: - if self.normal_blog_ids: - blog_id = self.normal_blog_ids.pop(0) - self.claim_order.append(blog_id) - return {"id": blog_id, "url": f"https://blog{blog_id}.example.com/"} - if include_priority and self.priority_blog_ids: - blog_id = self.priority_blog_ids.pop(0) - self.claim_order.append(blog_id) - return {"id": blog_id, "url": f"https://priority{blog_id}.example.com/"} - return None - - class BlockingQueuePipeline: """Pipeline stub that blocks one claimed blog until the test releases it.""" @@ -160,9 +130,9 @@ def write_exports(self) -> dict[str, object]: class RecordingPipeline: - """A fast pipeline that records claim order for fairness assertions.""" + """A fast pipeline that records claim order for queue assertions.""" - def __init__(self, repository: PriorityQueueRepository) -> None: + def __init__(self, repository: QueueRepository) -> None: self.repository = repository self.processed_ids: list[int] = [] @@ -308,30 +278,15 @@ def test_runtime_records_fatal_worker_errors_and_clears_stale_current_task_field assert snapshot["workers"][0]["current_url"] is None -def test_runtime_prioritizes_seed_requests_before_normal_queue() -> None: - """Priority seeds should be claimed ahead of ordinary waiting blogs.""" - repository = PriorityQueueRepository(priority_blog_ids=[101], normal_blog_ids=[1, 2]) - runtime = CrawlerRuntimeService(RecordingPipeline(repository), worker_count=1) +def test_runtime_claims_waiting_blogs_in_queue_order() -> None: + """Runtime batches should keep claiming ordinary waiting blogs until the limit is reached.""" + pipeline = RecordingPipeline(QueueRepository([1, 2, 3])) + runtime = CrawlerRuntimeService(pipeline, worker_count=1) result = runtime.run_batch(3) assert result["accepted"] is True - assert repository.claim_order[0] == 101 - - -def test_runtime_releases_normal_queue_slots_after_each_priority_seed() -> None: - """After one priority seed, the runtime should release normal queue claims before taking the next priority.""" - repository = PriorityQueueRepository(priority_blog_ids=[101, 102], normal_blog_ids=[1, 2, 3]) - runtime = CrawlerRuntimeService( - RecordingPipeline(repository), - worker_count=1, - priority_seed_normal_queue_slots=2, - ) - - result = runtime.run_batch(5) - - assert result["accepted"] is True - assert repository.claim_order[:4] == [101, 1, 2, 102] + assert pipeline.processed_ids == [1, 2, 3] def test_runtime_continues_to_next_waiting_blog_after_one_timeout_failure() -> None: diff --git a/tests/test_service_split.py b/tests/test_service_split.py index ca8e917..8f2cc86 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -217,31 +217,6 @@ def test_persistence_service_exposes_supported_repository_data(tmp_path: Path) - } assert detail.json()["outgoing_edges"] == [] - request = client.post( - "/internal/ingestion-requests", - json={ - "homepage_url": "https://queued.example.com/", - "email": "owner@example.com", - }, - ) - assert request.status_code == 200 - assert request.json()["request_id"] == 1 - assert request.json()["status"] == "QUEUED" - - request_status = client.get( - "/internal/ingestion-requests/1", - params={"request_token": request.json()["request_token"]}, - ) - assert request_status.status_code == 200 - assert request_status.json()["email"] == "owner@example.com" - - priority_requests = client.get("/internal/ingestion-requests") - assert priority_requests.status_code == 200 - assert priority_requests.json()[0]["request_id"] == 1 - assert "email" not in priority_requests.json()[0] - assert "request_token" not in priority_requests.json()[0] - assert "email" not in priority_requests.json()[0]["blog"] - auth = client.post( "/internal/users/register", json={"email": "Member@Example.com", "password": "long enough"}, @@ -257,10 +232,10 @@ def test_persistence_service_exposes_supported_repository_data(tmp_path: Path) - assert login.status_code == 200 assert login.json()["user"]["id"] == auth.json()["user"]["id"] - lookup = client.get("/internal/blogs/lookup", params={"url": "https://queued.example.com/"}) + lookup = client.get("/internal/blogs/lookup", params={"url": "https://blog.example.com/"}) assert lookup.status_code == 200 assert lookup.json()["match_reason"] == "identity_key" - assert lookup.json()["items"][0]["id"] == request.json()["seed_blog_id"] + assert lookup.json()["items"][0]["id"] == 1 filter_stats = client.get("/internal/filter-stats") assert filter_stats.status_code == 200 @@ -268,9 +243,8 @@ def test_persistence_service_exposes_supported_repository_data(tmp_path: Path) - reset = client.post("/internal/database/reset") assert reset.status_code == 200 - assert reset.json()["blogs_deleted"] == 3 + assert reset.json()["blogs_deleted"] == 2 assert reset.json()["logs_deleted"] == 0 - assert reset.json()["ingestion_requests_deleted"] == 1 assert reset.json()["blog_link_labels_deleted"] == 0 empty_catalog = client.get("/internal/blogs/catalog") @@ -300,10 +274,10 @@ def test_persistence_service_queue_routes_preserve_optional_row_serialization() class StubRepository: def __init__(self) -> None: - self.include_priority_calls: list[bool] = [] + self.calls = 0 - def get_next_waiting_blog(self, *, include_priority: bool = True) -> dict[str, object] | None: - self.include_priority_calls.append(include_priority) + def get_next_waiting_blog(self) -> dict[str, object] | None: + self.calls += 1 return { "id": 11, "blog_id": 11, @@ -311,9 +285,6 @@ def get_next_waiting_blog(self, *, include_priority: bool = True) -> dict[str, o "crawl_status": "PROCESSING", } - def get_next_priority_blog(self) -> dict[str, object] | None: - return None - repository = StubRepository() app = create_persistence_app( PersistenceState( @@ -324,8 +295,7 @@ def get_next_priority_blog(self) -> dict[str, object] | None: ) client = TestClient(app) - waiting = client.get("/internal/queue/next", params={"include_priority": "false"}) - priority = client.get("/internal/queue/priority-next") + waiting = client.get("/internal/queue/next") assert waiting.status_code == 200 assert waiting.json() == { @@ -334,78 +304,13 @@ def get_next_priority_blog(self) -> dict[str, object] | None: "domain": "queued.example.com", "crawl_status": "PROCESSING", } - assert repository.include_priority_calls == [False] - - assert priority.status_code == 200 - assert priority.json() is None - - -def test_persistence_service_maintenance_run_create_routes_preserve_bool_passthrough() -> None: - """Maintenance create routes should keep bool passthrough and payload shape unchanged.""" - - class StubRepository: - def __init__(self) -> None: - self.blog_dedup_calls: list[bool] = [] - - def create_blog_dedup_scan_run(self, *, crawler_was_running: bool = False) -> dict[str, object]: - self.blog_dedup_calls.append(crawler_was_running) - return {"id": 34, "status": "RUNNING", "crawler_was_running": crawler_was_running} - - repository = StubRepository() - app = create_persistence_app( - PersistenceState( - repository=repository, # type: ignore[arg-type] - graph_service=object(), # type: ignore[arg-type] - stats_service=object(), # type: ignore[arg-type] - ) - ) - client = TestClient(app) - - blog_dedup = client.post("/internal/blog-dedup-scans/runs") - - assert blog_dedup.status_code == 200 - assert blog_dedup.json() == {"id": 34, "status": "RUNNING", "crawler_was_running": False} - assert repository.blog_dedup_calls == [False] - - -def test_persistence_service_maintenance_child_list_routes_preserve_run_id_passthrough() -> None: - """Maintenance child-list routes should keep run_id passthrough and list payloads unchanged.""" - - class StubRepository: - def __init__(self) -> None: - self.blog_dedup_calls: list[int] = [] - - def list_blog_dedup_scan_run_items(self, run_id: int) -> list[dict[str, object]]: - self.blog_dedup_calls.append(run_id) - return [{"id": 2, "run_id": run_id, "reason_code": "blog_alias_collapsed"}] - - repository = StubRepository() - app = create_persistence_app( - PersistenceState( - repository=repository, # type: ignore[arg-type] - graph_service=object(), # type: ignore[arg-type] - stats_service=object(), # type: ignore[arg-type] - ) - ) - client = TestClient(app) - - blog_dedup = client.get("/internal/blog-dedup-scans/9/items") - - assert blog_dedup.status_code == 200 - assert blog_dedup.json() == [{"id": 2, "run_id": 9, "reason_code": "blog_alias_collapsed"}] - assert repository.blog_dedup_calls == [9] + assert repository.calls == 1 def test_persistence_service_zero_arg_list_routes_preserve_payload_passthrough() -> None: """Zero-arg list routes should keep list payloads and ordering unchanged.""" class StubRepository: - def list_priority_ingestion_requests(self) -> list[dict[str, object]]: - return [ - {"request_id": 2, "status": "QUEUED"}, - {"request_id": 5, "status": "CRAWLING"}, - ] - def list_blog_label_tags(self) -> list[dict[str, object]]: return [ {"id": 7, "slug": "blog"}, @@ -421,15 +326,8 @@ def list_blog_label_tags(self) -> list[dict[str, object]]: ) client = TestClient(app) - ingestion_requests = client.get("/internal/ingestion-requests") blog_label_tags = client.get("/internal/blog-labeling/tags") - assert ingestion_requests.status_code == 200 - assert ingestion_requests.json() == [ - {"request_id": 2, "status": "QUEUED"}, - {"request_id": 5, "status": "CRAWLING"}, - ] - assert blog_label_tags.status_code == 200 assert blog_label_tags.json() == [ {"id": 7, "slug": "blog"}, @@ -1605,26 +1503,6 @@ def test_backend_service_preserves_supported_public_api_shape(monkeypatch) -> No }, }, "list_logs": lambda self: [], - "create_ingestion_request": lambda self, homepage_url, email: { - "id": 9, - "request_id": 9, - "requested_url": homepage_url, - "normalized_url": homepage_url, - "email": email, - "status": "QUEUED", - "priority": 100, - "seed_blog_id": 3, - "matched_blog_id": None, - "blog_id": 3, - "request_token": "token-123", - "expires_at": None, - "error_message": None, - "created_at": "2026-04-05T00:00:00Z", - "updated_at": "2026-04-05T00:00:00Z", - "seed_blog": None, - "matched_blog": None, - "blog": None, - }, "create_user_seed": lambda self, homepage_url: { "status": "QUEUED", "blog_id": 44, @@ -1640,59 +1518,6 @@ def test_backend_service_preserves_supported_public_api_shape(monkeypatch) -> No "crawl_status": "WAITING", }, }, - "get_ingestion_request": lambda self, request_id, request_token: { - "id": request_id, - "request_id": request_id, - "requested_url": "https://queued.example/", - "normalized_url": "https://queued.example/", - "email": "owner@example.com", - "status": "QUEUED", - "priority": 100, - "seed_blog_id": 3, - "matched_blog_id": None, - "blog_id": 3, - "request_token": request_token, - "expires_at": None, - "error_message": None, - "created_at": "2026-04-05T00:00:00Z", - "updated_at": "2026-04-05T00:00:00Z", - "seed_blog": None, - "matched_blog": None, - "blog": None, - }, - "list_priority_ingestion_requests": lambda self: [ - { - "request_id": 9, - "requested_url": "https://queued.example/", - "normalized_url": "https://queued.example/", - "status": "QUEUED", - "seed_blog_id": 3, - "matched_blog_id": None, - "blog_id": 3, - "error_message": None, - "created_at": "2026-04-05T00:00:00Z", - "updated_at": "2026-04-05T00:00:00Z", - "blog": { - "id": 3, - "url": "https://queued.example/", - "normalized_url": "https://queued.example/", - "domain": "queued.example", - "title": "Queued Example", - "icon_url": None, - "status_code": None, - "crawl_status": "WAITING", - "friend_links_count": 0, - "last_crawled_at": None, - "created_at": "2026-04-05T00:00:00Z", - "updated_at": "2026-04-05T00:00:00Z", - "incoming_count": 0, - "outgoing_count": 0, - "connection_count": 0, - "activity_at": None, - "identity_complete": True, - }, - } - ], "lookup_blog_candidates": lambda self, url: { "query_url": url, "normalized_query_url": "https://queued.example/", @@ -1732,7 +1557,6 @@ def test_backend_service_preserves_supported_public_api_shape(monkeypatch) -> No "blogs_deleted": 3, "edges_deleted": 4, "logs_deleted": 0, - "ingestion_requests_deleted": 1, "blog_link_labels_deleted": 0, "blog_label_tags_deleted": 0, "blog_label_subjects_preserved": 1, @@ -1899,17 +1723,6 @@ def fake_get(url: str, **kwargs: object) -> httpx.Response: assert requeue.status_code == 200 assert requeue.json() == {"requeued": 7} - ingestion = client.post( - "/api/ingestion-requests", - json={"homepage_url": "https://queued.example/", "email": "owner@example.com"}, - ) - assert ingestion.status_code == 200 - assert ingestion.json()["request_id"] == 9 - - ingestion_status = client.get("/api/ingestion-requests/9?request_token=token-123") - assert ingestion_status.status_code == 200 - assert ingestion_status.json()["status"] == "QUEUED" - user_seed = client.post( "/api/blogs/user-seeds", json={"homepage_url": "https://queued-user.example/"}, @@ -1919,13 +1732,6 @@ def fake_get(url: str, **kwargs: object) -> httpx.Response: assert user_seed.json()["blog"]["accepted_by"] == "user" assert user_seed.json()["blog"]["crawl_status"] == "WAITING" - priority_ingestion = client.get("/api/ingestion-requests") - assert priority_ingestion.status_code == 200 - assert priority_ingestion.json()[0]["request_id"] == 9 - assert "email" not in priority_ingestion.json()[0] - assert "request_token" not in priority_ingestion.json()[0] - assert "email" not in priority_ingestion.json()[0]["blog"] - lookup = client.get("/api/blogs/lookup?url=https://queued.example/") assert lookup.status_code == 200 assert lookup.json()["match_reason"] == "identity_key" @@ -1934,7 +1740,6 @@ def fake_get(url: str, **kwargs: object) -> httpx.Response: reset = client.post("/api/admin/database/reset", headers=admin_headers()) assert reset.status_code == 200 assert reset.json()["blogs_deleted"] == 3 - assert reset.json()["ingestion_requests_deleted"] == 1 assert reset.json()["blog_link_labels_deleted"] == 0 assert reset.json()["blog_label_tags_deleted"] == 0 assert reset.json()["blog_link_labels_preserved"] == 1 @@ -2206,8 +2011,8 @@ def reset(self) -> dict[str, object]: assert response.json()["detail"] == "Unsupported crawl status: BAD" -def test_backend_lookup_and_priority_list_surface_upstream_validation_errors() -> None: - """Public lookup and priority list endpoints should preserve upstream failures.""" +def test_backend_lookup_and_user_seed_surface_upstream_validation_errors() -> None: + """Public lookup and user seed endpoints should preserve upstream failures.""" class LookupValidationStub: def stats(self) -> dict[str, object]: @@ -2233,11 +2038,6 @@ def lookup_blog_candidates(self, *, url: str) -> dict[str, object]: response = httpx.Response(422, request=request, json={"detail": "Unsupported homepage URL"}) raise httpx.HTTPStatusError("boom", request=request, response=response) - def list_priority_ingestion_requests(self) -> list[dict[str, object]]: - request = httpx.Request("GET", "http://persistence/internal/ingestion-requests") - response = httpx.Response(503, request=request, json={"detail": "upstream_unavailable"}) - raise httpx.HTTPStatusError("boom", request=request, response=response) - def create_user_seed(self, *, homepage_url: str) -> dict[str, object]: request = httpx.Request("POST", "http://persistence/internal/user-seeds") response = httpx.Response(422, request=request, json={"detail": "rule:blocked_tld"}) @@ -2282,10 +2082,6 @@ def reset(self) -> dict[str, object]: assert lookup.status_code == 422 assert lookup.json()["detail"] == "Unsupported homepage URL" - priority = client.get("/api/ingestion-requests") - assert priority.status_code == 503 - assert priority.json()["detail"] == "upstream_unavailable" - user_seed = client.post("/api/blogs/user-seeds", json={"homepage_url": "https://blog.sayori.org/"}) assert user_seed.status_code == 422 assert user_seed.json()["detail"] == "rule:blocked_tld" @@ -2444,215 +2240,6 @@ def test_backend_admin_routes_fail_when_auth_not_configured() -> None: assert response.json()["detail"] == "admin_auth_not_configured" -def test_persistence_service_exposes_blog_dedup_scan_endpoints(tmp_path: Path) -> None: - """Persistence should expose decision-rescan summary and removed item endpoints.""" - settings = Settings( - db_path=tmp_path / "heyblog.sqlite", - seed_path=tmp_path / "seed.csv", - export_dir=tmp_path / "exports", - ) - app = create_persistence_app(build_persistence_state(settings)) - client = TestClient(app) - - first = client.post( - "/internal/blogs/upsert", - json={ - "url": "https://langhai.cc/", - "normalized_url": "https://langhai.cc/", - "domain": "langhai.cc", - }, - ) - assert first.status_code == 200 - - run = client.post("/internal/blog-dedup-scans/runs", params={"crawler_was_running": "true"}) - assert run.status_code == 200 - assert run.json()["status"] == "RUNNING" - assert run.json()["total_count"] == 1 - - executed = client.post(f"/internal/blog-dedup-scans/{run.json()['id']}/execute") - assert executed.status_code == 200 - assert executed.json()["status"] == "SUCCEEDED" - assert executed.json()["total_count"] == 1 - - latest = client.get("/internal/blog-dedup-scans/latest") - assert latest.status_code == 200 - assert latest.json()["id"] == run.json()["id"] - - items = client.get(f"/internal/blog-dedup-scans/{run.json()['id']}/items") - assert items.status_code == 200 - assert isinstance(items.json(), list) - - legacy_shortcut = client.post("/internal/blog-dedup-scans", params={"crawler_was_running": "true"}) - assert legacy_shortcut.status_code == 404 - - -def test_backend_blog_dedup_scan_stops_and_restarts_crawler_and_blocks_runtime_actions() -> None: - """Admin scan should orchestrate stop/scan/restart and expose maintenance lock.""" - - class ScanPersistenceStub: - def __init__(self) -> None: - self.finalize_calls: list[dict[str, object]] = [] - self.run = { - "id": 7, - "status": "PENDING", - "ruleset_version": "2026-04-07-v2", - "total_count": 3, - "scanned_count": 0, - "removed_count": 0, - "kept_count": 0, - "crawler_was_running": True, - "crawler_restart_attempted": False, - "crawler_restart_succeeded": False, - "search_reindexed": False, - "error_message": None, - } - - def stats(self) -> dict[str, object]: - return { - "pending_tasks": 0, - "processing_tasks": 0, - "finished_tasks": 0, - "failed_tasks": 0, - "total_blogs": 0, - "total_edges": 0, - "status_counts": {}, - "average_friend_links": 0.0, - } - - def list_blogs(self) -> list[dict[str, object]]: - return [] - - def get_blog(self, blog_id: int) -> None: - return None - - def get_blog_detail(self, blog_id: int) -> None: - return None - - def list_edges(self) -> list[dict[str, object]]: - return [] - - def graph(self) -> dict[str, object]: - return {"nodes": [], "edges": []} - - def graph_view(self, **_: object) -> dict[str, object]: - return {"nodes": [], "edges": [], "meta": {}} - - def graph_neighbors(self, blog_id: int, hops: int = 1, limit: int = 120) -> dict[str, object]: - return {"nodes": [], "edges": [], "meta": {}} - - def latest_graph_snapshot(self) -> dict[str, object]: - return {"version": "v1"} - - def graph_snapshot(self, version: str) -> dict[str, object]: - return {"version": version, "nodes": [], "edges": [], "meta": {}} - - def list_logs(self) -> list[dict[str, object]]: - return [] - - def reset(self) -> dict[str, object]: - return {"ok": True, "blogs_deleted": 0, "edges_deleted": 0, "logs_deleted": 0} - - def create_blog_dedup_scan_run(self, *, crawler_was_running: bool = False) -> dict[str, object]: - self.run.update( - { - "status": "RUNNING", - "crawler_was_running": crawler_was_running, - "crawler_restart_attempted": False, - "crawler_restart_succeeded": False, - "search_reindexed": False, - "error_message": None, - } - ) - return dict(self.run) - - def execute_blog_dedup_scan_run(self, *, run_id: int) -> dict[str, object]: - sleep(0.05) - self.run.update( - { - "id": run_id, - "status": "SUCCEEDED", - "scanned_count": 3, - "removed_count": 2, - "kept_count": 1, - } - ) - return dict(self.run) - - def finalize_blog_dedup_scan_run(self, **payload: object) -> dict[str, object]: - self.finalize_calls.append(payload) - self.run.update( - { - "id": int(payload["run_id"]), - "crawler_restart_attempted": bool(payload["crawler_restart_attempted"]), - "crawler_restart_succeeded": bool(payload["crawler_restart_succeeded"]), - "search_reindexed": bool(payload["search_reindexed"]), - "error_message": payload.get("error_message"), - } - ) - return dict(self.run) - - def latest_blog_dedup_scan_run(self) -> dict[str, object]: - return dict(self.run) - - def list_blog_dedup_scan_run_items(self, run_id: int) -> list[dict[str, object]]: - return [ - { - "id": 1, - "run_id": run_id, - "removed_url": "http://blog.langhai.cc/index.html", - "reason_code": "blog_alias_collapsed", - "survivor_selection_basis": "FINISHED, created_at=2026-04-05T00:00:00Z, id=1", - } - ] - - class ToggleCrawler(StubCrawler): - def __init__(self) -> None: - self.runner_status = "running" - self.stop_calls = 0 - self.start_calls = 0 - - def runtime_status(self) -> dict[str, object]: - payload = super().runtime_status() - payload["runner_status"] = self.runner_status - return payload - - def stop(self) -> dict[str, object]: - self.stop_calls += 1 - self.runner_status = "idle" - return self.runtime_status() - - def start(self) -> dict[str, object]: - self.start_calls += 1 - self.runner_status = "running" - return self.runtime_status() - - persistence = ScanPersistenceStub() - crawler = ToggleCrawler() - search = StubSearch() - app = create_backend_app(BackendState(persistence=persistence, crawler=crawler, search=search, admin_token="secret-token")) - client = TestClient(app) - - response = client.post("/api/admin/blog-dedup-scans", headers=admin_headers()) - - assert response.status_code == 200 - assert response.json()["status"] == "RUNNING" - assert response.json()["total_count"] == 3 - assert crawler.stop_calls == 1 - for _ in range(20): - latest = client.get("/api/admin/blog-dedup-scans/latest", headers=admin_headers()) - assert latest.status_code == 200 - if latest.json()["status"] == "SUCCEEDED": - break - sleep(0.05) - assert latest.json()["crawler_restart_attempted"] is True - assert latest.json()["crawler_restart_succeeded"] is True - assert latest.json()["search_reindexed"] is True - assert crawler.start_calls == 1 - items = client.get("/api/admin/blog-dedup-scans/7/items", headers=admin_headers()) - assert items.status_code == 200 - assert items.json()[0]["reason_code"] == "blog_alias_collapsed" - - def test_search_service_queries_rebuilt_snapshot(tmp_path: Path) -> None: """Search service should return matches from its rebuildable snapshot.""" @@ -2938,7 +2525,7 @@ async def request( app = create_frontend_app(settings) client = TestClient(app) - response = client.post("/api/ingestion-requests", json={"homepage_url": "https://blog.example.com"}) + response = client.post("/api/blogs/user-seeds", json={"homepage_url": "https://blog.example.com"}) assert response.status_code == 200 assert captured["headers"].pop("x-request-id") From e78480836a60d8a0eb99d95410c34602f0987e33 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 20:53:38 +0100 Subject: [PATCH 25/35] =?UTF-8?q?=F0=9F=A6=84=20refactor:=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=E6=8E=89blog=5Finteractions=E5=92=8Crecommendation=5F?= =?UTF-8?q?impressions=E8=A1=A8=E4=B8=AD=E7=9A=84blog=5Fid=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0607_01_add_recommendation_event_tables.py | 14 +- ..._04_drop_recommendation_blog_id_columns.py | 187 ++++++++++++++++++ doc/api-docs.md | 29 +-- persistence_api/models.py | 8 +- persistence_api/repository.py | 46 +---- tests/test_repository.py | 85 ++++++-- tests/test_service_split.py | 24 +-- 7 files changed, 294 insertions(+), 99 deletions(-) create mode 100644 alembic/versions/20260607_04_drop_recommendation_blog_id_columns.py diff --git a/alembic/versions/20260607_01_add_recommendation_event_tables.py b/alembic/versions/20260607_01_add_recommendation_event_tables.py index 4d505eb..48f766a 100644 --- a/alembic/versions/20260607_01_add_recommendation_event_tables.py +++ b/alembic/versions/20260607_01_add_recommendation_event_tables.py @@ -87,22 +87,20 @@ def upgrade() -> None: sa.ForeignKey("recommendation_requests.id", ondelete="CASCADE"), nullable=False, ), - sa.Column("blog_id", sa.Integer(), sa.ForeignKey("blogs.blog_id", ondelete="CASCADE"), nullable=False), sa.Column("normalized_url", sa.Text(), nullable=False), sa.Column("position", sa.Integer(), nullable=False), sa.Column("score", sa.Integer(), nullable=True), sa.Column("reason_json", sa.JSON(), nullable=False, server_default=sa.text("'{}'")), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), sa.UniqueConstraint("request_id", "position", name="uq_recommendation_impression_request_position"), - sa.UniqueConstraint("request_id", "blog_id", name="uq_recommendation_impression_request_blog"), + sa.UniqueConstraint("request_id", "normalized_url", name="uq_recommendation_impression_request_url"), ) op.create_index("ix_recommendation_impressions_request_id", "recommendation_impressions", ["request_id"]) - op.create_index("ix_recommendation_impressions_blog_id", "recommendation_impressions", ["blog_id"]) op.create_index("ix_recommendation_impressions_normalized_url", "recommendation_impressions", ["normalized_url"]) op.create_index( - "ix_recommendation_impressions_blog_created", + "ix_recommendation_impressions_url_created", "recommendation_impressions", - ["blog_id", "created_at"], + ["normalized_url", "created_at"], ) tables = _tables() @@ -123,7 +121,6 @@ def upgrade() -> None: sa.ForeignKey("recommendation_impressions.id", ondelete="SET NULL"), nullable=True, ), - sa.Column("blog_id", sa.Integer(), sa.ForeignKey("blogs.blog_id", ondelete="CASCADE"), nullable=False), sa.Column("normalized_url", sa.Text(), nullable=False), sa.Column("event_type", sa.Text(), nullable=False), sa.Column("position", sa.Integer(), nullable=True), @@ -141,7 +138,6 @@ def upgrade() -> None: op.create_index("ix_blog_interactions_event_uuid", "blog_interactions", ["event_uuid"]) op.create_index("ix_blog_interactions_request_id", "blog_interactions", ["request_id"]) op.create_index("ix_blog_interactions_impression_id", "blog_interactions", ["impression_id"]) - op.create_index("ix_blog_interactions_blog_id", "blog_interactions", ["blog_id"]) op.create_index("ix_blog_interactions_normalized_url", "blog_interactions", ["normalized_url"]) op.create_index("ix_blog_interactions_event_type", "blog_interactions", ["event_type"]) op.create_index("ix_blog_interactions_entrance_kind", "blog_interactions", ["entrance_kind"]) @@ -150,9 +146,9 @@ def upgrade() -> None: op.create_index("ix_blog_interactions_user_id", "blog_interactions", ["user_id"]) op.create_index("ix_blog_interactions_session_id", "blog_interactions", ["session_id"]) op.create_index( - "ix_blog_interactions_blog_event_created", + "ix_blog_interactions_url_event_created", "blog_interactions", - ["blog_id", "event_type", "created_at"], + ["normalized_url", "event_type", "created_at"], ) op.create_index("ix_blog_interactions_request_event", "blog_interactions", ["request_id", "event_type"]) diff --git a/alembic/versions/20260607_04_drop_recommendation_blog_id_columns.py b/alembic/versions/20260607_04_drop_recommendation_blog_id_columns.py new file mode 100644 index 0000000..8f5215e --- /dev/null +++ b/alembic/versions/20260607_04_drop_recommendation_blog_id_columns.py @@ -0,0 +1,187 @@ +"""Drop blog_id columns from recommendation event tables. + +Revision ID: 20260607_04 +Revises: 20260607_03 +Create Date: 2026-06-07 16:35:00 BST +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260607_04" +down_revision = "20260607_03" +branch_labels = None +depends_on = None + + +def _table_names() -> set[str]: + """Return currently present table names. + + Args: + None. + + Returns: + Set of table names visible through the active migration connection. + """ + + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def _column_names(table_name: str) -> set[str]: + """Return column names for one table. + + Args: + table_name: Table to inspect. + + Returns: + Set of existing column names, or an empty set when the table is absent. + """ + + inspector = sa.inspect(op.get_bind()) + if table_name not in set(inspector.get_table_names()): + return set() + return {column["name"] for column in inspector.get_columns(table_name)} + + +def _index_names(table_name: str) -> set[str]: + """Return index names for one table. + + Args: + table_name: Table to inspect. + + Returns: + Set of existing index names, or an empty set when the table is absent. + """ + + inspector = sa.inspect(op.get_bind()) + if table_name not in set(inspector.get_table_names()): + return set() + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _unique_constraint_names(table_name: str) -> set[str]: + """Return named unique constraints for one table. + + Args: + table_name: Table to inspect. + + Returns: + Set of existing unique constraint names, or an empty set when absent. + """ + + inspector = sa.inspect(op.get_bind()) + if table_name not in set(inspector.get_table_names()): + return set() + return { + constraint["name"] + for constraint in inspector.get_unique_constraints(table_name) + if constraint["name"] + } + + +def upgrade() -> None: + """Remove blog foreign-key columns from recommendation event tables. + + Args: + None. + + Returns: + None. Existing rows keep their durable `normalized_url` attribution. + """ + + tables = _table_names() + if "recommendation_impressions" in tables: + columns = _column_names("recommendation_impressions") + indexes = _index_names("recommendation_impressions") + unique_constraints = _unique_constraint_names("recommendation_impressions") + with op.batch_alter_table("recommendation_impressions") as batch_op: + if "ix_recommendation_impressions_blog_created" in indexes: + batch_op.drop_index("ix_recommendation_impressions_blog_created") + if "ix_recommendation_impressions_blog_id" in indexes: + batch_op.drop_index("ix_recommendation_impressions_blog_id") + if "uq_recommendation_impression_request_blog" in unique_constraints: + batch_op.drop_constraint("uq_recommendation_impression_request_blog", type_="unique") + if "uq_recommendation_impression_request_url" not in unique_constraints: + batch_op.create_unique_constraint( + "uq_recommendation_impression_request_url", + ["request_id", "normalized_url"], + ) + if "ix_recommendation_impressions_url_created" not in indexes: + batch_op.create_index( + "ix_recommendation_impressions_url_created", + ["normalized_url", "created_at"], + ) + if "blog_id" in columns: + batch_op.drop_column("blog_id") + + if "blog_interactions" in tables: + columns = _column_names("blog_interactions") + indexes = _index_names("blog_interactions") + with op.batch_alter_table("blog_interactions") as batch_op: + if "ix_blog_interactions_blog_event_created" in indexes: + batch_op.drop_index("ix_blog_interactions_blog_event_created") + if "ix_blog_interactions_blog_id" in indexes: + batch_op.drop_index("ix_blog_interactions_blog_id") + if "ix_blog_interactions_url_event_created" not in indexes: + batch_op.create_index( + "ix_blog_interactions_url_event_created", + ["normalized_url", "event_type", "created_at"], + ) + if "blog_id" in columns: + batch_op.drop_column("blog_id") + + +def downgrade() -> None: + """Recreate removed blog_id columns for rollback. + + Args: + None. + + Returns: + None. Recreated values are nullable because historical URL-keyed event + rows cannot always be relinked after a graph reset. + """ + + tables = _table_names() + if "recommendation_impressions" in tables: + columns = _column_names("recommendation_impressions") + indexes = _index_names("recommendation_impressions") + unique_constraints = _unique_constraint_names("recommendation_impressions") + with op.batch_alter_table("recommendation_impressions") as batch_op: + if "blog_id" not in columns: + batch_op.add_column(sa.Column("blog_id", sa.Integer(), nullable=True)) + if "ix_recommendation_impressions_url_created" in indexes: + batch_op.drop_index("ix_recommendation_impressions_url_created") + if "uq_recommendation_impression_request_url" in unique_constraints: + batch_op.drop_constraint("uq_recommendation_impression_request_url", type_="unique") + if "uq_recommendation_impression_request_blog" not in unique_constraints: + batch_op.create_unique_constraint( + "uq_recommendation_impression_request_blog", + ["request_id", "blog_id"], + ) + if "ix_recommendation_impressions_blog_id" not in indexes: + batch_op.create_index("ix_recommendation_impressions_blog_id", ["blog_id"]) + if "ix_recommendation_impressions_blog_created" not in indexes: + batch_op.create_index( + "ix_recommendation_impressions_blog_created", + ["blog_id", "created_at"], + ) + + if "blog_interactions" in tables: + columns = _column_names("blog_interactions") + indexes = _index_names("blog_interactions") + with op.batch_alter_table("blog_interactions") as batch_op: + if "blog_id" not in columns: + batch_op.add_column(sa.Column("blog_id", sa.Integer(), nullable=True)) + if "ix_blog_interactions_url_event_created" in indexes: + batch_op.drop_index("ix_blog_interactions_url_event_created") + if "ix_blog_interactions_blog_id" not in indexes: + batch_op.create_index("ix_blog_interactions_blog_id", ["blog_id"]) + if "ix_blog_interactions_blog_event_created" not in indexes: + batch_op.create_index( + "ix_blog_interactions_blog_event_created", + ["blog_id", "event_type", "created_at"], + ) diff --git a/doc/api-docs.md b/doc/api-docs.md index 389d962..88c13be 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -528,9 +528,9 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 同一个 `event_uuid` 重复上报时不会重复计数,响应中会返回 `duplicate: true` - `entrance_kind` 与 `entrance_url` 为必填字段。`entrance_kind` 使用稳定、可聚合的路口种类,例如 `random_blog_page`、`home_search_result`、`blog_detail_discovery_path`、`blog_detail_relation_graph`;`entrance_url` 保留触发动作时的原始页面 URL 或上下文 URL,便于追溯具体来源。 -- 若传入 `request_uuid` 或 `impression_id`,服务端会校验它们存在且与 `blog_id` 匹配 +- 若传入 `request_uuid` 或 `impression_id`,服务端会校验它们存在且与当前 blog 的 `normalized_url` 匹配 - 前端不应因为事件上报失败而阻塞用户跳转或标注主流程 -- 持久化时事件落到 `blog_interactions`,其中 `entrance_kind` 与 `entrance_url` 单独存列并建立索引,便于按稳定路口维度统计详情打开、外链打开和标签选择。 +- 持久化时事件落到 `blog_interactions`,以 `normalized_url` 作为博客归因键;其中 `entrance_kind` 与 `entrance_url` 单独存列并建立索引,便于按稳定路口维度统计详情打开、外链打开和标签选择。 错误语义: @@ -1291,7 +1291,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - 仅允许在 crawler 运行器不处于 `starting/running/stopping` 时调用 - 若运行器忙碌,返回 `409`,错误详情为 `crawler_busy` - 会清空 `blogs`、`edges`、`raw_discovered_urls` -- 不会删除人工 label 相关数据:`blog_labels(normalized_url, title, label_id, created_time, updated_time)` 和 `blog_label_tags` 会被保留 +- 不会删除 users、sessions、seeds、人工 label、recommendation 事件等其它表;`seeds.blog_id` 会置空以解除到 `blogs` 的引用 - backend 在数据库重置后会尝试调用 `search /internal/search/reindex` - 即使 search 重建失败,数据库重置结果仍会返回,并附带 `search_reindexed=false` @@ -1302,13 +1302,8 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 "ok": true, "blogs_deleted": 12, "edges_deleted": 34, + "raw_discovered_urls_deleted": 56, "logs_deleted": 0, - "blog_link_labels_deleted": 0, - "blog_label_tags_deleted": 0, - "blog_labels_preserved": 8, - "blog_label_subjects_preserved": 0, - "blog_link_labels_preserved": 13, - "blog_label_tags_preserved": 6, "search_reindexed": true, "search": { "blogs": 0, @@ -1573,13 +1568,13 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 ### `POST /internal/recommendations/random-blog-batches` -用途:为 backend 创建随机博客推荐批次,并写入 `recommendation_requests` 与 `recommendation_impressions`。 +用途:为 backend 创建随机博客推荐批次,并写入 `recommendation_requests` 与 `recommendation_impressions`;曝光表以 `normalized_url` 持久归因,不保存 `blog_id`。 请求体字段与 `POST /api/recommendations/random-blog-batches` 一致,额外允许 backend 传入已解析的 `user_id`。 ### `POST /internal/recommendation-events` -用途:为 backend 写入幂等推荐交互事件,数据落到 `blog_interactions`。 +用途:为 backend 写入幂等推荐交互事件,数据落到 `blog_interactions`,并以 `normalized_url` 持久归因。 请求体字段与 `POST /api/recommendation-events` 一致,额外允许 backend 传入已解析的 `user_id`。其中 `entrance_kind` 与 `entrance_url` 仍为必填字段,persistence-api 会清洗长度并写入 `blog_interactions.entrance_kind` / `blog_interactions.entrance_url`。 @@ -1846,9 +1841,8 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 行为说明: - 清空 `blogs`、`edges`、`raw_discovered_urls` -- 保留 URL-keyed 人工 label 数据与 tag 定义 +- 不删除其它表;`seeds.blog_id` 会置空以解除到 `blogs` 的引用 - `logs_deleted` 固定返回 `0` -- 重置主键计数器 响应: @@ -1857,13 +1851,8 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 "ok": true, "blogs_deleted": 12, "edges_deleted": 34, - "logs_deleted": 0, - "blog_link_labels_deleted": 0, - "blog_label_tags_deleted": 0, - "blog_labels_preserved": 8, - "blog_label_subjects_preserved": 0, - "blog_link_labels_preserved": 13, - "blog_label_tags_preserved": 6 + "raw_discovered_urls_deleted": 56, + "logs_deleted": 0 } ``` diff --git a/persistence_api/models.py b/persistence_api/models.py index 51c8271..42b3ef2 100644 --- a/persistence_api/models.py +++ b/persistence_api/models.py @@ -303,8 +303,8 @@ class RecommendationImpressionModel(Base): __tablename__ = "recommendation_impressions" __table_args__ = ( UniqueConstraint("request_id", "position", name="uq_recommendation_impression_request_position"), - UniqueConstraint("request_id", "blog_id", name="uq_recommendation_impression_request_blog"), - Index("ix_recommendation_impressions_blog_created", "blog_id", "created_at"), + UniqueConstraint("request_id", "normalized_url", name="uq_recommendation_impression_request_url"), + Index("ix_recommendation_impressions_url_created", "normalized_url", "created_at"), ) id: Mapped[int] = mapped_column(primary_key=True) @@ -313,7 +313,6 @@ class RecommendationImpressionModel(Base): nullable=False, index=True, ) - blog_id: Mapped[int] = mapped_column(ForeignKey("blogs.blog_id", ondelete="CASCADE"), nullable=False, index=True) normalized_url: Mapped[str] = mapped_column(Text, nullable=False, index=True) position: Mapped[int] = mapped_column(Integer, nullable=False) score: Mapped[int | None] = mapped_column(Integer, nullable=True) @@ -334,7 +333,7 @@ class BlogInteractionModel(Base): __tablename__ = "blog_interactions" __table_args__ = ( - Index("ix_blog_interactions_blog_event_created", "blog_id", "event_type", "created_at"), + Index("ix_blog_interactions_url_event_created", "normalized_url", "event_type", "created_at"), Index("ix_blog_interactions_request_event", "request_id", "event_type"), ) @@ -350,7 +349,6 @@ class BlogInteractionModel(Base): nullable=True, index=True, ) - blog_id: Mapped[int] = mapped_column(ForeignKey("blogs.blog_id", ondelete="CASCADE"), nullable=False, index=True) normalized_url: Mapped[str] = mapped_column(Text, nullable=False, index=True) event_type: Mapped[str] = mapped_column(Text, nullable=False, index=True) position: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/persistence_api/repository.py b/persistence_api/repository.py index 42a9977..92465ec 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -3532,7 +3532,6 @@ def create_random_recommendation_batch( blog_id = _business_blog_id(blog) impression = RecommendationImpressionModel( request_id=recommendation.id, - blog_id=int(blog_id), normalized_url=str(blog.normalized_url), position=position, score=None, @@ -3580,7 +3579,6 @@ def _blog_interaction_payload(self, interaction: BlogInteractionModel) -> dict[s "event_uuid": interaction.event_uuid, "request_id": interaction.request_id, "impression_id": interaction.impression_id, - "blog_id": interaction.blog_id, "normalized_url": interaction.normalized_url, "event_type": interaction.event_type, "position": interaction.position, @@ -3670,7 +3668,7 @@ def record_blog_interaction( impression = session.get(RecommendationImpressionModel, impression_id) if impression is None: raise ValueError("recommendation_impression_not_found") - if int(impression.blog_id) != int(blog_id): + if str(impression.normalized_url) != str(blog.normalized_url): raise ValueError("recommendation_impression_blog_mismatch") if recommendation is not None and int(impression.request_id) != int(recommendation.id): raise ValueError("recommendation_impression_request_mismatch") @@ -3680,7 +3678,6 @@ def record_blog_interaction( event_uuid=clean_event_uuid, request_id=recommendation.id if recommendation is not None else None, impression_id=impression.id if impression is not None else None, - blog_id=int(blog_id), normalized_url=str(blog.normalized_url), event_type=clean_event_type, position=position, @@ -3714,7 +3711,7 @@ def get_blog_recommendation_stats(self, blog_id: int) -> dict[str, Any] | None: impressions = int( session.scalar( select(func.count(RecommendationImpressionModel.id)).where( - RecommendationImpressionModel.blog_id == blog_id + RecommendationImpressionModel.normalized_url == blog.normalized_url ) ) or 0 @@ -3723,20 +3720,22 @@ def get_blog_recommendation_stats(self, blog_id: int) -> dict[str, Any] | None: str(event_type): int(count or 0) for event_type, count in session.execute( select(BlogInteractionModel.event_type, func.count(BlogInteractionModel.id)) - .where(BlogInteractionModel.blog_id == blog_id) + .where(BlogInteractionModel.normalized_url == blog.normalized_url) .group_by(BlogInteractionModel.event_type) ).all() } unique_visitors = int( session.scalar( select(func.count(func.distinct(BlogInteractionModel.visitor_id))).where( - BlogInteractionModel.blog_id == blog_id + BlogInteractionModel.normalized_url == blog.normalized_url ) ) or 0 ) last_interaction_at = session.scalar( - select(func.max(BlogInteractionModel.created_at)).where(BlogInteractionModel.blog_id == blog_id) + select(func.max(BlogInteractionModel.created_at)).where( + BlogInteractionModel.normalized_url == blog.normalized_url + ) ) clicks = int(event_counts.get("click", 0)) detail_opens = int(event_counts.get("detail_open", 0)) @@ -4683,21 +4682,10 @@ def reset(self) -> dict[str, Any]: with session_scope(self.session_factory) as session: blogs_deleted = _count_selectable_rows(session, BlogModel) edges_deleted = _count_selectable_rows(session, EdgeModel) - users_preserved = _count_selectable_rows(session, UserModel) - user_sessions_preserved = _count_selectable_rows(session, UserSessionModel) - labels_preserved = _count_selectable_rows(session, BlogLabelModel) - user_labels_preserved = _count_selectable_rows(session, BlogUserLabelModel) - user_label_selections_preserved = _count_selectable_rows(session, BlogUserLabelSelectionModel) - label_tags_preserved = _count_selectable_rows(session, BlogLabelTagModel) - seeds_preserved = _count_selectable_rows(session, SeedModel) raw_urls_deleted = _count_selectable_rows(session, RawDiscoveredUrlModel) - recommendation_interactions_deleted = _count_selectable_rows(session, BlogInteractionModel) - recommendation_impressions_deleted = _count_selectable_rows(session, RecommendationImpressionModel) - recommendation_requests_deleted = _count_selectable_rows(session, RecommendationRequestModel) + # Seeds are durable configuration, but their nullable blog pointer + # must be cleared before deleting the referenced blog rows. session.query(SeedModel).update({SeedModel.blog_id: None}) - session.query(BlogInteractionModel).delete() - session.query(RecommendationImpressionModel).delete() - session.query(RecommendationRequestModel).delete() session.query(RawDiscoveredUrlModel).delete() session.query(EdgeModel).delete() session.query(BlogModel).delete() @@ -4705,22 +4693,8 @@ def reset(self) -> dict[str, Any]: "ok": True, "blogs_deleted": blogs_deleted, "edges_deleted": edges_deleted, - "logs_deleted": 0, - "users_preserved": users_preserved, - "user_sessions_preserved": user_sessions_preserved, - "blog_link_labels_deleted": 0, - "blog_label_tags_deleted": 0, - "blog_labels_preserved": labels_preserved, - "blog_labels_userlabel_preserved": user_labels_preserved, - "blog_user_label_selections_preserved": user_label_selections_preserved, - "blog_label_subjects_preserved": 0, - "blog_link_labels_preserved": labels_preserved, - "blog_label_tags_preserved": label_tags_preserved, - "seeds_preserved": seeds_preserved, "raw_discovered_urls_deleted": raw_urls_deleted, - "recommendation_interactions_deleted": recommendation_interactions_deleted, - "recommendation_impressions_deleted": recommendation_impressions_deleted, - "recommendation_requests_deleted": recommendation_requests_deleted, + "logs_deleted": 0, } diff --git a/tests/test_repository.py b/tests/test_repository.py index da05eb3..62bb7fe 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -78,7 +78,7 @@ def fake_repository( def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) -> None: - """Reset should wipe graph data while retaining durable seed records.""" + """Reset should wipe only graph queue tables while retaining other records.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") first_blog_id, inserted = repository.upsert_blog( url="https://blog.example.com/", @@ -95,31 +95,67 @@ def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) - domain="friend.example.com", ) assert inserted is True + repository.mark_blog_result( + blog_id=first_blog_id, + crawl_status="FINISHED", + status_code=200, + friend_links_count=1, + metadata_captured=True, + title="Blog Example", + ) repository.add_edge( from_blog_id=first_blog_id, to_blog_id=second_blog_id, link_url_raw="https://friend.example.com/", link_text="Friend Blog", ) + repository.create_raw_discovered_url( + source_blog_id=first_blog_id, + normalized_url="https://raw.example.com/", + status="success", + ) repository.add_log( blog_id=first_blog_id, stage="crawl", result="ok", message="This should not be persisted", ) + user = repository.register_user(email="reset-user@example.com", password="long enough") + batch = repository.create_random_recommendation_batch( + count=1, + visitor_id="visitor-reset", + session_id="session-reset", + source="reset-test", + page_url="http://localhost/reset-test", + ) + recommendation_item = batch["items"][0] + repository.record_blog_interaction( + event_uuid="reset-event", + event_type="detail_open", + blog_id=recommendation_item["id"], + visitor_id="visitor-reset", + session_id="session-reset", + entrance_kind="reset_test", + entrance_url="http://localhost/reset-test", + request_uuid=recommendation_item["request_uuid"], + impression_id=recommendation_item["impression_id"], + interaction_order=1, + ) result = repository.reset() assert result["ok"] is True assert result["blogs_deleted"] == 2 assert result["edges_deleted"] == 1 + assert result["raw_discovered_urls_deleted"] == 1 assert result["logs_deleted"] == 0 - assert result["seeds_preserved"] == 1 - assert result["blog_link_labels_deleted"] == 0 - assert result["blog_label_tags_deleted"] == 0 - assert result["recommendation_interactions_deleted"] == 0 - assert result["recommendation_impressions_deleted"] == 0 - assert result["recommendation_requests_deleted"] == 0 + assert set(result) == { + "ok", + "blogs_deleted", + "edges_deleted", + "raw_discovered_urls_deleted", + "logs_deleted", + } assert repository.list_blogs() == [] assert repository.list_edges() == [] assert repository.list_logs() == [] @@ -130,6 +166,10 @@ def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) - assert seed is not None assert seed.normalized_url == "https://blog.example.com/" assert seed.blog_id is None + assert repository.get_user_by_session_token(token=user["token"]) is not None + assert session.scalar(select(RecommendationRequestModel).limit(1)) is not None + assert session.scalar(select(RecommendationImpressionModel).limit(1)) is not None + assert session.scalar(select(BlogInteractionModel).limit(1)) is not None new_blog_id, inserted = repository.upsert_blog( url="https://reset.example.com/", @@ -138,6 +178,16 @@ def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) - ) assert inserted is True assert new_blog_id == 1 + restored_blog_id, inserted = repository.upsert_blog( + url="https://blog.example.com/", + normalized_url="https://blog.example.com/", + domain="blog.example.com", + ) + assert inserted is True + restored_stats = repository.get_blog_recommendation_stats(restored_blog_id) + assert restored_stats is not None + assert restored_stats["impressions"] == 1 + assert restored_stats["detail_opens"] == 1 def test_repository_register_login_and_session_profile(tmp_path: Path) -> None: @@ -1131,8 +1181,14 @@ def test_repository_persists_random_recommendation_batch_and_interaction_stats(t assert strategy_stats["by_strategy"][0]["clicks"] == 1 with session_scope(repository.session_factory) as session: assert session.scalar(select(RecommendationRequestModel).limit(1)) is not None - assert session.scalar(select(RecommendationImpressionModel).limit(1)) is not None - assert session.scalar(select(BlogInteractionModel).limit(1)) is not None + stored_impression = session.scalar(select(RecommendationImpressionModel).limit(1)) + stored_interaction = session.scalar(select(BlogInteractionModel).limit(1)) + assert stored_impression is not None + assert stored_impression.normalized_url == first["normalized_url"] + assert "blog_id" not in RecommendationImpressionModel.__table__.columns + assert stored_interaction is not None + assert stored_interaction.normalized_url == first["normalized_url"] + assert "blog_id" not in BlogInteractionModel.__table__.columns def test_repository_blog_catalog_uses_display_identity_fallbacks_for_legacy_rows(tmp_path: Path) -> None: @@ -1493,7 +1549,7 @@ def test_repository_blog_labels_are_keyed_by_url_across_reset_and_recrawl(tmp_pa labeled = repository.list_blog_labeling_candidates(label="blog", labeled=True) assert first["blog_id"] == first_raw_id - assert reset["blog_link_labels_preserved"] == 1 + assert reset["raw_discovered_urls_deleted"] == 1 assert second_raw_id != first_raw_id assert [row["id"] for row in labeled["items"]] == [second_raw_id] assert labeled["items"][0]["label_id"] == {"1": 1} @@ -1556,11 +1612,8 @@ def test_repository_blog_labeling_upsert_rejects_non_labelable_raw_targets_and_r assert labeled["items"][0]["label_id"] == {"1": 1, "4": 1} reset = repository.reset() - assert reset["blog_link_labels_deleted"] == 0 - assert reset["blog_label_tags_deleted"] == 0 - assert reset["blog_link_labels_preserved"] == 1 - assert reset["blog_labels_preserved"] == 1 - assert reset["blog_label_tags_preserved"] >= 6 + assert reset["blogs_deleted"] == 2 + assert reset["raw_discovered_urls_deleted"] == 1 assert repository.list_blog_labeling_candidates()["items"] == [] with session_scope(repository.session_factory) as session: label = session.scalar( @@ -1568,6 +1621,8 @@ def test_repository_blog_labeling_upsert_rejects_non_labelable_raw_targets_and_r ) assert label is not None assert label.label_id == {"1": 1, "4": 1} + assert session.scalar(select(BlogLabelTagModel).where(BlogLabelTagModel.slug == "blog")) is not None + assert session.scalar(select(BlogLabelTagModel).where(BlogLabelTagModel.slug == "unknown")) is not None assert set(label.__table__.columns.keys()) == { "normalized_url", "title", diff --git a/tests/test_service_split.py b/tests/test_service_split.py index 8f2cc86..b1d1147 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -244,8 +244,9 @@ def test_persistence_service_exposes_supported_repository_data(tmp_path: Path) - reset = client.post("/internal/database/reset") assert reset.status_code == 200 assert reset.json()["blogs_deleted"] == 2 + assert reset.json()["edges_deleted"] == 1 + assert reset.json()["raw_discovered_urls_deleted"] == 0 assert reset.json()["logs_deleted"] == 0 - assert reset.json()["blog_link_labels_deleted"] == 0 empty_catalog = client.get("/internal/blogs/catalog") assert empty_catalog.status_code == 200 @@ -1556,12 +1557,8 @@ def test_backend_service_preserves_supported_public_api_shape(monkeypatch) -> No "ok": True, "blogs_deleted": 3, "edges_deleted": 4, + "raw_discovered_urls_deleted": 5, "logs_deleted": 0, - "blog_link_labels_deleted": 0, - "blog_label_tags_deleted": 0, - "blog_label_subjects_preserved": 1, - "blog_link_labels_preserved": 1, - "blog_label_tags_preserved": 2, }, "requeue_failed_blogs": lambda self: {"requeued": 7}, }, @@ -1740,10 +1737,8 @@ def fake_get(url: str, **kwargs: object) -> httpx.Response: reset = client.post("/api/admin/database/reset", headers=admin_headers()) assert reset.status_code == 200 assert reset.json()["blogs_deleted"] == 3 - assert reset.json()["blog_link_labels_deleted"] == 0 - assert reset.json()["blog_label_tags_deleted"] == 0 - assert reset.json()["blog_link_labels_preserved"] == 1 - assert reset.json()["blog_label_tags_preserved"] == 2 + assert reset.json()["edges_deleted"] == 4 + assert reset.json()["raw_discovered_urls_deleted"] == 5 assert reset.json()["search_reindexed"] is True assert search.reindex_calls == 3 @@ -1920,7 +1915,7 @@ def list_logs(self) -> list[dict[str, object]]: return [] def reset(self) -> dict[str, object]: - return {"ok": True, "blogs_deleted": 0, "edges_deleted": 0, "logs_deleted": 0} + return {"ok": True, "blogs_deleted": 0, "edges_deleted": 0, "raw_discovered_urls_deleted": 0, "logs_deleted": 0} app = create_backend_app( BackendState(persistence=LabelingValidationStub(), crawler=StubCrawler(), search=StubSearch(), admin_token="secret-token") @@ -1995,7 +1990,7 @@ def list_logs(self) -> list[dict[str, object]]: return [] def reset(self) -> dict[str, object]: - return {"ok": True, "blogs_deleted": 0, "edges_deleted": 0, "logs_deleted": 0} + return {"ok": True, "blogs_deleted": 0, "edges_deleted": 0, "raw_discovered_urls_deleted": 0, "logs_deleted": 0} app = create_backend_app( BackendState(persistence=CatalogValidationStub(), crawler=StubCrawler(), search=StubSearch()) @@ -2071,7 +2066,7 @@ def list_logs(self) -> list[dict[str, object]]: return [] def reset(self) -> dict[str, object]: - return {"ok": True, "blogs_deleted": 0, "edges_deleted": 0, "logs_deleted": 0} + return {"ok": True, "blogs_deleted": 0, "edges_deleted": 0, "raw_discovered_urls_deleted": 0, "logs_deleted": 0} app = create_backend_app( BackendState(persistence=LookupValidationStub(), crawler=StubCrawler(), search=StubSearch()) @@ -2136,7 +2131,7 @@ def list_logs(self) -> list[dict[str, object]]: return [] def reset(self) -> dict[str, object]: - return {"ok": True, "blogs_deleted": 0, "edges_deleted": 0, "logs_deleted": 0} + return {"ok": True, "blogs_deleted": 0, "edges_deleted": 0, "raw_discovered_urls_deleted": 0, "logs_deleted": 0} app = create_backend_app( BackendState(persistence=GraphNeighborNotFoundStub(), crawler=StubCrawler(), search=StubSearch()) @@ -2184,6 +2179,7 @@ def runtime_status(self) -> dict[str, object]: "ok": True, "blogs_deleted": 0, "edges_deleted": 0, + "raw_discovered_urls_deleted": 0, "logs_deleted": 0, }, }, From d3ed85d18646cc751e936e843e98765e03703577 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 20:53:48 +0100 Subject: [PATCH 26/35] =?UTF-8?q?=F0=9F=90=B3=20chore:=20=E4=BF=AE?= =?UTF-8?q?=E6=94=B9seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- seed.csv | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/seed.csv b/seed.csv index 4fb78b1..8021f18 100644 --- a/seed.csv +++ b/seed.csv @@ -1,3 +1,8 @@ url -https://www.qladgk.com/ +https://blog.leonus.cn/ https://moondvsted.space/ +https://blog.verynb.net/ +https://blog.sayori.org/ +https://liguang.wang/ +https://junsen.online/ +https://blog.rsjwy.com/ \ No newline at end of file From 3f33a2eaa39ce3c64462433993c43eeb3c8cef3c Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 21:00:59 +0100 Subject: [PATCH 27/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=8D=9A=E5=AE=A2?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E6=B7=BB=E5=8A=A0=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E5=8D=9A=E5=AE=A2=E7=88=AC=E8=99=AB=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/api-docs.md | 2 + frontend/src/lib/api.ts | 3 + frontend/src/pages/BlogDetailPage.tsx | 91 ++++++++++++++++++++++++ frontend/src/pages/VisualizationPage.tsx | 2 + frontend/src/types/graph.ts | 2 + 5 files changed, 100 insertions(+) diff --git a/doc/api-docs.md b/doc/api-docs.md index 88c13be..ea77ee7 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -767,6 +767,8 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 额外字段: +- `crawl_status`: 当前抓取执行状态,例如 `WAITING`、`PROCESSING`、`FAILED`、`FINISHED`;详情页会直接展示该字段 +- `crawl_error_kind`: 最近一次抓取失败分类;当 `crawl_status=FAILED` 时,详情页会把该字段作为失败原因展示,例如 `timeout`、`page_too_large`、`http_status`、`request_error` - `incoming_edges`: 所有 `to_blog_id == blog_id` 的边,每条边额外携带 `neighbor_blog` - `outgoing_edges`: 所有 `from_blog_id == blog_id` 的边,每条边额外携带 `neighbor_blog` - `recommended_blogs`: “朋友的朋友”推荐列表,规则是“当前博客的友链认识、但当前博客还没直接认识的博客” diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 692758b..0ba5fce 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -58,6 +58,7 @@ interface BackendGraphNode { icon_url: string | null; status_code?: number | null; crawl_status?: string; + crawl_error_kind?: string | null; friend_links_count?: number; last_crawled_at?: string | null; created_at?: string; @@ -759,6 +760,8 @@ export async function fetchBlogDetail(blogId: number): Promise { : null; return { ...toGraphNode(payload), + crawlStatus: payload.crawl_status ?? "WAITING", + crawlErrorKind: payload.crawl_error_kind ?? null, incomingLinks: payload.incoming_edges.length, outgoingLinks: payload.outgoing_edges.length, relatedNodes: Array.from(relatedNodesById.values()), diff --git a/frontend/src/pages/BlogDetailPage.tsx b/frontend/src/pages/BlogDetailPage.tsx index 21cb68c..9e0ba71 100644 --- a/frontend/src/pages/BlogDetailPage.tsx +++ b/frontend/src/pages/BlogDetailPage.tsx @@ -1,9 +1,13 @@ import { ArrowLeft, ArrowRight, + AlertTriangle, + CheckCircle2, + Clock3, Loader2, Network, Route, + RotateCcw, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ForceGraph2D, { type ForceGraphMethods } from "react-force-graph-2d"; @@ -33,6 +37,92 @@ function formatCount(value: number) { return new Intl.NumberFormat("zh-CN").format(value); } +/** + * Return a compact Chinese label for a crawl status value. + * + * @param crawlStatus Raw crawl status returned by the backend. + * @returns User-facing crawl status label. + */ +function formatCrawlStatus(crawlStatus: string) { + const labels: Record = { + WAITING: "等待抓取", + PROCESSING: "正在抓取", + FINISHED: "抓取完成", + FAILED: "抓取失败", + }; + return labels[crawlStatus] ?? crawlStatus; +} + +/** + * Return a readable failure reason for a crawl error kind. + * + * @param crawlErrorKind Stable backend failure category. + * @returns User-facing failure reason. + */ +function formatCrawlErrorKind(crawlErrorKind: string | null) { + if (!crawlErrorKind) { + return "未记录具体失败原因"; + } + const labels: Record = { + timeout: "请求超时", + http_status: "目标站点返回异常 HTTP 状态", + invalid_url: "URL 无效", + page_too_large: "页面体积超过抓取限制", + request_error: "网络请求失败", + worker_error: "抓取任务执行异常", + }; + return labels[crawlErrorKind] ?? crawlErrorKind.replaceAll("_", " "); +} + +/** + * Render the crawl execution status for the current detail blog. + * + * @param props Blog detail payload with crawl status fields. + * @returns Compact status block, including failure reason when failed. + */ +function BlogCrawlStatus({ detail }: { detail: BlogDetail }) { + const isFailed = detail.crawlStatus === "FAILED"; + const statusMeta = (() => { + switch (detail.crawlStatus) { + case "FINISHED": + return { + Icon: CheckCircle2, + className: "border-emerald-200 bg-emerald-50 text-emerald-700", + }; + case "PROCESSING": + return { + Icon: RotateCcw, + className: "border-sky-200 bg-sky-50 text-sky-700", + }; + case "FAILED": + return { + Icon: AlertTriangle, + className: "border-rose-200 bg-rose-50 text-rose-700", + }; + default: + return { + Icon: Clock3, + className: "border-slate-200 bg-slate-50 text-slate-600", + }; + } + })(); + const { Icon } = statusMeta; + + return ( +
+
+ + 抓取状态:{formatCrawlStatus(detail.crawlStatus)} +
+ {isFailed ? ( +
+ 失败原因:{formatCrawlErrorKind(detail.crawlErrorKind)} +
+ ) : null} +
+ ); +} + /** * Render a detail page hero icon with favicon fallbacks. * @@ -573,6 +663,7 @@ export function BlogDetailPage() { > {detail.url} +
diff --git a/frontend/src/pages/VisualizationPage.tsx b/frontend/src/pages/VisualizationPage.tsx index 3882b61..c3b66d6 100644 --- a/frontend/src/pages/VisualizationPage.tsx +++ b/frontend/src/pages/VisualizationPage.tsx @@ -213,6 +213,8 @@ export function VisualizationPage() { } setBlogDetail({ ...node, + crawlStatus: "FINISHED", + crawlErrorKind: null, incomingLinks: node.incomingCount ?? 0, outgoingLinks: node.outgoingCount ?? 0, relatedNodes: [], diff --git a/frontend/src/types/graph.ts b/frontend/src/types/graph.ts index 2cc6ecb..15ab8fb 100644 --- a/frontend/src/types/graph.ts +++ b/frontend/src/types/graph.ts @@ -93,6 +93,8 @@ export interface BlogRelationGraph { } export interface BlogDetail extends GraphNode { + crawlStatus: string; + crawlErrorKind: string | null; incomingLinks: number; outgoingLinks: number; relatedNodes: GraphNode[]; From 61562fca1e5127c9cdb550c3506b60c40b7bf46e Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Sun, 7 Jun 2026 21:02:42 +0100 Subject: [PATCH 28/35] =?UTF-8?q?=F0=9F=90=9E=20fix:=20ruff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 -- persistence_api/repository.py | 1 - tests/test_service_split.py | 1 - 3 files changed, 4 deletions(-) diff --git a/backend/main.py b/backend/main.py index 93398f8..16d6533 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,11 +5,9 @@ from dataclasses import dataclass import ipaddress import socket -from threading import Thread from time import sleep from typing import Any from typing import Callable -from typing import NoReturn from urllib.parse import urlsplit import httpx diff --git a/persistence_api/repository.py b/persistence_api/repository.py index 92465ec..b1fc9aa 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -3529,7 +3529,6 @@ def create_random_recommendation_batch( items: list[dict[str, Any]] = [] for position, row in enumerate(rows, start=1): blog = row[0] - blog_id = _business_blog_id(blog) impression = RecommendationImpressionModel( request_id=recommendation.id, normalized_url=str(blog.normalized_url), diff --git a/tests/test_service_split.py b/tests/test_service_split.py index b1d1147..0ee3431 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -2,7 +2,6 @@ import json from pathlib import Path -from time import sleep import httpx import pytest From 46f87ad10e44fb9e684857bb379e212ecfcb8819 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Tue, 9 Jun 2026 08:56:17 +0100 Subject: [PATCH 29/35] =?UTF-8?q?=F0=9F=8E=88=20perf:=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=8F=AF=E8=A7=86=E5=8C=96=E5=9B=BE=E8=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/GraphVisualization.test.tsx | 74 +++++++- .../src/components/GraphVisualization.tsx | 179 +++++++++++++++++- 2 files changed, 242 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/GraphVisualization.test.tsx b/frontend/src/components/GraphVisualization.test.tsx index 430f0d0..aa544c9 100644 --- a/frontend/src/components/GraphVisualization.test.tsx +++ b/frontend/src/components/GraphVisualization.test.tsx @@ -206,6 +206,9 @@ describe("GraphVisualization", () => { label: "Alpha Blog", iconUrl: "/api/icons/proxy?url=https%3A%2F%2Falpha.example.com%2Ffavicon.ico", val: 1, + x: expect.any(Number), + y: expect.any(Number), + z: expect.any(Number), }), expect.objectContaining({ id: "2", @@ -213,6 +216,9 @@ describe("GraphVisualization", () => { label: "Beta Blog", iconUrl: undefined, val: 1, + x: expect.any(Number), + y: expect.any(Number), + z: expect.any(Number), }), ]), links: [ @@ -227,6 +233,66 @@ describe("GraphVisualization", () => { ); }); + test("seeds disconnected graph regions into separated initial positions", () => { + const twoRegionGraph: GraphData = { + nodes: [ + { + id: 1, + url: "https://alpha.example.com/", + domain: "alpha.example.com", + title: "Alpha Blog", + iconUrl: null, + }, + { + id: 2, + url: "https://beta.example.com/", + domain: "beta.example.com", + title: "Beta Blog", + iconUrl: null, + }, + { + id: 3, + url: "https://gamma.example.com/", + domain: "gamma.example.com", + title: "Gamma Blog", + iconUrl: null, + }, + { + id: 4, + url: "https://delta.example.com/", + domain: "delta.example.com", + title: "Delta Blog", + iconUrl: null, + }, + ], + edges: [ + { + id: "1-2", + source: 1, + target: 2, + linkText: null, + linkUrlRaw: "https://alpha.example.com/link", + }, + { + id: "3-4", + source: 3, + target: 4, + linkText: null, + linkUrlRaw: "https://gamma.example.com/link", + }, + ], + }; + + render(); + + const graphProps = forceGraphRenders.at(-1)!; + const [firstRegionNode] = graphProps.graphData.nodes; + const thirdNode = graphProps.graphData.nodes[2]; + const distance = Math.hypot(firstRegionNode.x - thirdNode.x, firstRegionNode.y - thirdNode.y, firstRegionNode.z - thirdNode.z); + + expect(distance).toBeGreaterThan(500); + }); + test("uses the original graph node for click callbacks", () => { const handleNodeClick = vi.fn(); render(); @@ -373,10 +439,10 @@ describe("GraphVisualization", () => { tuneNaturalClusterForces(graph as never); expect(forceCalls).toContainEqual(["center", null]); - expect(chargeForce.strength).toHaveBeenCalledWith(-190); - expect(chargeForce.distanceMax).toHaveBeenCalledWith(720); - expect(linkForce.distance).toHaveBeenCalledWith(58); - expect(linkForce.strength).toHaveBeenCalledWith(0.56); + expect(chargeForce.strength).toHaveBeenCalledWith(-280); + expect(chargeForce.distanceMax).toHaveBeenCalledWith(1400); + expect(linkForce.distance).toHaveBeenCalledWith(96); + expect(linkForce.strength).toHaveBeenCalledWith(0.24); expect(d3ReheatSimulation).toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/GraphVisualization.tsx b/frontend/src/components/GraphVisualization.tsx index 15a6f8a..bfcc4c3 100644 --- a/frontend/src/components/GraphVisualization.tsx +++ b/frontend/src/components/GraphVisualization.tsx @@ -10,10 +10,12 @@ const GRAPH_RENDER_MIN_STABILITY_TICKS = 80; const GRAPH_RENDER_STABLE_SAMPLE_TICKS = 20; const GRAPH_RENDER_AVERAGE_MOVEMENT_THRESHOLD = 0.15; const GRAPH_RENDER_MAX_MOVEMENT_THRESHOLD = 1; -const GRAPH_LINK_DISTANCE = 58; -const GRAPH_LINK_STRENGTH = 0.56; -const GRAPH_CHARGE_STRENGTH = -190; -const GRAPH_CHARGE_DISTANCE_MAX = 720; +const GRAPH_LINK_DISTANCE = 96; +const GRAPH_LINK_STRENGTH = 0.24; +const GRAPH_CHARGE_STRENGTH = -280; +const GRAPH_CHARGE_DISTANCE_MAX = 1400; +const GRAPH_SEEDED_GROUP_SIZE = 18; +const GRAPH_SEEDED_LAYOUT_SPACING = 360; interface GraphVisualizationProps { data: GraphData; @@ -150,6 +152,169 @@ function targetIdOf(link: RenderLink): string { return typeof link.target === "object" ? link.target.id : String(link.target); } +/** + * Build an undirected adjacency map from renderable links. + * + * @param nodes Nodes that can be displayed in the graph. + * @param links Links whose endpoints both exist in the graph. + * @returns Map keyed by node id with neighboring node ids. + */ +function buildAdjacency(nodes: RenderNode[], links: RenderLink[]): Map> { + const adjacency = new Map>(); + for (const node of nodes) { + adjacency.set(node.id, new Set()); + } + + for (const link of links) { + const source = sourceIdOf(link); + const target = targetIdOf(link); + if (source === target || !adjacency.has(source) || !adjacency.has(target)) { + continue; + } + adjacency.get(source)?.add(target); + adjacency.get(target)?.add(source); + } + + return adjacency; +} + +/** + * Find deterministic weakly connected components for initial graph placement. + * + * @param nodes Nodes that can be displayed in the graph. + * @param adjacency Undirected adjacency map. + * @returns Components sorted by size and id for stable layout. + */ +function findConnectedComponents(nodes: RenderNode[], adjacency: Map>): string[][] { + const visited = new Set(); + const nodeIds = nodes.map((node) => node.id).sort((left, right) => Number(left) - Number(right)); + const components: string[][] = []; + + for (const nodeId of nodeIds) { + if (visited.has(nodeId)) { + continue; + } + + const component: string[] = []; + const queue = [nodeId]; + visited.add(nodeId); + + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index]; + component.push(current); + const neighbors = Array.from(adjacency.get(current) ?? []).sort((left, right) => Number(left) - Number(right)); + for (const neighbor of neighbors) { + if (visited.has(neighbor)) { + continue; + } + visited.add(neighbor); + queue.push(neighbor); + } + } + + components.push(component); + } + + return components.sort((left, right) => right.length - left.length || Number(left[0]) - Number(right[0])); +} + +/** + * Split a large connected component into deterministic layout groups. + * + * @param component Node ids in one connected component. + * @param adjacency Undirected adjacency map. + * @returns Layout groups used only for initial spatial seeding. + */ +function splitComponentIntoLayoutGroups(component: string[], adjacency: Map>): string[][] { + if (component.length <= GRAPH_SEEDED_GROUP_SIZE) { + return [component]; + } + + const seedCount = Math.max(2, Math.ceil(component.length / GRAPH_SEEDED_GROUP_SIZE)); + const componentIds = new Set(component); + const seeds = component + .slice() + .sort((left, right) => { + const degreeDelta = (adjacency.get(right)?.size ?? 0) - (adjacency.get(left)?.size ?? 0); + return degreeDelta || Number(left) - Number(right); + }) + .slice(0, seedCount); + + const groupByNodeId = new Map(); + const queues = seeds.map((seed, index) => { + groupByNodeId.set(seed, index); + return [seed]; + }); + + for (let queueIndex = 0; queues.some((queue) => queue.length > 0); queueIndex = (queueIndex + 1) % queues.length) { + const current = queues[queueIndex].shift(); + if (!current) { + continue; + } + + const neighbors = Array.from(adjacency.get(current) ?? []).sort((left, right) => Number(left) - Number(right)); + for (const neighbor of neighbors) { + if (!componentIds.has(neighbor) || groupByNodeId.has(neighbor)) { + continue; + } + groupByNodeId.set(neighbor, queueIndex); + queues[queueIndex].push(neighbor); + } + } + + const groups = seeds.map((): string[] => []); + for (const nodeId of component) { + const groupIndex = groupByNodeId.get(nodeId) ?? 0; + groups[groupIndex].push(nodeId); + } + + return groups.filter((group) => group.length > 0); +} + +/** + * Seed deterministic 3D positions so force layout starts from separated regions. + * + * @param nodes Nodes to position. + * @param links Links used to infer components and layout groups. + * @returns Nodes with initial x/y/z coordinates. + */ +export function seedGraphInitialPositions(nodes: RenderNode[], links: RenderLink[]): RenderNode[] { + const adjacency = buildAdjacency(nodes, links); + const layoutGroups = findConnectedComponents(nodes, adjacency).flatMap((component) => + splitComponentIntoLayoutGroups(component, adjacency), + ); + const groupIndexByNodeId = new Map(); + for (const [groupIndex, group] of layoutGroups.entries()) { + for (const nodeId of group) { + groupIndexByNodeId.set(nodeId, groupIndex); + } + } + + const nodeIndexInGroup = new Map(); + for (const group of layoutGroups) { + const sortedGroup = group.slice().sort((left, right) => Number(left) - Number(right)); + sortedGroup.forEach((nodeId, index) => nodeIndexInGroup.set(nodeId, index)); + } + + const groupCount = Math.max(1, layoutGroups.length); + return nodes.map((node) => { + const groupIndex = groupIndexByNodeId.get(node.id) ?? 0; + const indexInGroup = nodeIndexInGroup.get(node.id) ?? 0; + const groupSize = Math.max(1, layoutGroups[groupIndex]?.length ?? 1); + const groupAngle = (Math.PI * 2 * groupIndex) / groupCount; + const groupRing = GRAPH_SEEDED_LAYOUT_SPACING * (1 + Math.floor(groupIndex / Math.max(1, Math.ceil(Math.sqrt(groupCount))))); + const localAngle = (Math.PI * 2 * indexInGroup) / groupSize; + const localRadius = 34 + 8 * Math.sqrt(groupSize) + 5 * (indexInGroup % 5); + + return { + ...node, + x: Math.cos(groupAngle) * groupRing + Math.cos(localAngle) * localRadius, + y: Math.sin(groupAngle) * groupRing + Math.sin(localAngle) * localRadius, + z: ((indexInGroup % 7) - 3) * 24 + (groupIndex % 3) * 60, + }; + }); +} + function buildExplicitIconUrls(node: GraphNode, useNodeIcons: boolean): string[] { const iconUrl = node.iconUrl?.trim(); if (!useNodeIcons || !iconUrl) { @@ -203,7 +368,7 @@ function buildGraphData(data: GraphData, useNodeIcons: boolean): RenderGraphData val: Math.max(1, degreeById.get(node.id) ?? node.degree ?? 1), })); - return { nodes, links }; + return { nodes: seedGraphInitialPositions(nodes, links), links }; } function buildNeighborIds(graphData: RenderGraphData, highlightNodeId?: number): Set { @@ -316,7 +481,7 @@ function createNodeObject(node: RenderNode, color: string, size: number): THREE. } /** - * Tune the d3 force engine so related blogs cluster without a global spherical pull. + * Tune the d3 force engine so related blogs cluster without collapsing into a global sphere. * * @param graph Force graph instance exposed by react-force-graph-3d. */ @@ -523,7 +688,7 @@ export function GraphVisualization({ node.fy = node.y; node.fz = node.z; }} - d3VelocityDecay={0.38} + d3VelocityDecay={0.44} d3AlphaDecay={0.025} cooldownTicks={cooldownTicks} onEngineTick={handleEngineTick} From 401420dea727244e08a28b99ef7c4fc5d3e956de Mon Sep 17 00:00:00 2001 From: Lianggang Pan <101978760+ligeaaa@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:07:43 +0100 Subject: [PATCH 30/35] Delete tracker directory --- tracker/log-system.md | 78 ------------------------------------------- 1 file changed, 78 deletions(-) delete mode 100644 tracker/log-system.md diff --git a/tracker/log-system.md b/tracker/log-system.md deleted file mode 100644 index 0626477..0000000 --- a/tracker/log-system.md +++ /dev/null @@ -1,78 +0,0 @@ -# Unified Logging System - -Created: 2026-05-24 - -## Background - -The project had crawler-only lifecycle logs, legacy no-op database log endpoints, -and task-specific maintenance events. The goal is to build one shared logging -system used by every service, with unified fields, service-specific directories, -separate files for review, and moderate event granularity. - -## Goals - -- Use one shared module for Python service logging. -- Write readable service-specific files under a common root. -- Keep application, error, and access logs separate. -- Carry request ids across frontend, backend, crawler, search, and persistence. -- Keep domain maintenance events separate from application logs. -- Avoid new dependencies unless explicitly requested. - -## Decisions - -- Use Python standard `logging` rather than adding `structlog` or `loguru`. -- Default to JSON lines for production-friendly parsing. -- Store local logs under `logs/{app,error,access}//` as hourly - service slices, and Docker logs under `volumes/logs/{app,error,access}//`. -- Preserve `/internal/logs` as a legacy no-op compatibility endpoint. -- Keep URL refilter and blog dedup progress as persisted domain events. - -## Progress - -- Added `shared.observability` with logging setup, JSON formatter, request-id - middleware, access logging, and event helper. -- Added log configuration fields to `Settings`. -- Wired backend, crawler, persistence-api, search, and frontend service entrypoints. -- Added `x-request-id` propagation through frontend proxy and shared HTTP clients. -- Migrated crawler lifecycle logs and model-consensus warnings to stable events. -- Updated Docker Compose to mount shared log volume. -- Updated API/config/architecture documentation and `.env` / `.env.example`. -- Added observability regression tests. -- Added a dedicated `url-refilter` event logger so dangerous raw URL refilter - runs write service-parallel files under `logs/app/url-refilter/` and - `logs/error/url-refilter/` instead of being buried in normal persistence logs. -- 2026-05-25 follow-up: backend now configures the same `url-refilter` - service logger and logs the start, crawler stop, persistence execution - request, completion, and failure boundaries so clicking the Admin button - creates a dedicated log entry immediately. -- 2026-05-25 follow-up: URL refilter lifecycle logs now include explicit - start, finish, failed-exit, and backend-close events with `reason` plus a - human message; execution progress logs are emitted at each 10,000 scanned - raw URLs, with final completion represented by the finish event. - -## Validation - -- Passed: `./.venv/bin/pytest tests/test_observability_logging.py tests/test_crawler_service.py tests/test_service_split.py::test_persistence_service_exposes_supported_repository_data tests/test_service_split.py::test_backend_url_refilter_run_stops_crawler_and_persists_progress_events` -- Passed: `./.venv/bin/pytest tests/test_observability_logging.py tests/test_service_split.py tests/test_pipeline.py tests/test_crawler_model_consensus.py` -- Passed: `./.venv/bin/pytest` (`152 passed`) -- Verified by tests: type-specific hourly log directories, JSON fields, request-id middleware, and shared HTTP client propagation. -- 2026-05-25 update: added regression coverage for dedicated service-parallel - maintenance log directories. - -## Closure - -Completed on 2026-05-24. The logging system now has a shared implementation, -service entrypoint integration, Docker volume routing, documentation, and -regression coverage. - -Update 2026-05-24: log files now group by type first (`app`, `error`, -`access`), then by service, slice hourly using `-YYYYMMDD-HH.log`, -delete slices older than `HEYBLOG_LOG_RETENTION_DAYS`, and expose those settings -in `.env` and `.env.example`. - -## Remaining Risks - -- Uvicorn's own access logger still exists unless deployment disables or - redirects it; HeyBlog now writes its own normalized access hourly slices. -- This pass does not implement audit logs or metrics; the boundaries are - documented for future work. From e58aa56f72fe29ff625db8ffaae84624b63d6a48 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Thu, 11 Jun 2026 13:26:34 +0100 Subject: [PATCH 31/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 15 + .../20260609_01_extend_user_system.py | 103 +++ ...60611_01_add_pending_user_registrations.py | 51 ++ backend/main.py | 80 ++- doc/api-docs.md | 123 +++- doc/config-reference.md | 12 + doc/public-admin-boundary.md | 21 +- docker-compose.yml | 11 + frontend/src/App.test.tsx | 174 +++++ frontend/src/App.tsx | 20 +- frontend/src/components/Navigation.tsx | 8 +- frontend/src/lib/api.ts | 74 +- frontend/src/lib/auth.ts | 16 + frontend/src/pages/AdminPage.tsx | 28 +- frontend/src/pages/ProfilePage.tsx | 286 ++++++-- frontend/src/types/graph.ts | 15 + persistence_api/email_delivery.py | 237 +++++++ persistence_api/main.py | 78 ++- persistence_api/models.py | 72 ++ persistence_api/repository.py | 641 +++++++++++++++++- shared/config.py | 22 + shared/http_clients/persistence_http.py | 35 + tests/test_repository.py | 179 ++++- tests/test_service_split.py | 180 ++++- 24 files changed, 2349 insertions(+), 132 deletions(-) create mode 100644 alembic/versions/20260609_01_extend_user_system.py create mode 100644 alembic/versions/20260611_01_add_pending_user_registrations.py create mode 100644 persistence_api/email_delivery.py diff --git a/.env.example b/.env.example index a8eb718..b25b67e 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,21 @@ HEYBLOG_DOCKER_EXPORT_DIR=/data/exports HEYBLOG_DOCKER_SEARCH_CACHE_DIR=/data/search-cache HEYBLOG_DOCKER_LOG_DIR=/data/logs +# Public frontend URL used in verification and password reset links +HEYBLOG_PUBLIC_BASE_URL=http://127.0.0.1:3000 + +# Email delivery. Keep disabled for local dev unless SMTP credentials are set. +HEYBLOG_EMAIL_PROVIDER=disabled +HEYBLOG_EMAIL_FROM=no-reply@example.com +HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=true +HEYBLOG_SMTP_HOST=smtp.example.com +HEYBLOG_SMTP_PORT=587 +HEYBLOG_SMTP_USERNAME= +HEYBLOG_SMTP_PASSWORD= +HEYBLOG_SMTP_USE_TLS=true +HEYBLOG_SMTP_USE_SSL=false +HEYBLOG_SMTP_TIMEOUT_SECONDS=10.0 + # Logging HEYBLOG_LOG_DIR=/file/path/logs HEYBLOG_LOG_LEVEL=INFO diff --git a/alembic/versions/20260609_01_extend_user_system.py b/alembic/versions/20260609_01_extend_user_system.py new file mode 100644 index 0000000..26472e2 --- /dev/null +++ b/alembic/versions/20260609_01_extend_user_system.py @@ -0,0 +1,103 @@ +"""Extend user auth lifecycle fields. + +Revision ID: 20260609_01 +Revises: 20260607_04 +Create Date: 2026-06-09 16:40:00 UTC +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260609_01" +down_revision = "20260607_04" +branch_labels = None +depends_on = None + + +def _table_names() -> set[str]: + """Return table names currently visible to Alembic.""" + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def _column_names(table_name: str) -> set[str]: + """Return column names for one table currently visible to Alembic.""" + return {column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def upgrade() -> None: + """Add user lifecycle columns, token rows, and audit rows.""" + existing_tables = _table_names() + if "users" in existing_tables: + user_columns = _column_names("users") + if "role" not in user_columns: + op.add_column("users", sa.Column("role", sa.Text(), nullable=False, server_default="user")) + if "is_active" not in user_columns: + op.add_column("users", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true())) + if "email_verified_at" not in user_columns: + op.add_column("users", sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True)) + if "password_changed_at" not in user_columns: + op.add_column("users", sa.Column("password_changed_at", sa.DateTime(timezone=True), nullable=True)) + if "last_login_at" not in user_columns: + op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True)) + op.create_check_constraint( + "ck_users_role", + "users", + "role IN ('admin', 'user')", + ) + + if "user_verification_tokens" not in existing_tables: + op.create_table( + "user_verification_tokens", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("token_hash", sa.Text(), nullable=False), + sa.Column("purpose", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("token_hash", name="uq_user_verification_tokens_token_hash"), + ) + op.create_index("ix_user_verification_tokens_user_id", "user_verification_tokens", ["user_id"]) + op.create_index("ix_user_verification_tokens_token_hash", "user_verification_tokens", ["token_hash"]) + op.create_index("ix_user_verification_tokens_purpose", "user_verification_tokens", ["purpose"]) + + if "user_audit_events" not in existing_tables: + op.create_table( + "user_audit_events", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("event_type", sa.Text(), nullable=False), + sa.Column("details", sa.JSON(), nullable=False, server_default=sa.text("'{}'")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_user_audit_events_user_id", "user_audit_events", ["user_id"]) + op.create_index("ix_user_audit_events_event_type", "user_audit_events", ["event_type"]) + + +def downgrade() -> None: + """Remove user lifecycle columns, token rows, and audit rows.""" + existing_tables = _table_names() + if "user_audit_events" in existing_tables: + op.drop_index("ix_user_audit_events_event_type", table_name="user_audit_events") + op.drop_index("ix_user_audit_events_user_id", table_name="user_audit_events") + op.drop_table("user_audit_events") + if "user_verification_tokens" in existing_tables: + op.drop_index("ix_user_verification_tokens_purpose", table_name="user_verification_tokens") + op.drop_index("ix_user_verification_tokens_token_hash", table_name="user_verification_tokens") + op.drop_index("ix_user_verification_tokens_user_id", table_name="user_verification_tokens") + op.drop_table("user_verification_tokens") + if "users" in existing_tables: + user_columns = _column_names("users") + op.drop_constraint("ck_users_role", "users", type_="check") + for column_name in ( + "last_login_at", + "password_changed_at", + "email_verified_at", + "is_active", + "role", + ): + if column_name in user_columns: + op.drop_column("users", column_name) diff --git a/alembic/versions/20260611_01_add_pending_user_registrations.py b/alembic/versions/20260611_01_add_pending_user_registrations.py new file mode 100644 index 0000000..67723b7 --- /dev/null +++ b/alembic/versions/20260611_01_add_pending_user_registrations.py @@ -0,0 +1,51 @@ +"""Add pending user registration table. + +Revision ID: 20260611_01 +Revises: 20260609_01 +Create Date: 2026-06-11 00:00:00 UTC +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260611_01" +down_revision = "20260609_01" +branch_labels = None +depends_on = None + + +def _table_names() -> set[str]: + """Return table names currently visible to Alembic.""" + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def upgrade() -> None: + """Create pending registrations for verify-before-persist signup.""" + if "pending_user_registrations" in _table_names(): + return + op.create_table( + "pending_user_registrations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("email", sa.Text(), nullable=False), + sa.Column("password_hash", sa.Text(), nullable=False), + sa.Column("token_hash", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("email", name="uq_pending_user_registrations_email"), + sa.UniqueConstraint("token_hash", name="uq_pending_user_registrations_token_hash"), + ) + op.create_index("ix_pending_user_registrations_email", "pending_user_registrations", ["email"]) + op.create_index("ix_pending_user_registrations_token_hash", "pending_user_registrations", ["token_hash"]) + + +def downgrade() -> None: + """Drop pending registrations.""" + if "pending_user_registrations" not in _table_names(): + return + op.drop_index("ix_pending_user_registrations_token_hash", table_name="pending_user_registrations") + op.drop_index("ix_pending_user_registrations_email", table_name="pending_user_registrations") + op.drop_table("pending_user_registrations") diff --git a/backend/main.py b/backend/main.py index 16d6533..618642c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,6 +55,23 @@ class UserAuthRequest(BaseModel): password: str +class EmailRequest(BaseModel): + email: str + + +class TokenRequest(BaseModel): + token: str + + +class PasswordResetRequest(BaseModel): + token: str + password: str + + +class UpdateUserRoleRequest(BaseModel): + role: str + + class ReplaceBlogLabelsRequest(BaseModel): tag_ids: list[int] | None = None label_id: dict[str, int] | None = None @@ -422,16 +439,29 @@ def require_admin_access(request: Request) -> None: state = get_state() if state.admin_dev_bypass: return - if not state.admin_token: - raise HTTPException(status_code=503, detail="admin_auth_not_configured") authorization = request.headers.get("authorization", "").strip() if not authorization: raise HTTPException(status_code=401, detail="admin_auth_required") scheme, _, token = authorization.partition(" ") if scheme.lower() != "bearer" or not token: raise HTTPException(status_code=401, detail="admin_auth_required") - if token != state.admin_token: + if state.admin_token and token == state.admin_token: + return + get_user_by_session_token = getattr(state.persistence, "get_user_by_session_token", None) + if get_user_by_session_token is None: + if not state.admin_token: + raise HTTPException(status_code=503, detail="admin_auth_not_configured") + raise HTTPException(status_code=403, detail="admin_auth_invalid") + try: + user = get_user_by_session_token(token=token) + except httpx.HTTPStatusError as exc: + _raise_upstream_http_error(exc, default="admin_auth_invalid", detail_override="admin_auth_invalid") + if user is None: + if not state.admin_token: + raise HTTPException(status_code=503, detail="admin_auth_not_configured") raise HTTPException(status_code=403, detail="admin_auth_invalid") + if user.get("role") != "admin" or not user.get("is_active") or not user.get("email_verified"): + raise HTTPException(status_code=403, detail="admin_auth_forbidden") def optional_user(request: Request) -> dict[str, Any] | None: authorization = request.headers.get("authorization", "").strip() @@ -605,6 +635,30 @@ def logout_user(request: Request, user: dict[str, Any] = Depends(require_user)) lambda: get_state().persistence.revoke_user_session(token=token) ) + @app.post("/api/auth/email/verify/request") + def request_email_verification(payload: EmailRequest) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.request_email_verification(email=payload.email) + ) + + @app.post("/api/auth/email/verify/confirm") + def confirm_email_verification(payload: TokenRequest) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.confirm_email_verification(token=payload.token) + ) + + @app.post("/api/auth/password/forgot") + def request_password_reset(payload: EmailRequest) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.request_password_reset(email=payload.email) + ) + + @app.post("/api/auth/password/reset") + def reset_user_password(payload: PasswordResetRequest) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.reset_user_password(token=payload.token, password=payload.password) + ) + @app.get("/api/me/label-selections") def get_my_label_selections( limit: int = 50, @@ -620,6 +674,26 @@ def get_my_label_stats(user: dict[str, Any] = Depends(require_user)) -> dict[str lambda: get_state().persistence.get_user_label_stats(user_id=int(user["id"])) ) + @app.get("/api/admin/users") + def list_admin_users( + page: int = 1, + page_size: int = 50, + _: None = Depends(require_admin_access), + ) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.list_users(page=page, page_size=page_size) + ) + + @app.patch("/api/admin/users/{user_id}/role") + def patch_admin_user_role( + user_id: int, + payload: UpdateUserRoleRequest, + _: None = Depends(require_admin_access), + ) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.update_user_role(user_id=user_id, role=payload.role) + ) + @app.get("/api/admin/blog-labeling/candidates") def get_blog_labeling_candidates( page: int = 1, diff --git a/doc/api-docs.md b/doc/api-docs.md index ea77ee7..9fe25e1 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -72,6 +72,10 @@ Public API 由 `backend` 服务统一暴露,供 public 浏览、图谱与用 - `POST /api/auth/login` - `GET /api/auth/me` - `POST /api/auth/logout` +- `POST /api/auth/email/verify/request` +- `POST /api/auth/email/verify/confirm` +- `POST /api/auth/password/forgot` +- `POST /api/auth/password/reset` - `GET /api/me/label-selections` - `GET /api/blogs/catalog` - `POST /api/recommendations/random-blog-batches` @@ -100,7 +104,7 @@ Public API 由 `backend` 服务统一暴露,供 public 浏览、图谱与用 ### 2.2 Admin API -Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并要求 `Authorization: Bearer `: +Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并要求 `Authorization: Bearer `。该 token 可以是 legacy `HEYBLOG_ADMIN_TOKEN`,也可以是已登录、已验证邮箱且 `role=admin` 的用户 session token: - `GET /api/admin/runtime/status` - `GET /api/admin/runtime/current` @@ -122,6 +126,8 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - `POST /api/admin/blog-labeling/title-preview` - `PUT /api/admin/blog-labeling/labels/{blog_id}` - `GET /api/admin/recommendation-stats` +- `GET /api/admin/users` +- `PATCH /api/admin/users/{user_id}/role` 补充脚本: @@ -131,9 +137,11 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 认证语义: +- Admin API 接受 legacy `HEYBLOG_ADMIN_TOKEN`,也接受已登录、已验证邮箱且 `role=admin` 的用户 session token。 - 未提供 token:`401 admin_auth_required` - token 不合法:`403 admin_auth_invalid` -- 未配置 `HEYBLOG_ADMIN_TOKEN` 且未开启 `HEYBLOG_ADMIN_DEV_BYPASS=true`:`503 admin_auth_not_configured` +- token 属于普通用户或未验证 admin 候选账号:`403 admin_auth_forbidden` +- 未配置 `HEYBLOG_ADMIN_TOKEN` 且未开启 `HEYBLOG_ADMIN_DEV_BYPASS=true`,同时请求也不是合法 admin 用户 session:`503 admin_auth_not_configured` ### 2.2 内部服务 API @@ -286,7 +294,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 #### `POST /api/auth/register` -用途:使用邮箱和密码注册普通用户,并立即创建登录会话。当前版本不做邮箱验证。 +用途:提交邮箱和密码并发送验证邮件。该接口只创建临时待验证注册记录,不创建登录 session,也不会把用户账号写入 `users`。只有用户通过验证码/验证链接完成 `/api/auth/email/verify/confirm` 后,系统才会创建持久化用户账号。游客无需入库;未登录请求即游客身份。 请求体: @@ -301,23 +309,20 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 ```json { - "token": "session-token", - "expires_at": "2026-06-25T00:00:00+00:00", - "user": { - "id": 1, - "email": "user@example.com", - "display_name": "user", - "created_at": "2026-05-26T22:04:50+00:00", - "updated_at": "2026-05-26T22:04:50+00:00" - } + "sent": true, + "verification_token": "dev-verification-token", + "verification_url": "http://127.0.0.1:3000/profile?verify_token=dev-verification-token", + "expires_at": "2026-06-12T00:00:00+00:00" } ``` 错误语义: - `409 email_already_registered` +- `409 email_registration_pending` - `422 invalid_email` - `422 password_too_short` +- `502 email_delivery_failed` #### `POST /api/auth/login` @@ -344,6 +349,100 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 用途:注销当前 session token。请求头同 `/api/auth/me`。 +#### `POST /api/auth/email/verify/request` + +用途:为已经创建但尚未验证的普通用户或 admin 用户重新生成邮箱验证 token。未知邮箱和仍处于注册待验证阶段、尚未持久化的邮箱返回中性成功语义,避免暴露账号是否存在;待验证新注册应继续使用注册邮件中的链接完成账号创建。 + +邮件通道由 `persistence-api` 的 `HEYBLOG_EMAIL_PROVIDER` 控制。默认 `disabled` 模式不会连接 SMTP,并会在响应体中返回一次性验证 token/link,方便本地调试和手动验证。设置 `HEYBLOG_EMAIL_PROVIDER=smtp` 后,系统会把验证链接发送到用户邮箱;生产环境应设置 `HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=false`,让 API 响应只保留发送状态和过期时间,不暴露明文 token。 + +请求体: + +```json +{ + "email": "user@example.com" +} +``` + +成功响应: + +```json +{ + "sent": true, + "verification_token": "dev-verification-token", + "verification_url": "http://127.0.0.1:3000/profile?verify_token=dev-verification-token", + "expires_at": "2026-06-10T00:00:00+00:00" +} +``` + +生产 SMTP 且关闭 dev token 暴露后的成功响应: + +```json +{ + "sent": true, + "expires_at": "2026-06-10T00:00:00+00:00" +} +``` + +错误语义: + +- `502 email_delivery_failed` + +#### `POST /api/auth/email/verify/confirm` + +用途:消费邮箱验证邮件链接中的一次性 token。对于新注册 token,该接口先创建持久化用户账号,再返回已验证用户资料;对于历史未验证账号 token,该接口把已有用户标记为已验证。token 只保存 hash,过期或已消费后不可复用。浏览器打开 `/profile?verify_token=...` 时,前端会自动调用该接口完成验证,随后提示用户登录。 + +请求体: + +```json +{ + "token": "dev-verification-token" +} +``` + +返回:创建或更新后的用户资料。新注册用户默认 `role=user`、`email_verified=true`,不会自动创建登录 session。 + +#### `POST /api/auth/password/forgot` + +用途:请求密码重置 token。未知邮箱返回中性成功语义。 + +请求体: + +```json +{ + "email": "user@example.com" +} +``` + +默认开发响应包含可直接使用的 `reset_token` 与 `reset_url`。设置 `HEYBLOG_EMAIL_PROVIDER=smtp` 后,系统会把 reset link 发送到用户邮箱;生产环境应设置 `HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=false`,让 API 响应隐藏明文 reset token。后端始终只持久化 token hash。 + +生产 SMTP 且关闭 dev token 暴露后的成功响应: + +```json +{ + "sent": true, + "expires_at": "2026-06-10T00:00:00+00:00" +} +``` + +错误语义: + +- `502 email_delivery_failed` + +#### `POST /api/auth/password/reset` + +用途:消费一次性密码重置 token,设置新密码,并撤销该用户所有旧 session。 + +请求体: + +```json +{ + "token": "dev-reset-token", + "password": "new long enough" +} +``` + +返回:更新后的用户资料。 + #### `GET /api/me/label-selections` 用途:返回当前登录用户最近的随机博客标注选择。 diff --git a/doc/config-reference.md b/doc/config-reference.md index 4f0ffa7..f088e70 100644 --- a/doc/config-reference.md +++ b/doc/config-reference.md @@ -44,6 +44,17 @@ Docker Compose 也会从仓库根目录的 `.env` 读取变量。 | `HEYBLOG_LOG_CONSOLE_ENABLED` | `true` | 全部 Python 服务 | 是否同时输出到控制台,方便 Docker logs 查看 | | `HEYBLOG_LOG_RETENTION_DAYS` | `7` | 全部 Python 服务 | 自动清理超过该天数的小时切片日志 | | `HEYBLOG_BACKEND_BASE_URL` | `http://127.0.0.1:8000` | `frontend` | 浏览器代理层转发到公共 API 的目标地址 | +| `HEYBLOG_PUBLIC_BASE_URL` | `http://127.0.0.1:3000` | `persistence-api` | 生成邮箱验证与密码重置链接时使用的公开前端基准地址 | +| `HEYBLOG_EMAIL_PROVIDER` | `disabled` | `persistence-api` | 用户生命周期邮件 provider。可选 `disabled`/`noop` 或 `smtp`;默认不连接邮件服务 | +| `HEYBLOG_EMAIL_FROM` | 空 | `persistence-api` | SMTP 邮件发件人地址;启用 `smtp` 时必须设置 | +| `HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS` | `true` | `persistence-api` | 是否在验证/重置 API 响应中暴露 raw token/link。生产 SMTP 应设置为 `false` | +| `HEYBLOG_SMTP_HOST` | 空 | `persistence-api` | SMTP 服务器主机名 | +| `HEYBLOG_SMTP_PORT` | `587` | `persistence-api` | SMTP 服务器端口 | +| `HEYBLOG_SMTP_USERNAME` | 未设置 | `persistence-api` | SMTP 用户名;为空时不执行登录 | +| `HEYBLOG_SMTP_PASSWORD` | 未设置 | `persistence-api` | SMTP 密码;为空时不执行登录 | +| `HEYBLOG_SMTP_USE_TLS` | `true` | `persistence-api` | 是否在普通 SMTP 连接上使用 STARTTLS | +| `HEYBLOG_SMTP_USE_SSL` | `false` | `persistence-api` | 是否使用隐式 SMTP-over-SSL 连接 | +| `HEYBLOG_SMTP_TIMEOUT_SECONDS` | `10.0` | `persistence-api` | SMTP 连接与发送超时时间 | | `HEYBLOG_CRAWLER_BASE_URL` | `http://127.0.0.1:8010` | `backend` | `backend` 调用 `crawler` 的内部地址 | | `HEYBLOG_SEARCH_BASE_URL` | `http://127.0.0.1:8020` | `backend` | `backend` 调用 `search` 的内部地址 | | `HEYBLOG_PERSISTENCE_BASE_URL` | `http://127.0.0.1:8030` | `backend`、`crawler`、`search` | 三个服务访问持久化边界的内部地址 | @@ -86,6 +97,7 @@ Docker Compose 也会从仓库根目录的 `.env` 读取变量。 | `persistence-api` | `HEYBLOG_DB_DSN` | 启用 PostgreSQL 后端 | | `persistence-api` | `HEYBLOG_DOCKER_DECISION_MODEL_ROOT` | 全库规则重扫读取的容器内运行时模型根目录 | | `persistence-api` | `HEYBLOG_DECISION_MODEL_CONSENSUS_STRATEGY` / `HEYBLOG_DECISION_MODEL_CONSENSUS_THRESHOLD` | 全库规则重扫使用的模型共识策略与 weighted 阈值 | +| `persistence-api` | `HEYBLOG_EMAIL_PROVIDER` / `HEYBLOG_EMAIL_FROM` / `HEYBLOG_SMTP_*` | 发送邮箱验证与密码重置邮件 | ## 3.1 运行时资源目录约定 diff --git a/doc/public-admin-boundary.md b/doc/public-admin-boundary.md index 911d488..cc0b1b9 100644 --- a/doc/public-admin-boundary.md +++ b/doc/public-admin-boundary.md @@ -20,6 +20,7 @@ Public capabilities: - inspect blog detail and graph relationships - search by blog/site/relation clues - submit user seed blog URLs for crawling +- register, log in, verify email, reset password, and save personal label selections ### Admin @@ -37,6 +38,7 @@ Admin capabilities: - manual crawl/bootstrap triggers - database maintenance - blog labeling +- user list and simple role management ## API Boundary @@ -52,6 +54,14 @@ Admin capabilities: - `GET /api/graph/snapshots/{version}` - `GET /api/stats` - `POST /api/blogs/user-seeds` +- `POST /api/auth/register` +- `POST /api/auth/login` +- `GET /api/auth/me` +- `POST /api/auth/logout` +- `POST /api/auth/email/verify/request` +- `POST /api/auth/email/verify/confirm` +- `POST /api/auth/password/forgot` +- `POST /api/auth/password/reset` ### Admin API @@ -67,10 +77,17 @@ Admin capabilities: - `GET /api/admin/blog-labeling/tags` - `POST /api/admin/blog-labeling/tags` - `PUT /api/admin/blog-labeling/labels/{blog_id}` +- `GET /api/admin/users` +- `PATCH /api/admin/users/{user_id}/role` ## Auth -- Admin API requires `Authorization: Bearer ` unless `HEYBLOG_ADMIN_DEV_BYPASS=true` is explicitly enabled. +- HeyBlog has three identities: guest, regular user, and admin. +- Guest is any request without a valid user session. +- Regular users are stored with `role=user`. +- Admin users are stored with `role=admin` and must have a verified email to access admin APIs. +- Admin API accepts either `Authorization: Bearer ` as a migration/bootstrap fallback, or an admin user session token. - Missing token returns `401 admin_auth_required`. - Invalid token returns `403 admin_auth_invalid`. -- Unconfigured admin auth returns `503 admin_auth_not_configured`. +- Non-admin or unverified user tokens return `403 admin_auth_forbidden`. +- Unconfigured legacy admin auth with no valid admin user session returns `503 admin_auth_not_configured`. diff --git a/docker-compose.yml b/docker-compose.yml index 031a7c2..a307878 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,17 @@ services: HEYBLOG_DECISION_MODEL_ROOT: ${HEYBLOG_DOCKER_DECISION_MODEL_ROOT:-/app/runtime_resources/models/url_decision/current} HEYBLOG_DECISION_MODEL_CONSENSUS_STRATEGY: ${HEYBLOG_DECISION_MODEL_CONSENSUS_STRATEGY:-weighted_average} HEYBLOG_DECISION_MODEL_CONSENSUS_THRESHOLD: ${HEYBLOG_DECISION_MODEL_CONSENSUS_THRESHOLD:-0.4} + HEYBLOG_PUBLIC_BASE_URL: ${HEYBLOG_PUBLIC_BASE_URL:-http://127.0.0.1:3000} + HEYBLOG_EMAIL_PROVIDER: ${HEYBLOG_EMAIL_PROVIDER:-disabled} + HEYBLOG_EMAIL_FROM: ${HEYBLOG_EMAIL_FROM:-} + HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS: ${HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS:-true} + HEYBLOG_SMTP_HOST: ${HEYBLOG_SMTP_HOST:-} + HEYBLOG_SMTP_PORT: ${HEYBLOG_SMTP_PORT:-587} + HEYBLOG_SMTP_USERNAME: ${HEYBLOG_SMTP_USERNAME:-} + HEYBLOG_SMTP_PASSWORD: ${HEYBLOG_SMTP_PASSWORD:-} + HEYBLOG_SMTP_USE_TLS: ${HEYBLOG_SMTP_USE_TLS:-true} + HEYBLOG_SMTP_USE_SSL: ${HEYBLOG_SMTP_USE_SSL:-false} + HEYBLOG_SMTP_TIMEOUT_SECONDS: ${HEYBLOG_SMTP_TIMEOUT_SECONDS:-10.0} HEYBLOG_LOG_DIR: ${HEYBLOG_DOCKER_LOG_DIR:-/data/logs} HEYBLOG_LOG_LEVEL: ${HEYBLOG_LOG_LEVEL:-INFO} HEYBLOG_LOG_FORMAT: ${HEYBLOG_LOG_FORMAT:-json} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 8726b98..b007a26 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -376,6 +376,48 @@ beforeEach(() => { if (url.pathname === "/api/status") { return new Response(JSON.stringify(statusPayload)); } + if (url.pathname === "/api/auth/register") { + return new Response( + JSON.stringify({ + sent: true, + verification_token: "mail-token", + expires_at: "2026-06-11T00:00:00Z", + }), + ); + } + if (url.pathname === "/api/auth/email/verify/confirm") { + return new Response( + JSON.stringify({ + id: 7, + email: "new@example.com", + display_name: "new", + role: "user", + is_active: true, + email_verified: true, + email_verified_at: "2026-06-10T00:10:00Z", + created_at: "2026-06-10T00:00:00Z", + updated_at: "2026-06-10T00:10:00Z", + }), + ); + } + if (url.pathname === "/api/auth/me") { + return new Response( + JSON.stringify({ + id: 7, + email: "new@example.com", + display_name: "new", + role: "user", + is_active: true, + email_verified: false, + email_verified_at: null, + created_at: "2026-06-10T00:00:00Z", + updated_at: "2026-06-10T00:00:00Z", + }), + ); + } + if (url.pathname === "/api/me/label-stats") { + return new Response(JSON.stringify({ label_count: 3 })); + } if (url.pathname === "/api/stats") { return new Response(JSON.stringify({ total_blogs: statusPayload.total_blogs, total_edges: statusPayload.total_edges })); } @@ -579,6 +621,60 @@ test("renders the home summary with URL search while keeping queue metrics and c expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining("/api/status"), expect.anything()); }); +test("shows the admin navigation item only for active verified admin sessions", async () => { + window.localStorage.setItem( + "heyblog_user_session", + JSON.stringify({ + token: "admin-session-token", + expiresAt: "2026-07-10T00:00:00Z", + user: { + id: 70, + email: "admin@magic-knowledge.top", + displayName: "admin", + role: "admin", + isActive: true, + emailVerified: true, + emailVerifiedAt: "2026-06-11T00:00:00Z", + createdAt: "2026-06-11T00:00:00Z", + updatedAt: "2026-06-11T00:00:00Z", + }, + }), + ); + + render(); + + expect(await screen.findByRole("link", { name: "管理" })).toHaveAttribute("href", "/admin"); +}); + +test("hides admin navigation and renders 404 for non-admin direct admin URLs", async () => { + window.localStorage.setItem( + "heyblog_user_session", + JSON.stringify({ + token: "user-session-token", + expiresAt: "2026-07-10T00:00:00Z", + user: { + id: 71, + email: "1304412077@qq.com", + displayName: "user", + role: "user", + isActive: true, + emailVerified: true, + emailVerifiedAt: "2026-06-11T00:00:00Z", + createdAt: "2026-06-11T00:00:00Z", + updatedAt: "2026-06-11T00:00:00Z", + }, + }), + ); + window.history.replaceState({}, "", "/admin"); + + render(); + + expect(await screen.findByText("404")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "页面不存在" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "管理" })).not.toBeInTheDocument(); + expect(screen.queryByText("管理控制台")).not.toBeInTheDocument(); +}); + test("lets home users search normalized URLs and open the blog detail route", async () => { catalogItems = catalogItems.map((item) => Number(item.id) === 3 ? { ...item, icon_url: "https://finished-blog.example.com/favicon.ico" } : item, @@ -723,6 +819,84 @@ test("shows the exact rule-filter reason when user seed submission fails", async expect(screen.getByRole("dialog", { name: "当前未找到该博客,是否将该博客加入博客网络?" })).toBeInTheDocument(); }); +test("keeps new registrations signed out until email verification", async () => { + window.history.replaceState({}, "", "/profile"); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "没有账号,注册一个" })); + fireEvent.change(screen.getByLabelText("邮箱"), { target: { value: "New@Example.com" } }); + fireEvent.change(screen.getByLabelText("密码"), { target: { value: "correct horse" } }); + fireEvent.click(screen.getByRole("button", { name: "注册并发送验证邮件" })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "登录账号" })).toBeInTheDocument(); + }); + expect(screen.getByText("验证邮件已发送,请验证邮箱后登录。")).toBeInTheDocument(); + expect(window.localStorage.getItem("heyblog_user_session")).toBeNull(); + expect(screen.queryByText("当前账号")).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "数据标注" })).not.toBeInTheDocument(); +}); + +test("confirms email automatically when opened from a verification email link", async () => { + window.localStorage.setItem( + "heyblog_user_session", + JSON.stringify({ + token: "new-user-token", + expiresAt: "2026-07-10T00:00:00Z", + user: { + id: 7, + email: "new@example.com", + displayName: "new", + role: "user", + isActive: true, + emailVerified: false, + emailVerifiedAt: null, + createdAt: "2026-06-10T00:00:00Z", + updatedAt: "2026-06-10T00:00:00Z", + }, + }), + ); + window.history.replaceState({}, "", "/profile?verify_token=mail-token"); + + render(); + + await waitFor(() => { + expect(screen.getByText(/已验证/)).toBeInTheDocument(); + }); + expect( + vi + .mocked(fetch) + .mock.calls.some( + ([input, init]) => + String(input).includes("/api/auth/email/verify/confirm") && + String(init?.body).includes('"token":"mail-token"'), + ), + ).toBe(true); + expect(screen.getByRole("heading", { name: "数据标注" })).toBeInTheDocument(); + expect(screen.getByText(/当前总共标注了/)).toBeInTheDocument(); +}); + +test("confirms email links without requiring a local session", async () => { + window.history.replaceState({}, "", "/profile?verify_token=mail-token"); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "登录账号" })).toBeInTheDocument(); + }); + expect( + vi + .mocked(fetch) + .mock.calls.some( + ([input, init]) => + String(input).includes("/api/auth/email/verify/confirm") && + String(init?.body).includes('"token":"mail-token"'), + ), + ).toBe(true); + expect(screen.queryByText(/Token/)).not.toBeInTheDocument(); +}); + test("adds a random blog route that loads nine finished cards and refreshes them on demand", async () => { window.history.replaceState({}, "", "/random"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3500b58..a754c07 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { Toaster } from "sonner"; +import { Navigation } from "./components/Navigation"; +import { hasStoredAdminSession } from "./lib/auth"; import { AboutPage } from "./pages/AboutPage"; import { AdminPage } from "./pages/AdminPage"; import { BlogDetailPage } from "./pages/BlogDetailPage"; @@ -9,6 +11,22 @@ import { ProfilePage } from "./pages/ProfilePage"; import { RandomBlogPage } from "./pages/RandomBlogPage"; import { VisualizationPage } from "./pages/VisualizationPage"; +function NotFoundPage() { + return ( +
+ +
+

404

+

页面不存在

+
+
+ ); +} + +function AdminRoute() { + return hasStoredAdminSession() ? : ; +} + /** * Mount the routed frontend shell. * @@ -27,7 +45,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index 390b60b..7a159fd 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -1,5 +1,6 @@ -import { Dices, Filter, Home, Info, Network, UserCircle } from "lucide-react"; +import { Dices, Filter, Home, Info, Network, Shield, UserCircle } from "lucide-react"; import { NavLink } from "react-router-dom"; +import { hasStoredAdminSession } from "../lib/auth"; const navigationItems = [ { to: "/", label: "首页", icon: Home }, @@ -16,10 +17,13 @@ const navigationItems = [ * @returns Floating route navigation bar. */ export function Navigation() { + const visibleItems = hasStoredAdminSession() + ? [...navigationItems, { to: "/admin", label: "管理", icon: Shield }] + : navigationItems; return (
+
+ {!user.emailVerified ? ( + + ) : null} +
-
-
- -
-

数据标注

- {isLoadingProfile ? ( -
- - 正在加载个人数据... + {!user.emailVerified ? ( +
+

+ 验证邮件已发送,请打开邮箱并点击邮件中的验证链接。 +

- ) : ( -

- 当前总共标注了 {labelCount} 次。 -

- )} + ) : null}
+ + {user.emailVerified ? ( +
+

数据标注

+ {isLoadingProfile ? ( +
+ + 正在加载个人数据... +
+ ) : ( +

+ 当前总共标注了 {labelCount} 次。 +

+ )} +
+ ) : null} )} diff --git a/frontend/src/types/graph.ts b/frontend/src/types/graph.ts index 15ab8fb..e7b6a9c 100644 --- a/frontend/src/types/graph.ts +++ b/frontend/src/types/graph.ts @@ -188,6 +188,10 @@ export interface UserProfile { id: number; email: string; displayName: string; + role: "admin" | "user"; + isActive: boolean; + emailVerified: boolean; + emailVerifiedAt: string | null; createdAt: string | null; updatedAt: string | null; } @@ -196,6 +200,17 @@ export interface AuthSession { token: string; expiresAt: string | null; user: UserProfile; + emailVerification?: AuthLifecycleToken; +} + +export interface AuthLifecycleToken { + sent: boolean; + verificationToken?: string; + verificationUrl?: string; + resetToken?: string; + resetUrl?: string; + expiresAt?: string | null; + alreadyVerified?: boolean; } export interface UserLabelSelection { diff --git a/persistence_api/email_delivery.py b/persistence_api/email_delivery.py new file mode 100644 index 0000000..d77d2f7 --- /dev/null +++ b/persistence_api/email_delivery.py @@ -0,0 +1,237 @@ +"""Email delivery adapters for user lifecycle messages.""" + +from __future__ import annotations + +from dataclasses import dataclass +from email.message import EmailMessage +import smtplib +import ssl +from typing import Protocol + +from shared.config import Settings + + +class EmailDeliveryError(Exception): + """Raised when a configured email provider cannot deliver a message. + + Args: + message: Stable error code suitable for API translation. + """ + + +class EmailDelivery(Protocol): + """Interface for sending user lifecycle email messages. + + Implementations send already-generated verification and reset URLs. They + must not persist raw lifecycle tokens or expose provider credentials. + """ + + def send_verification_email(self, *, to_email: str, verification_url: str) -> None: + """Send a verification-link email. + + Args: + to_email: Recipient account email address. + verification_url: Public one-time verification URL. + + Returns: + None after the message has been accepted by the provider. + """ + + def send_password_reset_email(self, *, to_email: str, reset_url: str) -> None: + """Send a password-reset-link email. + + Args: + to_email: Recipient account email address. + reset_url: Public one-time password reset URL. + + Returns: + None after the message has been accepted by the provider. + """ + + +@dataclass(slots=True) +class NoopEmailDelivery: + """Email adapter that intentionally performs no provider call. + + This keeps local development and tests independent from networked SMTP + credentials while still exercising token generation flows. + """ + + def send_verification_email(self, *, to_email: str, verification_url: str) -> None: + """Ignore one verification message. + + Args: + to_email: Recipient account email address. + verification_url: Public one-time verification URL. + + Returns: + None. + """ + + del to_email, verification_url + + def send_password_reset_email(self, *, to_email: str, reset_url: str) -> None: + """Ignore one password reset message. + + Args: + to_email: Recipient account email address. + reset_url: Public one-time password reset URL. + + Returns: + None. + """ + + del to_email, reset_url + + +@dataclass(slots=True) +class SmtpEmailDelivery: + """SMTP-backed email adapter for verification and reset messages. + + Args: + host: SMTP server hostname. + port: SMTP server port. + from_email: Sender address used in lifecycle emails. + username: Optional SMTP username. + password: Optional SMTP password. + use_tls: Whether to upgrade the connection with STARTTLS. + use_ssl: Whether to connect with implicit SMTP-over-SSL. + timeout_seconds: Network timeout for SMTP operations. + """ + + host: str + port: int + from_email: str + username: str | None = None + password: str | None = None + use_tls: bool = True + use_ssl: bool = False + timeout_seconds: float = 10.0 + + def send_verification_email(self, *, to_email: str, verification_url: str) -> None: + """Send a verification-link email. + + Args: + to_email: Recipient account email address. + verification_url: Public one-time verification URL. + + Returns: + None after the SMTP server accepts the message. + """ + + self._send( + to_email=to_email, + subject="Verify your HeyBlog email", + text_body=( + "Verify your HeyBlog email address by opening this link:\n\n" + f"{verification_url}\n\n" + "If you did not request this, you can ignore this email." + ), + ) + + def send_password_reset_email(self, *, to_email: str, reset_url: str) -> None: + """Send a password-reset-link email. + + Args: + to_email: Recipient account email address. + reset_url: Public one-time password reset URL. + + Returns: + None after the SMTP server accepts the message. + """ + + self._send( + to_email=to_email, + subject="Reset your HeyBlog password", + text_body=( + "Reset your HeyBlog password by opening this link:\n\n" + f"{reset_url}\n\n" + "If you did not request this, you can ignore this email." + ), + ) + + def _send(self, *, to_email: str, subject: str, text_body: str) -> None: + """Build and send one plain-text email over SMTP. + + Args: + to_email: Recipient email address. + subject: Message subject line. + text_body: Plain-text message body. + + Returns: + None after the provider accepts the message. + + Raises: + EmailDeliveryError: Raised when the SMTP call fails or is + misconfigured. + """ + + if not self.host or not self.from_email: + raise EmailDeliveryError("email_delivery_not_configured") + + message = EmailMessage() + message["From"] = self.from_email + message["To"] = to_email + message["Subject"] = subject + message.set_content(text_body) + + try: + if self.use_ssl: + context = ssl.create_default_context() + with smtplib.SMTP_SSL(self.host, self.port, timeout=self.timeout_seconds, context=context) as smtp: + self._authenticate_if_configured(smtp) + smtp.send_message(message) + return + + with smtplib.SMTP(self.host, self.port, timeout=self.timeout_seconds) as smtp: + if self.use_tls: + context = ssl.create_default_context() + smtp.starttls(context=context) + self._authenticate_if_configured(smtp) + smtp.send_message(message) + except (OSError, smtplib.SMTPException) as exc: + raise EmailDeliveryError("email_delivery_failed") from exc + + def _authenticate_if_configured(self, smtp: smtplib.SMTP) -> None: + """Authenticate with SMTP when username and password are configured. + + Args: + smtp: Open SMTP connection. + + Returns: + None. + """ + + if self.username and self.password: + smtp.login(self.username, self.password) + + +def build_email_delivery(settings: Settings) -> EmailDelivery: + """Create the configured email delivery adapter. + + Args: + settings: Runtime settings loaded from environment variables. + + Returns: + SMTP adapter when `HEYBLOG_EMAIL_PROVIDER=smtp`; otherwise a no-op + adapter for development and tests. + + Raises: + ValueError: Raised when an unsupported email provider is configured. + """ + + provider = settings.email_provider.strip().lower() + if provider in {"", "disabled", "noop"}: + return NoopEmailDelivery() + if provider == "smtp": + return SmtpEmailDelivery( + host=settings.smtp_host, + port=settings.smtp_port, + from_email=settings.email_from, + username=settings.smtp_username, + password=settings.smtp_password, + use_tls=settings.smtp_use_tls, + use_ssl=settings.smtp_use_ssl, + timeout_seconds=settings.smtp_timeout_seconds, + ) + raise ValueError("unsupported_email_provider") diff --git a/persistence_api/main.py b/persistence_api/main.py index 04454b7..a4b8deb 100644 --- a/persistence_api/main.py +++ b/persistence_api/main.py @@ -12,6 +12,8 @@ from fastapi.responses import Response from pydantic import BaseModel +from persistence_api.email_delivery import EmailDeliveryError +from persistence_api.email_delivery import build_email_delivery from persistence_api.repository import BLOG_CATALOG_DEFAULT_PAGE_SIZE from persistence_api.repository import BLOG_LABELING_DEFAULT_PAGE_SIZE from persistence_api.age_graph import AgeGraphManager @@ -63,6 +65,23 @@ class UserAuthRequest(BaseModel): password: str +class EmailRequest(BaseModel): + email: str + + +class TokenRequest(BaseModel): + token: str + + +class PasswordResetRequest(BaseModel): + token: str + password: str + + +class UpdateUserRoleRequest(BaseModel): + role: str + + class BlogResultRequest(BaseModel): crawl_status: str status_code: int | None @@ -274,7 +293,12 @@ def build_persistence_state(settings: Settings | None = None) -> PersistenceStat resolved = settings or Settings.from_env() if resolved.db_dsn: run_postgres_migrations(resolved.db_dsn) - repository = build_repository(db_path=resolved.db_path, db_dsn=resolved.db_dsn, settings=resolved) + repository = build_repository( + db_path=resolved.db_path, + db_dsn=resolved.db_dsn, + settings=resolved, + email_delivery=build_email_delivery(resolved), + ) age_manager = AgeGraphManager( getattr(repository, "engine", None), enabled=resolved.age_enabled and resolved.age_shadow_reads, @@ -399,6 +423,7 @@ def register_user(payload: UserAuthRequest) -> dict[str, Any]: exception_translations=( (ValueError, 422, None), (UserAuthError, 409, None), + (EmailDeliveryError, 502, "email_delivery_failed"), ), ) @@ -423,6 +448,57 @@ def get_current_user(session_token: str) -> dict[str, Any]: def logout_user(session_token: str) -> dict[str, bool]: return {"ok": get_state().repository.revoke_user_session(token=session_token)} + @app.post("/internal/users/email-verification/request") + def request_email_verification(payload: EmailRequest) -> dict[str, Any]: + return _call_with_http_exception_translation( + lambda: get_state().repository.request_email_verification(email=payload.email), + exception_translations=( + (ValueError, 422, None), + (EmailDeliveryError, 502, "email_delivery_failed"), + ), + ) + + @app.post("/internal/users/email-verification/confirm") + def confirm_email_verification(payload: TokenRequest) -> dict[str, Any]: + return _call_with_http_exception_translation( + lambda: get_state().repository.confirm_email_verification(token=payload.token), + exception_translations=((UserAuthError, 401, None),), + ) + + @app.post("/internal/users/password-reset/request") + def request_password_reset(payload: EmailRequest) -> dict[str, Any]: + return _call_with_http_exception_translation( + lambda: get_state().repository.request_password_reset(email=payload.email), + exception_translations=( + (ValueError, 422, None), + (EmailDeliveryError, 502, "email_delivery_failed"), + ), + ) + + @app.post("/internal/users/password-reset/confirm") + def reset_user_password(payload: PasswordResetRequest) -> dict[str, Any]: + return _call_with_http_exception_translation( + lambda: get_state().repository.reset_user_password(token=payload.token, password=payload.password), + exception_translations=( + (ValueError, 422, None), + (UserAuthError, 401, None), + ), + ) + + @app.get("/internal/users") + def list_users(page: int = 1, page_size: int = 50) -> dict[str, Any]: + return get_state().repository.list_users(page=page, page_size=page_size) + + @app.patch("/internal/users/{user_id}/role") + def update_user_role(user_id: int, payload: UpdateUserRoleRequest) -> dict[str, Any]: + return _call_with_http_exception_translation( + lambda: get_state().repository.update_user_role(user_id=user_id, role=payload.role), + exception_translations=( + (ValueError, 422, None), + (UserAuthError, 404, None), + ), + ) + @app.get("/internal/users/{user_id}/label-selections") def list_user_label_selections(user_id: int, limit: int = 50) -> list[dict[str, Any]]: return get_state().repository.list_user_label_selections(user_id=user_id, limit=limit) diff --git a/persistence_api/models.py b/persistence_api/models.py index 42b3ef2..417c2e6 100644 --- a/persistence_api/models.py +++ b/persistence_api/models.py @@ -7,6 +7,7 @@ from sqlalchemy import DateTime from sqlalchemy import Enum from sqlalchemy import ForeignKey +from sqlalchemy import Boolean from sqlalchemy import Integer from sqlalchemy import JSON from sqlalchemy import Index @@ -111,6 +112,11 @@ class UserModel(Base): email: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True) password_hash: Mapped[str] = mapped_column(Text, nullable=False) display_name: Mapped[str] = mapped_column(Text, nullable=False, default="") + role: Mapped[str] = mapped_column(Text, nullable=False, default="user") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) @@ -136,6 +142,72 @@ class UserSessionModel(Base): revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) +class PendingUserRegistrationModel(Base): + """Unverified registration intent stored until email ownership is proven. + + Args: + None. SQLAlchemy constructs model instances from mapped keyword + arguments. + + Returns: + One pending email/password registration. A row is promoted into + ``users`` only after its verification token is consumed. + """ + + __tablename__ = "pending_user_registrations" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class UserVerificationTokenModel(Base): + """Single-use user lifecycle token stored as a hash. + + Args: + None. SQLAlchemy constructs model instances from mapped keyword + arguments. + + Returns: + Token row used for email verification and password reset flows. Raw + tokens are returned to callers once and are never stored. + """ + + __tablename__ = "user_verification_tokens" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True) + purpose: Mapped[str] = mapped_column(Text, nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class UserAuditEventModel(Base): + """Security-relevant account event for audit screens. + + Args: + None. SQLAlchemy constructs model instances from mapped keyword + arguments. + + Returns: + Minimal append-only audit event. Details must not contain raw secrets. + """ + + __tablename__ = "user_audit_events" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + event_type: Mapped[str] = mapped_column(Text, nullable=False, index=True) + details: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + class BlogLabelModel(Base): """Stable URL-keyed label vote counters. diff --git a/persistence_api/repository.py b/persistence_api/repository.py index b1fc9aa..b7c5405 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -36,6 +36,8 @@ from persistence_api.db import create_persistence_engine from persistence_api.db import create_session_factory from persistence_api.db import session_scope +from persistence_api.email_delivery import EmailDelivery +from persistence_api.email_delivery import NoopEmailDelivery from persistence_api.models import Base from persistence_api.models import BlogLabelModel from persistence_api.models import BlogLabelTagModel @@ -48,7 +50,10 @@ from persistence_api.models import RecommendationImpressionModel from persistence_api.models import RecommendationRequestModel from persistence_api.models import SeedModel +from persistence_api.models import PendingUserRegistrationModel +from persistence_api.models import UserAuditEventModel from persistence_api.models import UserModel +from persistence_api.models import UserVerificationTokenModel from persistence_api.models import UserSessionModel from persistence_api.recommendations import collect_friends_of_friends_candidates from crawler.crawling.decisions.chain import build_url_decision_chain @@ -106,6 +111,14 @@ PASSWORD_MIN_LENGTH = 8 USER_SESSION_TTL_DAYS = 30 PASSWORD_HASH_ITERATIONS = 210_000 +USER_ROLE_ADMIN = "admin" +USER_ROLE_USER = "user" +USER_ROLES = frozenset({USER_ROLE_ADMIN, USER_ROLE_USER}) +USER_TOKEN_EMAIL_VERIFICATION = "email_verification" +USER_TOKEN_PASSWORD_RESET = "password_reset" +USER_EMAIL_VERIFICATION_TTL_HOURS = 24 +USER_PASSWORD_RESET_TTL_HOURS = 2 +PENDING_REGISTRATION_TTL_HOURS = 24 class BlogLabelingError(Exception): @@ -236,6 +249,19 @@ def _hash_session_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() +def _hash_user_lifecycle_token(token: str) -> str: + """Return the storage hash for an email verification or reset token. + + Args: + token: Raw lifecycle token returned once to a caller. + + Returns: + SHA-256 hex digest used for lookup without storing the raw token. + """ + + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + def _user_payload(model: UserModel) -> dict[str, Any]: """Return the public user profile payload. @@ -250,11 +276,35 @@ def _user_payload(model: UserModel) -> dict[str, Any]: "id": int(model.id), "email": model.email, "display_name": model.display_name, + "role": model.role, + "is_active": bool(model.is_active), + "email_verified": model.email_verified_at is not None, + "email_verified_at": _iso(model.email_verified_at), "created_at": _iso(model.created_at), "updated_at": _iso(model.updated_at), } +def _user_admin_payload(model: UserModel) -> dict[str, Any]: + """Return an admin-safe user management payload. + + Args: + model: User database row. + + Returns: + JSON-serializable account summary for admin user lists. + """ + + payload = _user_payload(model) + payload.update( + { + "last_login_at": _iso(model.last_login_at), + "password_changed_at": _iso(model.password_changed_at), + } + ) + return payload + + def _sortable_datetime(value: datetime | None) -> datetime: if value is None: return datetime.min.replace(tzinfo=UTC) @@ -785,6 +835,17 @@ def ensure_legacy_compat_schema(engine: Any) -> None: "email TEXT NOT NULL UNIQUE, " "password_hash TEXT NOT NULL, " "display_name TEXT DEFAULT '' NOT NULL, " + "role TEXT DEFAULT 'user' NOT NULL, " + "is_active BOOLEAN DEFAULT 1 NOT NULL, " + "email_verified_at " + + ("TIMESTAMP WITH TIME ZONE" if connection.dialect.name == "postgresql" else "DATETIME") + + ", " + "password_changed_at " + + ("TIMESTAMP WITH TIME ZONE" if connection.dialect.name == "postgresql" else "DATETIME") + + ", " + "last_login_at " + + ("TIMESTAMP WITH TIME ZONE" if connection.dialect.name == "postgresql" else "DATETIME") + + ", " "created_at " + ("TIMESTAMP WITH TIME ZONE" if connection.dialect.name == "postgresql" else "DATETIME") + " DEFAULT CURRENT_TIMESTAMP NOT NULL, " @@ -795,6 +856,18 @@ def ensure_legacy_compat_schema(engine: Any) -> None: ) connection.execute(text("CREATE INDEX IF NOT EXISTS ix_users_email ON users (email)")) existing_tables.add("users") + user_columns = {column["name"] for column in inspector.get_columns("users")} + user_timestamp_type = "TIMESTAMP WITH TIME ZONE" if connection.dialect.name == "postgresql" else "DATETIME" + if "role" not in user_columns: + connection.execute(text("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user' NOT NULL")) + if "is_active" not in user_columns: + connection.execute(text("ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT 1 NOT NULL")) + if "email_verified_at" not in user_columns: + connection.execute(text(f"ALTER TABLE users ADD COLUMN email_verified_at {user_timestamp_type}")) + if "password_changed_at" not in user_columns: + connection.execute(text(f"ALTER TABLE users ADD COLUMN password_changed_at {user_timestamp_type}")) + if "last_login_at" not in user_columns: + connection.execute(text(f"ALTER TABLE users ADD COLUMN last_login_at {user_timestamp_type}")) if "user_sessions" not in existing_tables: connection.execute( text( @@ -816,6 +889,88 @@ def ensure_legacy_compat_schema(engine: Any) -> None: connection.execute(text("CREATE INDEX IF NOT EXISTS ix_user_sessions_user_id ON user_sessions (user_id)")) connection.execute(text("CREATE INDEX IF NOT EXISTS ix_user_sessions_token_hash ON user_sessions (token_hash)")) existing_tables.add("user_sessions") + if "user_verification_tokens" not in existing_tables: + connection.execute( + text( + "CREATE TABLE user_verification_tokens (" + "id INTEGER PRIMARY KEY, " + "user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, " + "token_hash TEXT NOT NULL UNIQUE, " + "purpose TEXT NOT NULL, " + "created_at " + + user_timestamp_type + + " DEFAULT CURRENT_TIMESTAMP NOT NULL, " + "expires_at " + + user_timestamp_type + + " NOT NULL, " + "consumed_at " + + user_timestamp_type + + ")" + ) + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_user_verification_tokens_user_id ON user_verification_tokens (user_id)") + ) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_user_verification_tokens_token_hash " + "ON user_verification_tokens (token_hash)" + ) + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_user_verification_tokens_purpose ON user_verification_tokens (purpose)") + ) + existing_tables.add("user_verification_tokens") + if "pending_user_registrations" not in existing_tables: + pending_id_type = "SERIAL PRIMARY KEY" if connection.dialect.name == "postgresql" else "INTEGER PRIMARY KEY" + connection.execute( + text( + "CREATE TABLE pending_user_registrations (" + f"id {pending_id_type}, " + "email TEXT NOT NULL UNIQUE, " + "password_hash TEXT NOT NULL, " + "token_hash TEXT NOT NULL UNIQUE, " + "created_at " + + user_timestamp_type + + " DEFAULT CURRENT_TIMESTAMP NOT NULL, " + "expires_at " + + user_timestamp_type + + " NOT NULL, " + "consumed_at " + + user_timestamp_type + + ")" + ) + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_pending_user_registrations_email ON pending_user_registrations (email)") + ) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_pending_user_registrations_token_hash " + "ON pending_user_registrations (token_hash)" + ) + ) + existing_tables.add("pending_user_registrations") + if "user_audit_events" not in existing_tables: + json_type = "JSONB" if connection.dialect.name == "postgresql" else "JSON" + json_default = "'{}'::jsonb" if connection.dialect.name == "postgresql" else "'{}'" + connection.execute( + text( + "CREATE TABLE user_audit_events (" + "id INTEGER PRIMARY KEY, " + "user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, " + "event_type TEXT NOT NULL, " + f"details {json_type} DEFAULT {json_default} NOT NULL, " + "created_at " + + user_timestamp_type + + " DEFAULT CURRENT_TIMESTAMP NOT NULL)" + ) + ) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_user_audit_events_user_id ON user_audit_events (user_id)")) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_user_audit_events_event_type ON user_audit_events (event_type)") + ) + existing_tables.add("user_audit_events") if "blog_user_label_selections" not in existing_tables: connection.execute( text( @@ -1603,6 +1758,18 @@ def get_user_by_session_token(self, *, token: str) -> dict[str, Any] | None: ... def revoke_user_session(self, *, token: str) -> bool: ... + def request_email_verification(self, *, email: str) -> dict[str, Any]: ... + + def confirm_email_verification(self, *, token: str) -> dict[str, Any]: ... + + def request_password_reset(self, *, email: str) -> dict[str, Any]: ... + + def reset_user_password(self, *, token: str, password: str) -> dict[str, Any]: ... + + def list_users(self, *, page: int = 1, page_size: int = 50) -> dict[str, Any]: ... + + def update_user_role(self, *, user_id: int, role: str) -> dict[str, Any]: ... + def list_user_label_selections(self, *, user_id: int, limit: int = 50) -> list[dict[str, Any]]: ... def count_user_label_selections(self, *, user_id: int) -> int: ... @@ -1780,10 +1947,16 @@ class SQLAlchemyRepository: database_url: str decision_settings: Settings | None = None startup_schema_sync: bool = True + public_base_url: str = "http://127.0.0.1:3000" + email_delivery: EmailDelivery = field(default_factory=NoopEmailDelivery) + email_dev_expose_tokens: bool = True engine: Any = field(init=False, repr=False) session_factory: Any = field(init=False, repr=False) def __post_init__(self) -> None: + if self.decision_settings is not None: + self.public_base_url = self.decision_settings.public_base_url + self.email_dev_expose_tokens = self.decision_settings.email_dev_expose_tokens self.engine = create_persistence_engine(self.database_url) self.session_factory = create_session_factory(self.engine) if self.startup_schema_sync: @@ -2749,6 +2922,7 @@ def _create_user_session_payload(self, session: Session, user: UserModel) -> dic revoked_at=None, ) session.add(session_row) + user.last_login_at = timestamp user.updated_at = timestamp session.flush() return { @@ -2757,19 +2931,201 @@ def _create_user_session_payload(self, session: Session, user: UserModel) -> dic "user": _user_payload(user), } + def _record_user_audit_event( + self, + session: Session, + *, + user_id: int | None, + event_type: str, + details: dict[str, Any] | None = None, + ) -> None: + """Append one user audit event without storing raw secrets. + + Args: + session: Active SQLAlchemy session. + user_id: Optional user ID attached to the event. + event_type: Stable event name. + details: Optional JSON-safe metadata. Raw tokens and passwords must + not be supplied. + + Returns: + None. + """ + + session.add( + UserAuditEventModel( + user_id=user_id, + event_type=event_type, + details=_coerce_json_object(details), + created_at=now_utc(), + ) + ) + + def _create_lifecycle_token( + self, + session: Session, + *, + user: UserModel, + purpose: str, + ttl_hours: int, + ) -> dict[str, Any]: + """Create one hashed lifecycle token and return its raw one-time value. + + Args: + session: Active SQLAlchemy session. + user: User row that owns the token. + purpose: Token purpose such as email verification or password reset. + ttl_hours: Token lifetime in hours. + + Returns: + JSON payload containing the raw token once and expiry metadata. + """ + + timestamp = now_utc() + token = token_urlsafe(32) + row = UserVerificationTokenModel( + user_id=int(user.id), + token_hash=_hash_user_lifecycle_token(token), + purpose=purpose, + created_at=timestamp, + expires_at=timestamp + timedelta(hours=ttl_hours), + consumed_at=None, + ) + session.add(row) + session.flush() + return { + "token": token, + "expires_at": _iso(row.expires_at), + } + + def _consume_lifecycle_token( + self, + session: Session, + *, + token: str, + purpose: str, + ) -> tuple[UserVerificationTokenModel, UserModel]: + """Consume one valid lifecycle token and return its row plus user. + + Args: + session: Active SQLAlchemy session. + token: Raw token supplied by the caller. + purpose: Required token purpose. + + Returns: + Tuple of token row and owning user row. + + Raises: + UserAuthError: Raised when the token is invalid, expired, consumed, + or points to a missing/inactive user. + """ + + clean_token = token.strip() + if not clean_token: + raise UserAuthError("invalid_token") + timestamp = now_utc() + row = session.scalar( + select(UserVerificationTokenModel).where( + UserVerificationTokenModel.token_hash == _hash_user_lifecycle_token(clean_token), + UserVerificationTokenModel.purpose == purpose, + UserVerificationTokenModel.consumed_at.is_(None), + UserVerificationTokenModel.expires_at > timestamp, + ).limit(1) + ) + if row is None: + raise UserAuthError("invalid_token") + user = session.scalar(select(UserModel).where(UserModel.id == row.user_id).limit(1)) + if user is None or not user.is_active: + raise UserAuthError("invalid_token") + row.consumed_at = timestamp + return row, user + + def _email_verification_payload(self, token_payload: dict[str, Any]) -> dict[str, Any]: + """Return an email verification response payload. + + Args: + token_payload: Raw lifecycle token payload returned by + `_create_lifecycle_token`. + + Returns: + Payload containing delivery status and expiry. Development mode also + includes the raw token and verification URL for local manual flows. + """ + + token = str(token_payload["token"]) + verification_url = f"{self.public_base_url}/profile?verify_token={token}" + payload = { + "sent": True, + "expires_at": token_payload["expires_at"], + } + if self.email_dev_expose_tokens: + payload["verification_token"] = token + payload["verification_url"] = verification_url + return payload + + def _verification_url(self, token_payload: dict[str, Any]) -> str: + """Build the public email verification URL for one raw token payload. + + Args: + token_payload: Raw lifecycle token payload returned by + `_create_lifecycle_token`. + + Returns: + Public frontend URL that consumes the one-time verification token. + """ + + return f"{self.public_base_url}/profile?verify_token={token_payload['token']}" + + def _password_reset_payload(self, token_payload: dict[str, Any]) -> dict[str, Any]: + """Return a password reset response payload. + + Args: + token_payload: Raw lifecycle token payload returned by + `_create_lifecycle_token`. + + Returns: + Payload containing delivery status and expiry. Development mode also + includes the raw token and reset URL for local manual flows. + """ + + token = str(token_payload["token"]) + reset_url = f"{self.public_base_url}/profile?reset_token={token}" + payload = { + "sent": True, + "expires_at": token_payload["expires_at"], + } + if self.email_dev_expose_tokens: + payload["reset_token"] = token + payload["reset_url"] = reset_url + return payload + + def _password_reset_url(self, token_payload: dict[str, Any]) -> str: + """Build the public password reset URL for one raw token payload. + + Args: + token_payload: Raw lifecycle token payload returned by + `_create_lifecycle_token`. + + Returns: + Public frontend URL that consumes the one-time reset token. + """ + + return f"{self.public_base_url}/profile?reset_token={token_payload['token']}" + def register_user(self, *, email: str, password: str) -> dict[str, Any]: - """Create a user account and first login session. + """Create a pending registration and send a verification email. Args: email: User email address used as the login identifier. - password: Plaintext password to hash and store. + password: Plaintext password to hash and hold until verification. Returns: - Auth payload with bearer token and user profile. + Email verification delivery payload. Raises: ValueError: Raised for invalid email or weak password. - UserAuthError: Raised when the email is already registered. + UserAuthError: Raised when the email is already registered or has a + still-valid pending registration. """ normalized_email = _normalize_user_email(email) @@ -2779,16 +3135,45 @@ def register_user(self, *, email: str, password: str) -> dict[str, Any]: existing = session.scalar(select(UserModel).where(UserModel.email == normalized_email).limit(1)) if existing is not None: raise UserAuthError("email_already_registered") - user = UserModel( + pending = session.scalar( + select(PendingUserRegistrationModel) + .where( + PendingUserRegistrationModel.email == normalized_email, + PendingUserRegistrationModel.consumed_at.is_(None), + PendingUserRegistrationModel.expires_at > timestamp, + ) + .limit(1) + ) + if pending is not None: + raise UserAuthError("email_registration_pending") + session.query(PendingUserRegistrationModel).filter( + PendingUserRegistrationModel.email == normalized_email + ).delete(synchronize_session=False) + token_payload = { + "token": token_urlsafe(32), + "expires_at": _iso(timestamp + timedelta(hours=PENDING_REGISTRATION_TTL_HOURS)), + } + pending = PendingUserRegistrationModel( email=normalized_email, password_hash=_hash_password(validated_password), - display_name=normalized_email.split("@", 1)[0], + token_hash=_hash_user_lifecycle_token(str(token_payload["token"])), created_at=timestamp, - updated_at=timestamp, + expires_at=timestamp + timedelta(hours=PENDING_REGISTRATION_TTL_HOURS), + consumed_at=None, + ) + session.add(pending) + self.email_delivery.send_verification_email( + to_email=normalized_email, + verification_url=self._verification_url(token_payload), + ) + self._record_user_audit_event( + session, + user_id=None, + event_type="user.registration_verification_sent", + details={"email": normalized_email}, ) - session.add(user) session.flush() - return self._create_user_session_payload(session, user) + return self._email_verification_payload(token_payload) def login_user(self, *, email: str, password: str) -> dict[str, Any]: """Authenticate an existing user and create a fresh session. @@ -2808,8 +3193,9 @@ def login_user(self, *, email: str, password: str) -> dict[str, Any]: normalized_email = _normalize_user_email(email) with session_scope(self.session_factory) as session: user = session.scalar(select(UserModel).where(UserModel.email == normalized_email).limit(1)) - if user is None or not _verify_password(password, user.password_hash): + if user is None or not user.is_active or not _verify_password(password, user.password_hash): raise UserAuthError("invalid_credentials") + self._record_user_audit_event(session, user_id=int(user.id), event_type="user.login") return self._create_user_session_payload(session, user) def _active_user_by_session_token(self, session: Session, *, token: str) -> UserModel | None: @@ -2837,7 +3223,8 @@ def _active_user_by_session_token(self, session: Session, *, token: str) -> User ) if row is None: return None - return session.scalar(select(UserModel).where(UserModel.id == row.user_id).limit(1)) + user = session.scalar(select(UserModel).where(UserModel.id == row.user_id, UserModel.is_active.is_(True)).limit(1)) + return user def get_user_by_session_token(self, *, token: str) -> dict[str, Any] | None: """Load the current user for one bearer token. @@ -2879,6 +3266,218 @@ def revoke_user_session(self, *, token: str) -> bool: session.flush() return True + def request_email_verification(self, *, email: str) -> dict[str, Any]: + """Create a fresh email verification token for one account. + + Args: + email: Account email address. + + Returns: + Dev-friendly verification payload. Unknown emails receive the same + neutral `sent` shape without token fields. + """ + + normalized_email = _normalize_user_email(email) + with session_scope(self.session_factory) as session: + user = session.scalar(select(UserModel).where(UserModel.email == normalized_email).limit(1)) + if user is None or not user.is_active: + return {"sent": True} + if user.email_verified_at is not None: + return {"sent": True, "already_verified": True} + token_payload = self._create_lifecycle_token( + session, + user=user, + purpose=USER_TOKEN_EMAIL_VERIFICATION, + ttl_hours=USER_EMAIL_VERIFICATION_TTL_HOURS, + ) + self.email_delivery.send_verification_email( + to_email=normalized_email, + verification_url=self._verification_url(token_payload), + ) + self._record_user_audit_event(session, user_id=int(user.id), event_type="user.email_verification_requested") + return self._email_verification_payload(token_payload) + + def confirm_email_verification(self, *, token: str) -> dict[str, Any]: + """Consume an email verification token and activate the account. + + Args: + token: Raw verification token supplied by the user. + + Returns: + Created or updated user profile payload. + """ + + clean_token = token.strip() + if not clean_token: + raise UserAuthError("invalid_token") + with session_scope(self.session_factory) as session: + timestamp = now_utc() + pending = session.scalar( + select(PendingUserRegistrationModel) + .where( + PendingUserRegistrationModel.token_hash == _hash_user_lifecycle_token(clean_token), + PendingUserRegistrationModel.consumed_at.is_(None), + PendingUserRegistrationModel.expires_at > timestamp, + ) + .limit(1) + ) + if pending is not None: + existing = session.scalar(select(UserModel).where(UserModel.email == pending.email).limit(1)) + if existing is not None: + pending.consumed_at = timestamp + raise UserAuthError("email_already_registered") + user = UserModel( + email=str(pending.email), + password_hash=str(pending.password_hash), + display_name=str(pending.email).split("@", 1)[0], + role=USER_ROLE_USER, + is_active=True, + email_verified_at=timestamp, + password_changed_at=None, + last_login_at=None, + created_at=timestamp, + updated_at=timestamp, + ) + session.add(user) + pending.consumed_at = timestamp + session.flush() + self._record_user_audit_event(session, user_id=int(user.id), event_type="user.registered") + self._record_user_audit_event(session, user_id=int(user.id), event_type="user.email_verified") + session.flush() + return _user_payload(user) + + _, user = self._consume_lifecycle_token( + session, + token=clean_token, + purpose=USER_TOKEN_EMAIL_VERIFICATION, + ) + user.email_verified_at = timestamp + user.updated_at = timestamp + self._record_user_audit_event(session, user_id=int(user.id), event_type="user.email_verified") + session.flush() + return _user_payload(user) + + def request_password_reset(self, *, email: str) -> dict[str, Any]: + """Create a fresh password reset token for one account. + + Args: + email: Account email address. + + Returns: + Neutral reset payload. Known active users include a dev token so + local tests and manual flows can complete without SMTP. + """ + + normalized_email = _normalize_user_email(email) + with session_scope(self.session_factory) as session: + user = session.scalar(select(UserModel).where(UserModel.email == normalized_email).limit(1)) + if user is None or not user.is_active: + return {"sent": True} + token_payload = self._create_lifecycle_token( + session, + user=user, + purpose=USER_TOKEN_PASSWORD_RESET, + ttl_hours=USER_PASSWORD_RESET_TTL_HOURS, + ) + self.email_delivery.send_password_reset_email( + to_email=normalized_email, + reset_url=self._password_reset_url(token_payload), + ) + self._record_user_audit_event(session, user_id=int(user.id), event_type="user.password_reset_requested") + return self._password_reset_payload(token_payload) + + def reset_user_password(self, *, token: str, password: str) -> dict[str, Any]: + """Consume a reset token, update password, and revoke active sessions. + + Args: + token: Raw password reset token supplied by the user. + password: New plaintext password. + + Returns: + Updated user profile payload. + """ + + validated_password = _validate_password(password) + with session_scope(self.session_factory) as session: + _, user = self._consume_lifecycle_token( + session, + token=token, + purpose=USER_TOKEN_PASSWORD_RESET, + ) + timestamp = now_utc() + user.password_hash = _hash_password(validated_password) + user.password_changed_at = timestamp + user.updated_at = timestamp + session.query(UserSessionModel).filter( + UserSessionModel.user_id == int(user.id), + UserSessionModel.revoked_at.is_(None), + ).update({UserSessionModel.revoked_at: timestamp}) + self._record_user_audit_event(session, user_id=int(user.id), event_type="user.password_reset_completed") + session.flush() + return _user_payload(user) + + def list_users(self, *, page: int = 1, page_size: int = 50) -> dict[str, Any]: + """List registered users for the admin user table. + + Args: + page: One-based page number. + page_size: Number of users per page. + + Returns: + Paginated user management payload. + """ + + safe_page = max(1, page) + safe_page_size = min(max(1, page_size), 200) + offset = (safe_page - 1) * safe_page_size + with session_scope(self.session_factory) as session: + total_items = int(session.scalar(select(func.count(UserModel.id))) or 0) + users = session.scalars( + select(UserModel) + .order_by(UserModel.id.asc()) + .limit(safe_page_size) + .offset(offset) + ).all() + total_pages = max(1, ceil(total_items / safe_page_size)) if total_items else 1 + return { + "items": [_user_admin_payload(user) for user in users], + "page": safe_page, + "page_size": safe_page_size, + "total_items": total_items, + "total_pages": total_pages, + "has_next": safe_page < total_pages, + "has_prev": safe_page > 1, + } + + def update_user_role(self, *, user_id: int, role: str) -> dict[str, Any]: + """Update one user's role in the simplified admin/user role model. + + Args: + user_id: Target user ID. + role: New role. Supported values are `admin` and `user`. + + Returns: + Updated admin user payload. + """ + + clean_role = role.strip().lower() + if clean_role not in USER_ROLES: + raise ValueError("invalid_user_role") + with session_scope(self.session_factory) as session: + user = session.scalar(select(UserModel).where(UserModel.id == user_id).limit(1)) + if user is None: + raise UserAuthError("user_not_found") + user.role = clean_role + user.updated_at = now_utc() + self._record_user_audit_event( + session, + user_id=int(user.id), + event_type="user.role_updated", + details={"role": clean_role}, + ) + session.flush() + return _user_admin_payload(user) + def lookup_blog_candidates(self, *, url: str) -> dict[str, Any]: normalized = normalize_url(url) identity = resolve_blog_identity(url) @@ -4700,10 +5299,17 @@ def reset(self) -> dict[str, Any]: class Repository(SQLAlchemyRepository): """Compatibility wrapper for test call sites that still pass a db path.""" - def __init__(self, db_path: Path, *, decision_settings: Settings | None = None) -> None: + def __init__( + self, + db_path: Path, + *, + decision_settings: Settings | None = None, + email_delivery: EmailDelivery | None = None, + ) -> None: super().__init__( f"sqlite+pysqlite:///{db_path}", decision_settings=decision_settings, + email_delivery=email_delivery or NoopEmailDelivery(), startup_schema_sync=True, ) @@ -4713,12 +5319,19 @@ def build_repository( db_path: Path, db_dsn: str | None = None, settings: Settings | None = None, + email_delivery: EmailDelivery | None = None, ) -> RepositoryProtocol: """Build the configured repository implementation.""" if db_dsn is not None: try: - return SQLAlchemyRepository(db_dsn, decision_settings=settings, startup_schema_sync=True) + kwargs: dict[str, Any] = { + "decision_settings": settings, + "startup_schema_sync": True, + } + if email_delivery is not None: + kwargs["email_delivery"] = email_delivery + return SQLAlchemyRepository(db_dsn, **kwargs) except ModuleNotFoundError as exc: if exc.name != "psycopg": raise - return Repository(db_path, decision_settings=settings) + return Repository(db_path, decision_settings=settings, email_delivery=email_delivery) diff --git a/shared/config.py b/shared/config.py index 00502e0..de38abd 100644 --- a/shared/config.py +++ b/shared/config.py @@ -113,6 +113,17 @@ class Settings: friend_link_prefix_blocklist: tuple[str, ...] = () admin_token: str | None = None admin_dev_bypass: bool = False + public_base_url: str = "http://127.0.0.1:3000" + email_provider: str = "disabled" + email_from: str = "" + email_dev_expose_tokens: bool = True + smtp_host: str = "" + smtp_port: int = 587 + smtp_username: str | None = None + smtp_password: str | None = None + smtp_use_tls: bool = True + smtp_use_ssl: bool = False + smtp_timeout_seconds: float = 10.0 decision_model_root: Path = DEFAULT_DECISION_MODEL_ROOT filter_chain_config_path: Path = DEFAULT_FILTER_CHAIN_CONFIG_PATH rss_discovery_enabled: bool = True @@ -221,6 +232,17 @@ def from_env(cls) -> "Settings": friend_link_prefix_blocklist=_parse_csv_env("HEYBLOG_FRIEND_LINK_PREFIX_BLOCKLIST"), admin_token=os.getenv("HEYBLOG_ADMIN_TOKEN"), admin_dev_bypass=_parse_bool_env("HEYBLOG_ADMIN_DEV_BYPASS"), + public_base_url=os.getenv("HEYBLOG_PUBLIC_BASE_URL", "http://127.0.0.1:3000").rstrip("/"), + email_provider=os.getenv("HEYBLOG_EMAIL_PROVIDER", "disabled").strip().lower() or "disabled", + email_from=os.getenv("HEYBLOG_EMAIL_FROM", "").strip(), + email_dev_expose_tokens=_parse_bool_env("HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS", default=True), + smtp_host=os.getenv("HEYBLOG_SMTP_HOST", "").strip(), + smtp_port=max(1, int(os.getenv("HEYBLOG_SMTP_PORT", "587"))), + smtp_username=os.getenv("HEYBLOG_SMTP_USERNAME") or None, + smtp_password=os.getenv("HEYBLOG_SMTP_PASSWORD") or None, + smtp_use_tls=_parse_bool_env("HEYBLOG_SMTP_USE_TLS", default=True), + smtp_use_ssl=_parse_bool_env("HEYBLOG_SMTP_USE_SSL"), + smtp_timeout_seconds=max(0.001, float(os.getenv("HEYBLOG_SMTP_TIMEOUT_SECONDS", "10.0"))), decision_model_root=Path( os.getenv("HEYBLOG_DECISION_MODEL_ROOT", str(DEFAULT_DECISION_MODEL_ROOT)) ), diff --git a/shared/http_clients/persistence_http.py b/shared/http_clients/persistence_http.py index 2d95764..927ef10 100644 --- a/shared/http_clients/persistence_http.py +++ b/shared/http_clients/persistence_http.py @@ -69,6 +69,11 @@ def _put(self, path: str, payload: dict[str, Any]) -> Any: response.raise_for_status() return response.json() + def _patch(self, path: str, payload: dict[str, Any]) -> Any: + response = self.client.patch(path, json=payload, **context_header_kwargs()) + response.raise_for_status() + return response.json() + def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: response = self.client.get(path, params=params, **context_header_kwargs()) response.raise_for_status() @@ -377,6 +382,36 @@ def revoke_user_session(self, *, token: str) -> dict[str, Any]: return self._post(f"/internal/users/logout?session_token={token}", {}) + def request_email_verification(self, *, email: str) -> dict[str, Any]: + """Create a fresh email verification token for one account.""" + + return self._post("/internal/users/email-verification/request", {"email": email}) + + def confirm_email_verification(self, *, token: str) -> dict[str, Any]: + """Confirm a user email verification token.""" + + return self._post("/internal/users/email-verification/confirm", {"token": token}) + + def request_password_reset(self, *, email: str) -> dict[str, Any]: + """Create a fresh password reset token for one account.""" + + return self._post("/internal/users/password-reset/request", {"email": email}) + + def reset_user_password(self, *, token: str, password: str) -> dict[str, Any]: + """Confirm a password reset token and set a new password.""" + + return self._post("/internal/users/password-reset/confirm", {"token": token, "password": password}) + + def list_users(self, *, page: int = 1, page_size: int = 50) -> dict[str, Any]: + """Fetch a paginated admin user list.""" + + return self._get("/internal/users", {"page": page, "page_size": page_size}) + + def update_user_role(self, *, user_id: int, role: str) -> dict[str, Any]: + """Update one user's role.""" + + return self._patch(f"/internal/users/{user_id}/role", {"role": role}) + def list_user_label_selections(self, *, user_id: int, limit: int = 50) -> list[dict[str, Any]]: """Fetch recent random-page selections for one user.""" diff --git a/tests/test_repository.py b/tests/test_repository.py index 62bb7fe..9794ebc 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -18,6 +18,7 @@ from persistence_api.models import BlogLabelTagModel from persistence_api.models import BlogInteractionModel from persistence_api.models import BlogModel +from persistence_api.models import PendingUserRegistrationModel from persistence_api.models import RawDiscoveredUrlModel from persistence_api.models import RecommendationImpressionModel from persistence_api.models import RecommendationRequestModel @@ -26,6 +27,59 @@ from shared.config import Settings +class CapturingEmailDelivery: + """Test email sender that records lifecycle messages. + + Attributes: + verification_urls: Verification messages captured as `(email, url)`. + reset_urls: Password reset messages captured as `(email, url)`. + """ + + def __init__(self) -> None: + self.verification_urls: list[tuple[str, str]] = [] + self.reset_urls: list[tuple[str, str]] = [] + + def send_verification_email(self, *, to_email: str, verification_url: str) -> None: + """Capture one verification email. + + Args: + to_email: Recipient email address. + verification_url: One-time verification URL. + + Returns: + None. + """ + + self.verification_urls.append((to_email, verification_url)) + + def send_password_reset_email(self, *, to_email: str, reset_url: str) -> None: + """Capture one password reset email. + + Args: + to_email: Recipient email address. + reset_url: One-time password reset URL. + + Returns: + None. + """ + + self.reset_urls.append((to_email, reset_url)) + + +def register_and_verify_user( + repository: repository_module.SQLAlchemyRepository, + *, + email: str, + password: str, +) -> dict[str, object]: + """Create a user through the verify-before-persist registration flow.""" + + pending = repository.register_user(email=email, password=password) + token = pending.get("verification_token") + assert isinstance(token, str) + return repository.confirm_email_verification(token=token) + + def test_build_repository_roundtrip_works_with_path_backed_repository(tmp_path: Path) -> None: """The compatibility wrapper should still support path-backed test repositories.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") @@ -120,7 +174,8 @@ def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) - result="ok", message="This should not be persisted", ) - user = repository.register_user(email="reset-user@example.com", password="long enough") + verified_user = register_and_verify_user(repository, email="reset-user@example.com", password="long enough") + user = repository.login_user(email=str(verified_user["email"]), password="long enough") batch = repository.create_random_recommendation_batch( count=1, visitor_id="visitor-reset", @@ -191,32 +246,124 @@ def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) - def test_repository_register_login_and_session_profile(tmp_path: Path) -> None: - """Users can register, log in, and resolve their bearer session profile.""" + """Users persist only after email verification, then can log in.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - created = repository.register_user(email="User@Example.com", password="correct horse") - assert created["user"]["email"] == "user@example.com" - assert created["token"] - resolved_user = repository.get_user_by_session_token(token=created["token"]) - assert resolved_user is not None - assert resolved_user["id"] == created["user"]["id"] - assert resolved_user["email"] == created["user"]["email"] + pending = repository.register_user(email="User@Example.com", password="correct horse") + assert pending["sent"] is True + assert pending["verification_token"] + with session_scope(repository.session_factory) as session: + assert session.scalar(select(PendingUserRegistrationModel).where(PendingUserRegistrationModel.email == "user@example.com")) is not None + assert session.scalar(select(repository_module.UserModel).where(repository_module.UserModel.email == "user@example.com")) is None + with pytest.raises(repository_module.UserAuthError, match="invalid_credentials"): + repository.login_user(email="user@example.com", password="correct horse") + + created = repository.confirm_email_verification(token=str(pending["verification_token"])) + assert created["email"] == "user@example.com" + assert created["role"] == "user" + assert created["email_verified"] is True logged_in = repository.login_user(email="user@example.com", password="correct horse") - assert logged_in["user"]["id"] == created["user"]["id"] - assert logged_in["token"] != created["token"] + assert logged_in["user"]["id"] == created["id"] + assert logged_in["token"] + + assert repository.revoke_user_session(token=logged_in["token"]) is True + assert repository.get_user_by_session_token(token=logged_in["token"]) is None + + +def test_repository_email_verification_and_password_reset_flow(tmp_path: Path) -> None: + """Email verification and password reset tokens should be single-use.""" + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + + created = repository.register_user(email="verify@example.com", password="correct horse") + verification_token = created["verification_token"] + verified = repository.confirm_email_verification(token=verification_token) + assert verified["email_verified"] is True - assert repository.revoke_user_session(token=created["token"]) is True - assert repository.get_user_by_session_token(token=created["token"]) is None + with pytest.raises(repository_module.UserAuthError, match="invalid_token"): + repository.confirm_email_verification(token=verification_token) + + login = repository.login_user(email="verify@example.com", password="correct horse") + reset_request = repository.request_password_reset(email="verify@example.com") + reset_token = reset_request["reset_token"] + reset_user = repository.reset_user_password(token=reset_token, password="new correct horse") + assert reset_user["email"] == "verify@example.com" + assert repository.get_user_by_session_token(token=login["token"]) is None + + with pytest.raises(repository_module.UserAuthError, match="invalid_credentials"): + repository.login_user(email="verify@example.com", password="correct horse") + assert repository.login_user(email="verify@example.com", password="new correct horse")["token"] + + +def test_repository_sends_lifecycle_email_and_hides_tokens_when_configured(tmp_path: Path) -> None: + """Production email mode should send links without exposing raw tokens.""" + email_delivery = CapturingEmailDelivery() + settings = Settings( + db_path=tmp_path / "db.sqlite", + seed_path=tmp_path / "seed.csv", + export_dir=tmp_path / "exports", + public_base_url="https://heyblog.example.com", + email_dev_expose_tokens=False, + ) + repository = repository_module.build_repository( + db_path=tmp_path / "db.sqlite", + settings=settings, + email_delivery=email_delivery, + ) + + verification_payload = repository.register_user(email="Mail@Example.com", password="correct horse") + assert verification_payload == { + "sent": True, + "expires_at": verification_payload["expires_at"], + } + assert len(email_delivery.verification_urls) == 1 + sent_email, verification_url = email_delivery.verification_urls[0] + assert sent_email == "mail@example.com" + assert verification_url.startswith("https://heyblog.example.com/profile?verify_token=") + verification_token = verification_url.rsplit("=", 1)[1] + assert repository.confirm_email_verification(token=verification_token)["email_verified"] is True + + reset_payload = repository.request_password_reset(email="mail@example.com") + assert reset_payload == { + "sent": True, + "expires_at": reset_payload["expires_at"], + } + assert len(email_delivery.reset_urls) == 1 + reset_email, reset_url = email_delivery.reset_urls[0] + assert reset_email == "mail@example.com" + assert reset_url.startswith("https://heyblog.example.com/profile?reset_token=") + reset_token = reset_url.rsplit("=", 1)[1] + assert repository.reset_user_password(token=reset_token, password="new correct horse")["email"] == "mail@example.com" + + +def test_repository_admin_role_updates_user_identity(tmp_path: Path) -> None: + """Users should be promotable between regular user and admin roles.""" + repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + + created = register_and_verify_user(repository, email="admin@example.com", password="correct horse") + user_id = int(created["id"]) + promoted = repository.update_user_role(user_id=user_id, role="admin") + assert promoted["role"] == "admin" + listed = repository.list_users() + assert listed["items"][0]["role"] == "admin" + + demoted = repository.update_user_role(user_id=user_id, role="user") + assert demoted["role"] == "user" + with pytest.raises(ValueError, match="invalid_user_role"): + repository.update_user_role(user_id=user_id, role="label_admin") def test_repository_rejects_duplicate_user_and_bad_credentials(tmp_path: Path) -> None: """Email uniqueness and password validation should produce stable errors.""" repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") - repository.register_user(email="dupe@example.com", password="long enough") + register_and_verify_user(repository, email="dupe@example.com", password="long enough") with pytest.raises(repository_module.UserAuthError, match="email_already_registered"): repository.register_user(email="DUPE@example.com", password="long enough") + + repository.register_user(email="pending@example.com", password="long enough") + with pytest.raises(repository_module.UserAuthError, match="email_registration_pending"): + repository.register_user(email="PENDING@example.com", password="long enough") with pytest.raises(repository_module.UserAuthError, match="invalid_credentials"): repository.login_user(email="dupe@example.com", password="wrong password") with pytest.raises(ValueError, match="password_too_short"): @@ -1029,8 +1176,8 @@ def test_repository_random_catalog_filters_admin_non_blog_and_saves_user_labels( user_label = repository.increment_blog_user_label(blog_id=kept_id, label="blog") duplicate_blog = repository.increment_blog_user_label(blog_id=kept_id, label="blog", previous_label="blog") user_non_blog = repository.increment_blog_user_label(blog_id=kept_id, label="other", previous_label="blog") - account = repository.register_user(email="labeler@example.com", password="long enough") - user_id = int(account["user"]["id"]) + account = register_and_verify_user(repository, email="labeler@example.com", password="long enough") + user_id = int(account["id"]) account_blog = repository.increment_blog_user_label(blog_id=kept_id, label="blog", user_id=user_id) account_other = repository.increment_blog_user_label(blog_id=kept_id, label="other", user_id=user_id) diff --git a/tests/test_service_split.py b/tests/test_service_split.py index 0ee3431..fb825d4 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -9,6 +9,7 @@ from backend.main import BackendState from backend.main import create_app as create_backend_app +from persistence_api.email_delivery import EmailDeliveryError from frontend.server import create_app as create_frontend_app from persistence_api.main import PersistenceState from persistence_api.main import build_persistence_state @@ -221,15 +222,25 @@ def test_persistence_service_exposes_supported_repository_data(tmp_path: Path) - json={"email": "Member@Example.com", "password": "long enough"}, ) assert auth.status_code == 200 - assert auth.json()["user"]["email"] == "member@example.com" - token = auth.json()["token"] - assert client.get("/internal/users/me", params={"session_token": token}).json()["id"] == auth.json()["user"]["id"] + assert auth.json()["sent"] is True + assert client.post( + "/internal/users/login", + json={"email": "member@example.com", "password": "long enough"}, + ).status_code == 401 + verified_auth = client.post( + "/internal/users/email-verification/confirm", + json={"token": auth.json()["verification_token"]}, + ) + assert verified_auth.status_code == 200 + assert verified_auth.json()["email"] == "member@example.com" login = client.post( "/internal/users/login", json={"email": "member@example.com", "password": "long enough"}, ) assert login.status_code == 200 - assert login.json()["user"]["id"] == auth.json()["user"]["id"] + assert login.json()["user"]["id"] == verified_auth.json()["id"] + token = login.json()["token"] + assert client.get("/internal/users/me", params={"session_token": token}).json()["id"] == verified_auth.json()["id"] lookup = client.get("/internal/blogs/lookup", params={"url": "https://blog.example.com/"}) assert lookup.status_code == 200 @@ -252,6 +263,56 @@ def test_persistence_service_exposes_supported_repository_data(tmp_path: Path) - assert empty_catalog.json()["items"] == [] +def test_persistence_user_registration_translates_email_delivery_failure(tmp_path: Path) -> None: + """SMTP failures should return a stable API error instead of leaking provider details.""" + + class FailingEmailDelivery: + """Email sender that always fails during lifecycle delivery.""" + + def send_verification_email(self, *, to_email: str, verification_url: str) -> None: + """Raise a delivery error for one verification message. + + Args: + to_email: Recipient email address. + verification_url: One-time verification URL. + + Returns: + None. + """ + + del to_email, verification_url + raise EmailDeliveryError("email_delivery_failed") + + def send_password_reset_email(self, *, to_email: str, reset_url: str) -> None: + """Raise a delivery error for one password reset message. + + Args: + to_email: Recipient email address. + reset_url: One-time password reset URL. + + Returns: + None. + """ + + del to_email, reset_url + raise EmailDeliveryError("email_delivery_failed") + + settings = Settings( + db_path=tmp_path / "heyblog.sqlite", + seed_path=tmp_path / "seed.csv", + export_dir=tmp_path / "exports", + ) + state = build_persistence_state(settings) + state.repository.email_delivery = FailingEmailDelivery() + app = create_persistence_app(state) + client = TestClient(app) + + response = client.post("/internal/users/register", json={"email": "user@example.com", "password": "long enough"}) + + assert response.status_code == 502 + assert response.json()["detail"] == "email_delivery_failed" + + def test_persistence_service_removes_legacy_read_shortcuts(tmp_path: Path) -> None: """Persistence service should not expose obsolete raw-read shortcut endpoints.""" settings = Settings( @@ -642,18 +703,23 @@ def test_persistence_service_exposes_blog_labeling_endpoints(tmp_path: Path) -> assert switched_user_label.json()["label_slugs"] == ["other"] account = client.post("/internal/users/register", json={"email": "voter@example.com", "password": "long enough"}) assert account.status_code == 200 + verified_account = client.post( + "/internal/users/email-verification/confirm", + json={"token": account.json()["verification_token"]}, + ) + assert verified_account.status_code == 200 account_user_label = client.post( f"/internal/blogs/{finished.json()['id']}/user-labels", - json={"label": "blog", "user_id": account.json()["user"]["id"]}, + json={"label": "blog", "user_id": verified_account.json()["id"]}, ) assert account_user_label.status_code == 200 account_user_label_switch = client.post( f"/internal/blogs/{finished.json()['id']}/user-labels", - json={"label": "other", "user_id": account.json()["user"]["id"]}, + json={"label": "other", "user_id": verified_account.json()["id"]}, ) assert account_user_label_switch.status_code == 200 selections = client.get( - f"/internal/users/{account.json()['user']['id']}/label-selections", + f"/internal/users/{verified_account.json()['id']}/label-selections", params={"limit": 5}, ) assert selections.status_code == 200 @@ -826,13 +892,15 @@ def get(self, path: str, params: dict[str, object] | None = None, **kwargs: obje def post(self, path: str, json: dict[str, object], **kwargs: object) -> StubResponse: del kwargs self.post_calls.append((path, json)) + if path == "/internal/users/register": + return StubResponse({"sent": True, "verification_token": "verify-token"}) return StubResponse({"token": "token", "user": {"id": 7, "email": "user@example.com"}}) client = PersistenceHttpClient("http://persistence.internal") stub = StubClient() client.client = stub # type: ignore[assignment] - assert client.register_user(email="user@example.com", password="long enough")["token"] == "token" + assert client.register_user(email="user@example.com", password="long enough")["sent"] is True assert client.login_user(email="user@example.com", password="long enough")["token"] == "token" assert client.get_user_by_session_token(token="token")["id"] == 7 assert client.list_user_label_selections(user_id=7) == [] @@ -1038,6 +1106,33 @@ def test_settings_loads_candidate_link_page_limit(monkeypatch) -> None: assert settings.max_candidate_links_per_page == 17 +def test_settings_loads_smtp_email_delivery_configuration(monkeypatch) -> None: + """Environment loading should expose SMTP lifecycle email settings.""" + monkeypatch.setenv("HEYBLOG_EMAIL_PROVIDER", "smtp") + monkeypatch.setenv("HEYBLOG_EMAIL_FROM", "no-reply@heyblog.example") + monkeypatch.setenv("HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS", "false") + monkeypatch.setenv("HEYBLOG_SMTP_HOST", "smtp.heyblog.example") + monkeypatch.setenv("HEYBLOG_SMTP_PORT", "465") + monkeypatch.setenv("HEYBLOG_SMTP_USERNAME", "smtp-user") + monkeypatch.setenv("HEYBLOG_SMTP_PASSWORD", "smtp-password") + monkeypatch.setenv("HEYBLOG_SMTP_USE_TLS", "false") + monkeypatch.setenv("HEYBLOG_SMTP_USE_SSL", "true") + monkeypatch.setenv("HEYBLOG_SMTP_TIMEOUT_SECONDS", "3.5") + + settings = Settings.from_env() + + assert settings.email_provider == "smtp" + assert settings.email_from == "no-reply@heyblog.example" + assert settings.email_dev_expose_tokens is False + assert settings.smtp_host == "smtp.heyblog.example" + assert settings.smtp_port == 465 + assert settings.smtp_username == "smtp-user" + assert settings.smtp_password == "smtp-password" + assert settings.smtp_use_tls is False + assert settings.smtp_use_ssl is True + assert settings.smtp_timeout_seconds == 3.5 + + def test_settings_default_runtime_model_root_uses_runtime_resources(monkeypatch) -> None: """Environment loading should default runtime model reads to published resources.""" monkeypatch.delenv("HEYBLOG_DECISION_MODEL_ROOT", raising=False) @@ -1265,15 +1360,9 @@ def test_backend_service_preserves_supported_public_api_shape(monkeypatch) -> No "is_labeled": bool(tag_ids or label_id), }, "register_user": lambda self, email, password: { - "token": "user-token", - "expires_at": "2026-06-25T00:00:00Z", - "user": { - "id": 42, - "email": email.lower(), - "display_name": email.split("@", 1)[0], - "created_at": "2026-05-26T00:00:00Z", - "updated_at": "2026-05-26T00:00:00Z", - }, + "sent": True, + "verification_token": "verify-token", + "expires_at": "2026-06-12T00:00:00Z", }, "login_user": lambda self, email, password: { "token": "login-token", @@ -1603,8 +1692,8 @@ def test_backend_service_preserves_supported_public_api_shape(monkeypatch) -> No auth = client.post("/api/auth/register", json={"email": "Member@Example.com", "password": "long enough"}) assert auth.status_code == 200 - assert auth.json()["token"] == "user-token" - assert auth.json()["user"]["email"] == "member@example.com" + assert auth.json()["sent"] is True + assert auth.json()["verification_token"] == "verify-token" login = client.post("/api/auth/login", json={"email": "member@example.com", "password": "long enough"}) assert login.status_code == 200 assert login.json()["token"] == "login-token" @@ -2219,6 +2308,59 @@ def test_backend_admin_routes_require_valid_token() -> None: assert invalid.json()["detail"] == "admin_auth_invalid" +def test_backend_admin_routes_require_verified_admin_session_role() -> None: + """Admin APIs should reject non-admin sessions even when called directly.""" + + class PersistenceStub: + def stats(self) -> dict[str, object]: + return {} + + def get_user_by_session_token(self, *, token: str) -> dict[str, object] | None: + users = { + "plain-user-token": { + "id": 1, + "role": "user", + "is_active": True, + "email_verified": True, + }, + "unverified-admin-token": { + "id": 2, + "role": "admin", + "is_active": True, + "email_verified": False, + }, + "admin-session-token": { + "id": 3, + "role": "admin", + "is_active": True, + "email_verified": True, + }, + } + return users.get(token) + + app = create_backend_app( + BackendState( + persistence=PersistenceStub(), + crawler=StubCrawler(), + search=StubSearch(), + admin_token="secret-token", + ) + ) + client = TestClient(app) + + user_response = client.get("/api/admin/runtime/status", headers=admin_headers("plain-user-token")) + assert user_response.status_code == 403 + assert user_response.json()["detail"] == "admin_auth_forbidden" + + unverified_response = client.get("/api/admin/runtime/status", headers=admin_headers("unverified-admin-token")) + assert unverified_response.status_code == 403 + assert unverified_response.json()["detail"] == "admin_auth_forbidden" + + admin_response = client.get("/api/admin/runtime/status", headers=admin_headers("admin-session-token")) + assert admin_response.status_code == 200 + assert admin_response.json()["runner_status"] == "idle" + + def test_backend_admin_routes_fail_when_auth_not_configured() -> None: app = create_backend_app( BackendState( From effbe8311e8b37a354eb079570175e68b648f053 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Thu, 11 Jun 2026 13:54:43 +0100 Subject: [PATCH 32/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260611_02_add_admin_hourly_stats.py | 75 ++++++++ backend/main.py | 9 + doc/api-docs.md | 57 ++++++ doc/public-admin-boundary.md | 1 + frontend/src/App.test.tsx | 119 ++++++++++++ frontend/src/lib/api.ts | 75 ++++++++ frontend/src/pages/AdminPage.tsx | 119 +++++++++++- frontend/src/types/graph.ts | 21 +++ persistence_api/main.py | 7 + persistence_api/models.py | 29 +++ persistence_api/repository.py | 171 ++++++++++++++++++ shared/http_clients/persistence_http.py | 12 ++ tests/test_repository.py | 32 +++- tests/test_service_split.py | 7 + 14 files changed, 730 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/20260611_02_add_admin_hourly_stats.py diff --git a/alembic/versions/20260611_02_add_admin_hourly_stats.py b/alembic/versions/20260611_02_add_admin_hourly_stats.py new file mode 100644 index 0000000..b96bcd4 --- /dev/null +++ b/alembic/versions/20260611_02_add_admin_hourly_stats.py @@ -0,0 +1,75 @@ +"""Add hourly admin statistics snapshots. + +Revision ID: 20260611_02 +Revises: 20260611_01 +Create Date: 2026-06-11 00:00:00 BST +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260611_02" +down_revision = "20260611_01" +branch_labels = None +depends_on = None + + +def _tables() -> set[str]: + """Return currently present database table names. + + Args: + None. + + Returns: + Set of table names currently present in the migration target. + """ + + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def upgrade() -> None: + """Create the hourly admin statistics snapshot table. + + Args: + None. + + Returns: + None. The migration mutates the active database schema in place. + """ + + if "admin_hourly_stats" in _tables(): + return + op.create_table( + "admin_hourly_stats", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("hour_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("user_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("random_request_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("random_impression_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("detail_open_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("external_open_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("detail_ctr", sa.Float(), nullable=False, server_default="0"), + sa.Column("external_ctr", sa.Float(), nullable=False, server_default="0"), + sa.Column("total_click_ctr", sa.Float(), nullable=False, server_default="0"), + sa.Column("refreshed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("hour_start", name="uq_admin_hourly_stats_hour_start"), + ) + op.create_index("ix_admin_hourly_stats_hour_start", "admin_hourly_stats", ["hour_start"]) + + +def downgrade() -> None: + """Drop the hourly admin statistics snapshot table. + + Args: + None. + + Returns: + None. The migration mutates the active database schema in place. + """ + + if "admin_hourly_stats" in _tables(): + op.drop_table("admin_hourly_stats") diff --git a/backend/main.py b/backend/main.py index 618642c..f1d5ca8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -599,6 +599,15 @@ def get_admin_recommendation_stats(_: None = Depends(require_admin_access)) -> d lambda: get_state().persistence.get_recommendation_strategy_stats() ) + @app.get("/api/admin/hourly-stats") + def get_admin_hourly_stats( + limit: int = 24, + _: None = Depends(require_admin_access), + ) -> dict[str, Any]: + return _call_upstream_with_http_error_translation( + lambda: get_state().persistence.get_admin_hourly_stats(limit=limit) + ) + @app.get("/api/icons/proxy") def proxy_icon(url: str) -> Response: """Return one remote icon through the backend origin for graph textures. diff --git a/doc/api-docs.md b/doc/api-docs.md index 9fe25e1..1a6b0f7 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -126,6 +126,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 - `POST /api/admin/blog-labeling/title-preview` - `PUT /api/admin/blog-labeling/labels/{blog_id}` - `GET /api/admin/recommendation-stats` +- `GET /api/admin/hourly-stats` - `GET /api/admin/users` - `PATCH /api/admin/users/{user_id}/role` @@ -692,6 +693,62 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 } ``` +#### `GET /api/admin/hourly-stats` + +用途:返回后台统计小时快照,并在读取时刷新当前自然小时的数据。该接口位于 admin API 下,需要 `Authorization: Bearer ` 或已验证 admin 用户 session token。 + +查询参数: + +- `limit`: 返回最近多少个自然小时快照,默认 `24`,最大 `168` + +统计语义: + +- 数据写入 `admin_hourly_stats` 表,每条记录对应一个 UTC 自然小时窗口 `[hour_start, hour_start + 1h)` +- `user_count`: 当前 active 用户总数 +- `random_request_count`: 该小时内 random blog 推荐请求数 +- `random_impression_count`: 该小时内 random blog 推荐曝光数;随机页每次通常请求 9 个 +- `detail_open_count`: 该小时内 random blog 卡片详情打开次数 +- `external_open_count`: 该小时内 random blog 卡片外链打开次数 +- `detail_ctr`: `detail_open_count / random_impression_count` +- `external_ctr`: `external_open_count / random_impression_count` +- `total_click_ctr`: `(detail_open_count + external_open_count) / random_impression_count` + +成功响应示例: + +```json +{ + "current_hour": { + "id": 1, + "hour_start": "2026-06-11T10:00:00+00:00", + "user_count": 12, + "random_request_count": 3, + "random_impression_count": 27, + "detail_open_count": 4, + "external_open_count": 5, + "detail_ctr": 0.1481481481, + "external_ctr": 0.1851851852, + "total_click_ctr": 0.3333333333, + "refreshed_at": "2026-06-11T10:05:00+00:00", + "created_at": "2026-06-11T10:05:00+00:00" + }, + "latest": { + "id": 1, + "hour_start": "2026-06-11T10:00:00+00:00", + "user_count": 12, + "random_request_count": 3, + "random_impression_count": 27, + "detail_open_count": 4, + "external_open_count": 5, + "detail_ctr": 0.1481481481, + "external_ctr": 0.1851851852, + "total_click_ctr": 0.3333333333, + "refreshed_at": "2026-06-11T10:05:00+00:00", + "created_at": "2026-06-11T10:05:00+00:00" + }, + "items": [] +} +``` + #### `POST /api/blogs/user-seeds` 用途:当首页 URL 搜索没有命中时,允许用户提交一个完整博客链接作为用户来源 seed。该接口只执行确定性规则过滤,跳过 RSS discovery 与模型共识;规则通过后会把 URL 同时写入 `blogs` 与 `seeds`。 diff --git a/doc/public-admin-boundary.md b/doc/public-admin-boundary.md index cc0b1b9..61beecd 100644 --- a/doc/public-admin-boundary.md +++ b/doc/public-admin-boundary.md @@ -77,6 +77,7 @@ Admin capabilities: - `GET /api/admin/blog-labeling/tags` - `POST /api/admin/blog-labeling/tags` - `PUT /api/admin/blog-labeling/labels/{blog_id}` +- `GET /api/admin/hourly-stats` - `GET /api/admin/users` - `PATCH /api/admin/users/{user_id}/role` diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b007a26..e77d10e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -421,6 +421,91 @@ beforeEach(() => { if (url.pathname === "/api/stats") { return new Response(JSON.stringify({ total_blogs: statusPayload.total_blogs, total_edges: statusPayload.total_edges })); } + if (url.pathname === "/api/admin/runtime/status") { + return new Response( + JSON.stringify({ + runner_status: "idle", + active_run_id: null, + worker_count: 0, + active_workers: 0, + current_blog_id: null, + current_url: null, + current_stage: null, + elapsed_seconds: null, + maintenance_in_progress: false, + }), + ); + } + if (url.pathname === "/api/admin/runtime/current") { + return new Response( + JSON.stringify({ + runner_status: "idle", + active_run_id: null, + worker_count: 0, + active_workers: 0, + current_blog_id: null, + current_url: null, + current_stage: null, + elapsed_seconds: null, + }), + ); + } + if (url.pathname === "/api/admin/hourly-stats") { + const row = { + id: 1, + hour_start: "2026-06-11T10:00:00Z", + user_count: 12, + random_request_count: 3, + random_impression_count: 27, + detail_open_count: 4, + external_open_count: 5, + detail_ctr: 4 / 27, + external_ctr: 5 / 27, + total_click_ctr: 9 / 27, + refreshed_at: "2026-06-11T10:05:00Z", + created_at: "2026-06-11T10:05:00Z", + }; + return new Response(JSON.stringify({ current_hour: row, latest: row, items: [row] })); + } + if (url.pathname === "/api/admin/blog-labeling/counts") { + return new Response(JSON.stringify({ total_labeled: 0, by_label: {} })); + } + if (url.pathname === "/api/admin/blog-labeling/parquet-status") { + return new Response( + JSON.stringify({ + path: "/tmp/blog-label-training.parquet", + filename: "blog-label-training.parquet", + exists: false, + saved_count: 0, + total_labeled: 0, + missing_count: 0, + batch_size: 100, + rewritten: false, + message: "not ready", + updated_at: null, + }), + ); + } + if (url.pathname === "/api/admin/blog-labeling/candidates") { + return new Response( + JSON.stringify({ + items: [], + available_tags: [ + { id: 1, name: "blog", slug: "blog", created_at: "2026-06-11T00:00:00Z", updated_at: "2026-06-11T00:00:00Z" }, + { id: 2, name: "company", slug: "company", created_at: "2026-06-11T00:00:00Z", updated_at: "2026-06-11T00:00:00Z" }, + { id: 3, name: "other", slug: "other", created_at: "2026-06-11T00:00:00Z", updated_at: "2026-06-11T00:00:00Z" }, + { id: 4, name: "unknown", slug: "unknown", created_at: "2026-06-11T00:00:00Z", updated_at: "2026-06-11T00:00:00Z" }, + ], + page: 1, + page_size: 9, + total_items: 0, + total_pages: 1, + has_next: false, + has_prev: false, + sort: "id_desc", + }), + ); + } if (url.pathname === "/api/filter-stats") { return new Response( JSON.stringify({ @@ -646,6 +731,40 @@ test("shows the admin navigation item only for active verified admin sessions", expect(await screen.findByRole("link", { name: "管理" })).toHaveAttribute("href", "/admin"); }); +test("renders hourly admin statistics for verified admin sessions", async () => { + window.history.replaceState({}, "", "/admin"); + window.localStorage.setItem( + "heyblog_user_session", + JSON.stringify({ + token: "admin-session-token", + expiresAt: "2026-07-10T00:00:00Z", + user: { + id: 70, + email: "admin@magic-knowledge.top", + displayName: "admin", + role: "admin", + isActive: true, + emailVerified: true, + emailVerifiedAt: "2026-06-11T00:00:00Z", + createdAt: "2026-06-11T00:00:00Z", + updatedAt: "2026-06-11T00:00:00Z", + }, + }), + ); + + render(); + + expect(await screen.findByRole("heading", { name: "后台统计" })).toBeInTheDocument(); + expect(screen.getByText("当前用户数")).toBeInTheDocument(); + expect(screen.getAllByText("12").length).toBeGreaterThan(0); + expect(screen.getByText("随机请求 / 曝光")).toBeInTheDocument(); + expect(screen.getByText("3 / 27")).toBeInTheDocument(); + expect(screen.getByText("详情点击率")).toBeInTheDocument(); + expect(screen.getByText("外链点击率")).toBeInTheDocument(); + expect(screen.getAllByText("14.81%").length).toBeGreaterThan(0); + expect(screen.getAllByText("18.52%").length).toBeGreaterThan(0); +}); + test("hides admin navigation and renders 404 for non-admin direct admin URLs", async () => { window.localStorage.setItem( "heyblog_user_session", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dd907d7..842b7e1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,6 +5,8 @@ import type { AdminBlogLabelParquetStatus, AdminRequeueFailedBlogsResult, AdminBlogLabelTag, + AdminHourlyStats, + AdminHourlyStatsRow, AdminRuntimeCurrent, AdminRuntimeStatus, BlogCatalogItem, @@ -287,6 +289,27 @@ interface BackendRuntimePayload { maintenance_in_progress?: boolean; } +interface BackendAdminHourlyStatsRow { + id: number; + hour_start: string | null; + user_count: number; + random_request_count: number; + random_impression_count: number; + detail_open_count: number; + external_open_count: number; + detail_ctr: number; + external_ctr: number; + total_click_ctr: number; + refreshed_at: string | null; + created_at: string | null; +} + +interface BackendAdminHourlyStats { + current_hour: BackendAdminHourlyStatsRow; + latest: BackendAdminHourlyStatsRow; + items: BackendAdminHourlyStatsRow[]; +} + interface BackendBlogLabelTag { id: number; name: string; @@ -439,6 +462,43 @@ function toRandomRecommendationBatch(payload: BackendRandomRecommendationBatchPa }; } +/** + * Convert one backend hourly admin stats row into frontend camelCase shape. + * + * @param row Raw backend admin stats row. + * @returns Normalized admin stats row. + */ +function toAdminHourlyStatsRow(row: BackendAdminHourlyStatsRow): AdminHourlyStatsRow { + return { + id: row.id, + hourStart: row.hour_start, + userCount: row.user_count, + randomRequestCount: row.random_request_count, + randomImpressionCount: row.random_impression_count, + detailOpenCount: row.detail_open_count, + externalOpenCount: row.external_open_count, + detailCtr: row.detail_ctr, + externalCtr: row.external_ctr, + totalClickCtr: row.total_click_ctr, + refreshedAt: row.refreshed_at, + createdAt: row.created_at, + }; +} + +/** + * Convert backend admin hourly stats payload into frontend shape. + * + * @param payload Raw backend admin stats payload. + * @returns Normalized hourly stats collection. + */ +function toAdminHourlyStats(payload: BackendAdminHourlyStats): AdminHourlyStats { + return { + currentHour: toAdminHourlyStatsRow(payload.current_hour), + latest: toAdminHourlyStatsRow(payload.latest), + items: payload.items.map(toAdminHourlyStatsRow), + }; +} + /** * Convert one backend relation graph into frontend graph coordinates. * @@ -1218,6 +1278,21 @@ export async function fetchAdminRuntimeCurrent(adminToken: string): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + const payload = await apiJson(`/api/admin/hourly-stats?${params.toString()}`, { + headers: adminHeaders(adminToken), + }); + return toAdminHourlyStats(payload); +} + /** * Fetch one page of protected blog labeling candidates. * diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 1797d24..2d137a8 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -25,6 +25,7 @@ import { fetchAdminBlogLabelingCandidates, fetchAdminBlogLabelParquetStatus, fetchAdminBlogLabelTitlePreview, + fetchAdminHourlyStats, fetchAdminRuntimeCurrent, fetchAdminRuntimeStatus, fetchStats, @@ -47,6 +48,7 @@ import type { StatsData, AdminBlogLabelCounts, AdminBlogLabelParquetStatus, + AdminHourlyStats, } from "../types/graph"; const ADMIN_TOKEN_STORAGE_KEY = "heyblog_admin_token"; @@ -111,6 +113,34 @@ function clearStoredAdminToken() { window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); } +/** + * Format one ratio as a percentage for compact admin stats. + * + * @param value Ratio where `1` means 100%. + * @returns Human-readable percentage string. + */ +function formatPercent(value: number | null | undefined): string { + return `${((value ?? 0) * 100).toFixed(2)}%`; +} + +/** + * Format an ISO timestamp for the current operator locale. + * + * @param value ISO timestamp or null. + * @returns Short local date-time string. + */ +function formatAdminTime(value: string | null | undefined): string { + if (!value) { + return "--"; + } + return new Intl.DateTimeFormat(undefined, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + /** * Resolve an icon URL for a labeling card. * @@ -139,6 +169,7 @@ export function AdminPage() { const [labelTags, setLabelTags] = useState([]); const [labelCounts, setLabelCounts] = useState({ totalLabeled: 0, byLabel: {} }); const [labelParquetStatus, setLabelParquetStatus] = useState(null); + const [adminHourlyStats, setAdminHourlyStats] = useState(null); const [labelingTotalItems, setLabelingTotalItems] = useState(0); const [labelingTotalPages, setLabelingTotalPages] = useState(1); const [labelingPage, setLabelingPage] = useState(1); @@ -195,23 +226,32 @@ export function AdminPage() { setLabelTags([]); setLabelCounts({ totalLabeled: 0, byLabel: {} }); setLabelParquetStatus(null); + setAdminHourlyStats(null); setLabelingTotalItems(0); setLabelingTotalPages(1); setAdminError("请输入管理员 Token 或 Admin 账号登录 Token 以加载受保护接口。"); return; } - const [runtimeStatusResponse, runtimeCurrentResponse, labelCountResponse, labelParquetResponse] = + const [ + runtimeStatusResponse, + runtimeCurrentResponse, + labelCountResponse, + labelParquetResponse, + hourlyStatsResponse, + ] = await Promise.all([ fetchAdminRuntimeStatus(adminToken), fetchAdminRuntimeCurrent(adminToken), fetchAdminBlogLabelCounts(adminToken), fetchAdminBlogLabelParquetStatus(adminToken), + fetchAdminHourlyStats(adminToken), ]); setRuntimeStatus(runtimeStatusResponse); setRuntimeCurrent(runtimeCurrentResponse); setLabelCounts(labelCountResponse); setLabelParquetStatus(labelParquetResponse); + setAdminHourlyStats(hourlyStatsResponse); setAdminError(null); try { @@ -239,6 +279,7 @@ export function AdminPage() { setLabelTags([]); setLabelCounts({ totalLabeled: 0, byLabel: {} }); setLabelParquetStatus(null); + setAdminHourlyStats(null); setLabelingTotalItems(0); setLabelingTotalPages(1); setAdminError("管理员接口加载失败,请确认 Token 是否正确且账号具备 Admin 身份。"); @@ -477,6 +518,8 @@ export function AdminPage() { } const avgConnections = stats.totalNodes > 0 ? (stats.totalEdges / stats.totalNodes).toFixed(2) : "0.00"; + const currentAdminStats = adminHourlyStats?.currentHour ?? null; + const recentHourlyRows = adminHourlyStats?.items.slice(0, 6) ?? []; const visibleLabelCounts = labelTags.map((tag) => ({ ...tag, count: labelCounts.byLabel[tag.slug] ?? 0, @@ -559,6 +602,80 @@ export function AdminPage() {
+
+
+
+

后台统计

+

+ 当前自然小时:{formatAdminTime(currentAdminStats?.hourStart)},刷新于{" "} + {formatAdminTime(currentAdminStats?.refreshedAt)}。 +

+
+
统计按推荐曝光计算平均点击率。
+
+ +
+
+
当前用户数
+
{currentAdminStats?.userCount ?? 0}
+
+
+
随机请求 / 曝光
+
+ {currentAdminStats?.randomRequestCount ?? 0} / {currentAdminStats?.randomImpressionCount ?? 0} +
+
+
+
详情点击率
+
{formatPercent(currentAdminStats?.detailCtr)}
+
{currentAdminStats?.detailOpenCount ?? 0} 次详情打开
+
+
+
外链点击率
+
{formatPercent(currentAdminStats?.externalCtr)}
+
{currentAdminStats?.externalOpenCount ?? 0} 次外链打开
+
+
+
总点击率
+
{formatPercent(currentAdminStats?.totalClickCtr)}
+
+
+ +
+ + + + + + + + + + + + + {recentHourlyRows.map((row) => ( + + + + + + + + + ))} + {recentHourlyRows.length === 0 ? ( + + + + ) : null} + +
小时用户请求曝光详情 CTR外链 CTR
{formatAdminTime(row.hourStart)}{row.userCount}{row.randomRequestCount}{row.randomImpressionCount}{formatPercent(row.detailCtr)}{formatPercent(row.externalCtr)}
+ 输入管理员 Token 后加载小时统计。 +
+
+
+
diff --git a/frontend/src/types/graph.ts b/frontend/src/types/graph.ts index e7b6a9c..8a04cac 100644 --- a/frontend/src/types/graph.ts +++ b/frontend/src/types/graph.ts @@ -247,6 +247,27 @@ export interface AdminRuntimeCurrent { elapsedSeconds: number | null; } +export interface AdminHourlyStatsRow { + id: number; + hourStart: string | null; + userCount: number; + randomRequestCount: number; + randomImpressionCount: number; + detailOpenCount: number; + externalOpenCount: number; + detailCtr: number; + externalCtr: number; + totalClickCtr: number; + refreshedAt: string | null; + createdAt: string | null; +} + +export interface AdminHourlyStats { + currentHour: AdminHourlyStatsRow; + latest: AdminHourlyStatsRow; + items: AdminHourlyStatsRow[]; +} + export interface AdminRequeueFailedBlogsResult { requeued: number; } diff --git a/persistence_api/main.py b/persistence_api/main.py index a4b8deb..1f6c11b 100644 --- a/persistence_api/main.py +++ b/persistence_api/main.py @@ -416,6 +416,13 @@ def get_blog_recommendation_stats(blog_id: int) -> dict[str, Any]: def get_recommendation_strategy_stats() -> dict[str, Any]: return get_state().repository.get_recommendation_strategy_stats() + @app.get("/internal/admin/hourly-stats") + def get_admin_hourly_stats(limit: int = 24) -> dict[str, Any]: + return _call_with_value_error_http_translation( + lambda: get_state().repository.get_admin_hourly_stats(limit=limit), + status_code=422, + ) + @app.post("/internal/users/register") def register_user(payload: UserAuthRequest) -> dict[str, Any]: return _call_with_http_exception_translation( diff --git a/persistence_api/models.py b/persistence_api/models.py index 417c2e6..8316704 100644 --- a/persistence_api/models.py +++ b/persistence_api/models.py @@ -6,6 +6,7 @@ from sqlalchemy import DateTime from sqlalchemy import Enum +from sqlalchemy import Float from sqlalchemy import ForeignKey from sqlalchemy import Boolean from sqlalchemy import Integer @@ -433,3 +434,31 @@ class BlogInteractionModel(Base): client_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) attributes_json: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class AdminHourlyStatsModel(Base): + """Hourly admin dashboard statistics snapshot. + + Args: + None. SQLAlchemy constructs model instances from mapped keyword + arguments. + + Returns: + One natural-hour aggregate row refreshed from source tables. + """ + + __tablename__ = "admin_hourly_stats" + __table_args__ = (UniqueConstraint("hour_start", name="uq_admin_hourly_stats_hour_start"),) + + id: Mapped[int] = mapped_column(primary_key=True) + hour_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + user_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + random_request_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + random_impression_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + detail_open_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + external_open_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + detail_ctr: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + external_ctr: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + total_click_ctr: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + refreshed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/persistence_api/repository.py b/persistence_api/repository.py index b7c5405..4b31f32 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -39,6 +39,7 @@ from persistence_api.email_delivery import EmailDelivery from persistence_api.email_delivery import NoopEmailDelivery from persistence_api.models import Base +from persistence_api.models import AdminHourlyStatsModel from persistence_api.models import BlogLabelModel from persistence_api.models import BlogLabelTagModel from persistence_api.models import BlogInteractionModel @@ -1018,6 +1019,30 @@ def ensure_legacy_compat_schema(engine: Any) -> None: connection.execute( text("CREATE INDEX IF NOT EXISTS ix_blog_interactions_entrance_url ON blog_interactions (entrance_url)") ) + if "admin_hourly_stats" not in existing_tables: + stats_id_type = "SERIAL PRIMARY KEY" if connection.dialect.name == "postgresql" else "INTEGER PRIMARY KEY" + stats_timestamp_type = "TIMESTAMP WITH TIME ZONE" if connection.dialect.name == "postgresql" else "DATETIME" + connection.execute( + text( + "CREATE TABLE admin_hourly_stats (" + f"id {stats_id_type}, " + f"hour_start {stats_timestamp_type} NOT NULL UNIQUE, " + "user_count INTEGER DEFAULT 0 NOT NULL, " + "random_request_count INTEGER DEFAULT 0 NOT NULL, " + "random_impression_count INTEGER DEFAULT 0 NOT NULL, " + "detail_open_count INTEGER DEFAULT 0 NOT NULL, " + "external_open_count INTEGER DEFAULT 0 NOT NULL, " + "detail_ctr FLOAT DEFAULT 0 NOT NULL, " + "external_ctr FLOAT DEFAULT 0 NOT NULL, " + "total_click_ctr FLOAT DEFAULT 0 NOT NULL, " + f"refreshed_at {stats_timestamp_type} DEFAULT CURRENT_TIMESTAMP NOT NULL, " + f"created_at {stats_timestamp_type} DEFAULT CURRENT_TIMESTAMP NOT NULL)" + ) + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_admin_hourly_stats_hour_start ON admin_hourly_stats (hour_start)") + ) + existing_tables.add("admin_hourly_stats") if "blog_label_tags" not in existing_tables: connection.execute( text( @@ -1881,6 +1906,8 @@ def get_blog_recommendation_stats(self, blog_id: int) -> dict[str, Any] | None: def get_recommendation_strategy_stats(self) -> dict[str, Any]: ... + def get_admin_hourly_stats(self, *, limit: int = 24) -> dict[str, Any]: ... + def list_blog_labeling_candidates( self, *, @@ -4353,6 +4380,150 @@ def get_blog_recommendation_stats(self, blog_id: int) -> dict[str, Any] | None: "by_event_type": event_counts, } + def _hour_start(self, value: datetime | None = None) -> datetime: + """Return the UTC natural-hour boundary for one timestamp. + + Args: + value: Optional timestamp to normalize. Current UTC time is used + when omitted. + + Returns: + Timezone-aware UTC datetime truncated to the hour. + """ + + current = value or datetime.now(UTC) + if current.tzinfo is None: + current = current.replace(tzinfo=UTC) + return current.astimezone(UTC).replace(minute=0, second=0, microsecond=0) + + def _admin_hourly_stats_payload(self, row: AdminHourlyStatsModel) -> dict[str, Any]: + """Serialize one hourly admin statistics row. + + Args: + row: Persisted hourly statistics snapshot. + + Returns: + JSON-ready snapshot payload. + """ + + return { + "id": row.id, + "hour_start": _iso(row.hour_start), + "user_count": row.user_count, + "random_request_count": row.random_request_count, + "random_impression_count": row.random_impression_count, + "detail_open_count": row.detail_open_count, + "external_open_count": row.external_open_count, + "detail_ctr": row.detail_ctr, + "external_ctr": row.external_ctr, + "total_click_ctr": row.total_click_ctr, + "refreshed_at": _iso(row.refreshed_at), + "created_at": _iso(row.created_at), + } + + def _refresh_admin_hourly_stats(self, session: Session, hour_start: datetime) -> AdminHourlyStatsModel: + """Refresh or create one admin statistics snapshot for a natural hour. + + Args: + session: Active SQLAlchemy session. + hour_start: UTC natural-hour boundary to aggregate. + + Returns: + Persisted snapshot row after source-table aggregation. + """ + + hour_end = hour_start + timedelta(hours=1) + user_count = int( + session.scalar(select(func.count(UserModel.id)).where(UserModel.is_active.is_(True))) or 0 + ) + random_request_count = int( + session.scalar( + select(func.count(RecommendationRequestModel.id)).where( + RecommendationRequestModel.surface == RANDOM_RECOMMENDATION_SURFACE, + RecommendationRequestModel.created_at >= hour_start, + RecommendationRequestModel.created_at < hour_end, + ) + ) + or 0 + ) + random_impression_count = int( + session.scalar( + select(func.count(RecommendationImpressionModel.id)) + .join(RecommendationRequestModel, RecommendationImpressionModel.request_id == RecommendationRequestModel.id) + .where( + RecommendationRequestModel.surface == RANDOM_RECOMMENDATION_SURFACE, + RecommendationImpressionModel.created_at >= hour_start, + RecommendationImpressionModel.created_at < hour_end, + ) + ) + or 0 + ) + interaction_counts = { + str(event_type): int(count or 0) + for event_type, count in session.execute( + select(BlogInteractionModel.event_type, func.count(BlogInteractionModel.id)) + .join(RecommendationRequestModel, BlogInteractionModel.request_id == RecommendationRequestModel.id) + .where( + RecommendationRequestModel.surface == RANDOM_RECOMMENDATION_SURFACE, + BlogInteractionModel.created_at >= hour_start, + BlogInteractionModel.created_at < hour_end, + BlogInteractionModel.event_type.in_(("detail_open", "external_open")), + ) + .group_by(BlogInteractionModel.event_type) + ).all() + } + detail_open_count = int(interaction_counts.get("detail_open", 0)) + external_open_count = int(interaction_counts.get("external_open", 0)) + denominator = random_impression_count or 0 + detail_ctr = detail_open_count / denominator if denominator else 0.0 + external_ctr = external_open_count / denominator if denominator else 0.0 + total_click_ctr = (detail_open_count + external_open_count) / denominator if denominator else 0.0 + row = session.scalar( + select(AdminHourlyStatsModel).where(AdminHourlyStatsModel.hour_start == hour_start) + ) + if row is None: + row = AdminHourlyStatsModel(hour_start=hour_start) + session.add(row) + row.user_count = user_count + row.random_request_count = random_request_count + row.random_impression_count = random_impression_count + row.detail_open_count = detail_open_count + row.external_open_count = external_open_count + row.detail_ctr = detail_ctr + row.external_ctr = external_ctr + row.total_click_ctr = total_click_ctr + row.refreshed_at = datetime.now(UTC) + session.flush() + return row + + def get_admin_hourly_stats(self, *, limit: int = 24) -> dict[str, Any]: + """Return hourly admin statistics and refresh the current hour. + + Args: + limit: Maximum number of hourly snapshots to return. + + Returns: + Admin statistics payload with latest/current snapshots first. + """ + + normalized_limit = max(1, min(int(limit), 168)) + with session_scope(self.session_factory) as session: + current_hour = self._hour_start() + current = self._refresh_admin_hourly_stats(session, current_hour) + rows = list( + session.scalars( + select(AdminHourlyStatsModel) + .order_by(AdminHourlyStatsModel.hour_start.desc()) + .limit(normalized_limit) + ) + ) + latest = rows[0] if rows else current + return { + "current_hour": self._admin_hourly_stats_payload(current), + "latest": self._admin_hourly_stats_payload(latest), + "items": [self._admin_hourly_stats_payload(row) for row in rows], + } + def get_recommendation_strategy_stats(self) -> dict[str, Any]: """Return aggregate recommendation request, impression, and event stats. diff --git a/shared/http_clients/persistence_http.py b/shared/http_clients/persistence_http.py index 927ef10..663b5c2 100644 --- a/shared/http_clients/persistence_http.py +++ b/shared/http_clients/persistence_http.py @@ -329,6 +329,18 @@ def get_recommendation_strategy_stats(self) -> dict[str, Any]: return self._get("/internal/recommendation-stats") + def get_admin_hourly_stats(self, *, limit: int = 24) -> dict[str, Any]: + """Load hourly admin dashboard statistics snapshots. + + Args: + limit: Maximum number of hourly snapshots to fetch. + + Returns: + Hourly admin statistics payload returned by persistence. + """ + + return self._get("/internal/admin/hourly-stats", {"limit": limit}) + def create_user_seed(self, *, homepage_url: str) -> dict[str, Any]: """Create or refresh a user-submitted crawler seed. diff --git a/tests/test_repository.py b/tests/test_repository.py index 9794ebc..1c10cc3 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -22,6 +22,7 @@ from persistence_api.models import RawDiscoveredUrlModel from persistence_api.models import RecommendationImpressionModel from persistence_api.models import RecommendationRequestModel +from persistence_api.models import AdminHourlyStatsModel from persistence_api.models import SeedModel from shared.contracts.enums import CrawlStatus from shared.config import Settings @@ -1310,8 +1311,21 @@ def test_repository_persists_random_recommendation_batch_and_interaction_stats(t entrance_kind="test_detail", entrance_url="http://localhost/random", ) + repository.record_blog_interaction( + event_uuid="event-2", + event_type="external_open", + blog_id=first["id"], + visitor_id="visitor-1", + session_id="session-1", + entrance_kind="test_external", + entrance_url="http://localhost/random", + request_uuid=first["request_uuid"], + impression_id=first["impression_id"], + interaction_order=2, + ) stats = repository.get_blog_recommendation_stats(first["id"]) strategy_stats = repository.get_recommendation_strategy_stats() + hourly_stats = repository.get_admin_hourly_stats() assert event["duplicate"] is False assert event["entrance_kind"] == "test_detail" @@ -1320,22 +1334,34 @@ def test_repository_persists_random_recommendation_batch_and_interaction_stats(t assert stats is not None assert stats["impressions"] == 1 assert stats["detail_opens"] == 1 + assert stats["external_opens"] == 1 assert stats["unique_visitors"] == 1 - assert stats["ctr"] == 1.0 + assert stats["ctr"] == 2.0 assert strategy_stats["total_requests"] == 1 assert strategy_stats["total_impressions"] == 2 - assert strategy_stats["total_interactions"] == 1 - assert strategy_stats["by_strategy"][0]["clicks"] == 1 + assert strategy_stats["total_interactions"] == 2 + assert strategy_stats["by_strategy"][0]["clicks"] == 2 + assert hourly_stats["current_hour"]["user_count"] == 0 + assert hourly_stats["current_hour"]["random_request_count"] == 1 + assert hourly_stats["current_hour"]["random_impression_count"] == 2 + assert hourly_stats["current_hour"]["detail_open_count"] == 1 + assert hourly_stats["current_hour"]["external_open_count"] == 1 + assert hourly_stats["current_hour"]["detail_ctr"] == 0.5 + assert hourly_stats["current_hour"]["external_ctr"] == 0.5 + assert hourly_stats["current_hour"]["total_click_ctr"] == 1.0 with session_scope(repository.session_factory) as session: assert session.scalar(select(RecommendationRequestModel).limit(1)) is not None stored_impression = session.scalar(select(RecommendationImpressionModel).limit(1)) stored_interaction = session.scalar(select(BlogInteractionModel).limit(1)) + stored_hourly_stats = session.scalar(select(AdminHourlyStatsModel).limit(1)) assert stored_impression is not None assert stored_impression.normalized_url == first["normalized_url"] assert "blog_id" not in RecommendationImpressionModel.__table__.columns assert stored_interaction is not None assert stored_interaction.normalized_url == first["normalized_url"] assert "blog_id" not in BlogInteractionModel.__table__.columns + assert stored_hourly_stats is not None + assert stored_hourly_stats.random_impression_count == 2 def test_repository_blog_catalog_uses_display_identity_fallbacks_for_legacy_rows(tmp_path: Path) -> None: diff --git a/tests/test_service_split.py b/tests/test_service_split.py index fb825d4..adafb4c 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -965,6 +965,7 @@ def post(self, path: str, json: dict[str, object], **kwargs: object) -> StubResp ) assert client.get_blog_recommendation_stats(42) == {"ok": True} assert client.get_recommendation_strategy_stats() == {"ok": True} + assert client.get_admin_hourly_stats(limit=6) == {"ok": True} assert stub.post_calls == [ ( @@ -1002,6 +1003,7 @@ def post(self, path: str, json: dict[str, object], **kwargs: object) -> StubResp assert stub.get_calls == [ ("/internal/blogs/42/recommendation-stats", None), ("/internal/recommendation-stats", None), + ("/internal/admin/hourly-stats", {"limit": 6}), ] @@ -1031,6 +1033,9 @@ def get_blog_recommendation_stats(self, blog_id: int) -> dict[str, object]: def get_recommendation_strategy_stats(self) -> dict[str, object]: return {"total_requests": 1, "by_strategy": []} + def get_admin_hourly_stats(self, *, limit: int = 24) -> dict[str, object]: + return {"limit": limit, "items": []} + persistence = RecommendationPersistenceStub() app = create_backend_app( BackendState( @@ -1070,11 +1075,13 @@ def get_recommendation_strategy_stats(self) -> dict[str, object]: ) blog_stats = client.get("/api/blogs/42/stats") admin_stats = client.get("/api/admin/recommendation-stats", headers=admin_headers()) + admin_hourly_stats = client.get("/api/admin/hourly-stats?limit=6", headers=admin_headers()) assert batch_response.status_code == 200 assert event_response.status_code == 200 assert blog_stats.json() == {"blog_id": 42, "impressions": 1} assert admin_stats.json() == {"total_requests": 1, "by_strategy": []} + assert admin_hourly_stats.json() == {"limit": 6, "items": []} assert persistence.batch_payload is not None assert persistence.batch_payload["user_id"] == 7 assert persistence.batch_payload["visitor_id"] == "visitor-1" From 62867de7f3a47239badaa5c9a54d9c4c665e75e9 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Thu, 11 Jun 2026 14:23:11 +0100 Subject: [PATCH 33/35] =?UTF-8?q?=F0=9F=8E=88=20perf:=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=85=B3=E9=97=ADtoken=E6=9A=B4=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- doc/api-docs.md | 4 ++-- doc/config-reference.md | 2 +- docker-compose.yml | 2 +- persistence_api/repository.py | 2 +- shared/config.py | 4 ++-- tests/test_service_split.py | 9 +++++++++ 7 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index b25b67e..eece7ed 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,7 @@ HEYBLOG_PUBLIC_BASE_URL=http://127.0.0.1:3000 # Email delivery. Keep disabled for local dev unless SMTP credentials are set. HEYBLOG_EMAIL_PROVIDER=disabled HEYBLOG_EMAIL_FROM=no-reply@example.com -HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=true +HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=false HEYBLOG_SMTP_HOST=smtp.example.com HEYBLOG_SMTP_PORT=587 HEYBLOG_SMTP_USERNAME= diff --git a/doc/api-docs.md b/doc/api-docs.md index 1a6b0f7..84386ad 100644 --- a/doc/api-docs.md +++ b/doc/api-docs.md @@ -354,7 +354,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 用途:为已经创建但尚未验证的普通用户或 admin 用户重新生成邮箱验证 token。未知邮箱和仍处于注册待验证阶段、尚未持久化的邮箱返回中性成功语义,避免暴露账号是否存在;待验证新注册应继续使用注册邮件中的链接完成账号创建。 -邮件通道由 `persistence-api` 的 `HEYBLOG_EMAIL_PROVIDER` 控制。默认 `disabled` 模式不会连接 SMTP,并会在响应体中返回一次性验证 token/link,方便本地调试和手动验证。设置 `HEYBLOG_EMAIL_PROVIDER=smtp` 后,系统会把验证链接发送到用户邮箱;生产环境应设置 `HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=false`,让 API 响应只保留发送状态和过期时间,不暴露明文 token。 +邮件通道由 `persistence-api` 的 `HEYBLOG_EMAIL_PROVIDER` 控制。默认 `disabled` 模式不会连接 SMTP,API 响应默认只返回发送状态和过期时间,不暴露明文 token。需要本地调试或手动验证时,可显式设置 `HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=true` 让响应体返回一次性验证 token/link。设置 `HEYBLOG_EMAIL_PROVIDER=smtp` 后,系统会把验证链接发送到用户邮箱。 请求体: @@ -414,7 +414,7 @@ Admin API 同样由 `backend` 暴露,但统一位于 `/api/admin/*` 下,并 } ``` -默认开发响应包含可直接使用的 `reset_token` 与 `reset_url`。设置 `HEYBLOG_EMAIL_PROVIDER=smtp` 后,系统会把 reset link 发送到用户邮箱;生产环境应设置 `HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=false`,让 API 响应隐藏明文 reset token。后端始终只持久化 token hash。 +默认响应隐藏明文 reset token,只返回发送状态和过期时间。需要本地调试或手动验证时,可显式设置 `HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS=true` 返回可直接使用的 `reset_token` 与 `reset_url`。设置 `HEYBLOG_EMAIL_PROVIDER=smtp` 后,系统会把 reset link 发送到用户邮箱。后端始终只持久化 token hash。 生产 SMTP 且关闭 dev token 暴露后的成功响应: diff --git a/doc/config-reference.md b/doc/config-reference.md index f088e70..205c1e7 100644 --- a/doc/config-reference.md +++ b/doc/config-reference.md @@ -47,7 +47,7 @@ Docker Compose 也会从仓库根目录的 `.env` 读取变量。 | `HEYBLOG_PUBLIC_BASE_URL` | `http://127.0.0.1:3000` | `persistence-api` | 生成邮箱验证与密码重置链接时使用的公开前端基准地址 | | `HEYBLOG_EMAIL_PROVIDER` | `disabled` | `persistence-api` | 用户生命周期邮件 provider。可选 `disabled`/`noop` 或 `smtp`;默认不连接邮件服务 | | `HEYBLOG_EMAIL_FROM` | 空 | `persistence-api` | SMTP 邮件发件人地址;启用 `smtp` 时必须设置 | -| `HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS` | `true` | `persistence-api` | 是否在验证/重置 API 响应中暴露 raw token/link。生产 SMTP 应设置为 `false` | +| `HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS` | `false` | `persistence-api` | 是否在验证/重置 API 响应中暴露 raw token/link。仅本地调试需要手动设置为 `true` | | `HEYBLOG_SMTP_HOST` | 空 | `persistence-api` | SMTP 服务器主机名 | | `HEYBLOG_SMTP_PORT` | `587` | `persistence-api` | SMTP 服务器端口 | | `HEYBLOG_SMTP_USERNAME` | 未设置 | `persistence-api` | SMTP 用户名;为空时不执行登录 | diff --git a/docker-compose.yml b/docker-compose.yml index a307878..6ebea3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -160,7 +160,7 @@ services: HEYBLOG_PUBLIC_BASE_URL: ${HEYBLOG_PUBLIC_BASE_URL:-http://127.0.0.1:3000} HEYBLOG_EMAIL_PROVIDER: ${HEYBLOG_EMAIL_PROVIDER:-disabled} HEYBLOG_EMAIL_FROM: ${HEYBLOG_EMAIL_FROM:-} - HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS: ${HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS:-true} + HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS: ${HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS:-false} HEYBLOG_SMTP_HOST: ${HEYBLOG_SMTP_HOST:-} HEYBLOG_SMTP_PORT: ${HEYBLOG_SMTP_PORT:-587} HEYBLOG_SMTP_USERNAME: ${HEYBLOG_SMTP_USERNAME:-} diff --git a/persistence_api/repository.py b/persistence_api/repository.py index 4b31f32..21b83fe 100644 --- a/persistence_api/repository.py +++ b/persistence_api/repository.py @@ -1976,7 +1976,7 @@ class SQLAlchemyRepository: startup_schema_sync: bool = True public_base_url: str = "http://127.0.0.1:3000" email_delivery: EmailDelivery = field(default_factory=NoopEmailDelivery) - email_dev_expose_tokens: bool = True + email_dev_expose_tokens: bool = False engine: Any = field(init=False, repr=False) session_factory: Any = field(init=False, repr=False) diff --git a/shared/config.py b/shared/config.py index de38abd..316734f 100644 --- a/shared/config.py +++ b/shared/config.py @@ -116,7 +116,7 @@ class Settings: public_base_url: str = "http://127.0.0.1:3000" email_provider: str = "disabled" email_from: str = "" - email_dev_expose_tokens: bool = True + email_dev_expose_tokens: bool = False smtp_host: str = "" smtp_port: int = 587 smtp_username: str | None = None @@ -235,7 +235,7 @@ def from_env(cls) -> "Settings": public_base_url=os.getenv("HEYBLOG_PUBLIC_BASE_URL", "http://127.0.0.1:3000").rstrip("/"), email_provider=os.getenv("HEYBLOG_EMAIL_PROVIDER", "disabled").strip().lower() or "disabled", email_from=os.getenv("HEYBLOG_EMAIL_FROM", "").strip(), - email_dev_expose_tokens=_parse_bool_env("HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS", default=True), + email_dev_expose_tokens=_parse_bool_env("HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS"), smtp_host=os.getenv("HEYBLOG_SMTP_HOST", "").strip(), smtp_port=max(1, int(os.getenv("HEYBLOG_SMTP_PORT", "587"))), smtp_username=os.getenv("HEYBLOG_SMTP_USERNAME") or None, diff --git a/tests/test_service_split.py b/tests/test_service_split.py index adafb4c..ab07cb9 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -1140,6 +1140,15 @@ def test_settings_loads_smtp_email_delivery_configuration(monkeypatch) -> None: assert settings.smtp_timeout_seconds == 3.5 +def test_settings_defaults_to_hiding_lifecycle_tokens(monkeypatch) -> None: + """Environment loading should keep lifecycle tokens hidden unless opted in.""" + monkeypatch.delenv("HEYBLOG_EMAIL_DEV_EXPOSE_TOKENS", raising=False) + + settings = Settings.from_env() + + assert settings.email_dev_expose_tokens is False + + def test_settings_default_runtime_model_root_uses_runtime_resources(monkeypatch) -> None: """Environment loading should default runtime model reads to published resources.""" monkeypatch.delenv("HEYBLOG_DECISION_MODEL_ROOT", raising=False) From c49482354b0dcc1cdedec158ba3a5b15e84ece45 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Thu, 11 Jun 2026 14:40:44 +0100 Subject: [PATCH 34/35] =?UTF-8?q?=F0=9F=90=9E=20fix:=20pytest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_repository.py | 29 +++++++++++++++++++++++------ tests/test_service_split.py | 2 ++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/tests/test_repository.py b/tests/test_repository.py index 1c10cc3..c3ea775 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -67,6 +67,17 @@ def send_password_reset_email(self, *, to_email: str, reset_url: str) -> None: self.reset_urls.append((to_email, reset_url)) +def build_dev_token_repository(tmp_path: Path) -> repository_module.SQLAlchemyRepository: + """Build a repository that exposes lifecycle tokens for local flow tests.""" + settings = Settings( + db_path=tmp_path / "db.sqlite", + seed_path=tmp_path / "seed.csv", + export_dir=tmp_path / "exports", + email_dev_expose_tokens=True, + ) + return repository_module.build_repository(db_path=settings.db_path, settings=settings) + + def register_and_verify_user( repository: repository_module.SQLAlchemyRepository, *, @@ -134,7 +145,7 @@ def fake_repository( def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) -> None: """Reset should wipe only graph queue tables while retaining other records.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + repository = build_dev_token_repository(tmp_path) first_blog_id, inserted = repository.upsert_blog( url="https://blog.example.com/", normalized_url="https://blog.example.com/", @@ -248,7 +259,7 @@ def test_repository_reset_preserves_seed_rows_and_restarts_ids(tmp_path: Path) - def test_repository_register_login_and_session_profile(tmp_path: Path) -> None: """Users persist only after email verification, then can log in.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + repository = build_dev_token_repository(tmp_path) pending = repository.register_user(email="User@Example.com", password="correct horse") assert pending["sent"] is True @@ -274,7 +285,7 @@ def test_repository_register_login_and_session_profile(tmp_path: Path) -> None: def test_repository_email_verification_and_password_reset_flow(tmp_path: Path) -> None: """Email verification and password reset tokens should be single-use.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + repository = build_dev_token_repository(tmp_path) created = repository.register_user(email="verify@example.com", password="correct horse") verification_token = created["verification_token"] @@ -339,7 +350,7 @@ def test_repository_sends_lifecycle_email_and_hides_tokens_when_configured(tmp_p def test_repository_admin_role_updates_user_identity(tmp_path: Path) -> None: """Users should be promotable between regular user and admin roles.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + repository = build_dev_token_repository(tmp_path) created = register_and_verify_user(repository, email="admin@example.com", password="correct horse") user_id = int(created["id"]) @@ -356,7 +367,7 @@ def test_repository_admin_role_updates_user_identity(tmp_path: Path) -> None: def test_repository_rejects_duplicate_user_and_bad_credentials(tmp_path: Path) -> None: """Email uniqueness and password validation should produce stable errors.""" - repository = repository_module.build_repository(db_path=tmp_path / "db.sqlite") + repository = build_dev_token_repository(tmp_path) register_and_verify_user(repository, email="dupe@example.com", password="long enough") with pytest.raises(repository_module.UserAuthError, match="email_already_registered"): @@ -1124,7 +1135,13 @@ def test_repository_blog_catalog_supports_random_sort_for_finished_sampling(tmp_ def test_repository_random_catalog_filters_admin_non_blog_and_saves_user_labels(tmp_path: Path) -> None: """Random catalog should exclude admin non-blog URLs and store public votes separately.""" - repository = repository_module.build_repository(db_path=tmp_path / "heyblog.sqlite") + settings = Settings( + db_path=tmp_path / "heyblog.sqlite", + seed_path=tmp_path / "seed.csv", + export_dir=tmp_path / "exports", + email_dev_expose_tokens=True, + ) + repository = repository_module.build_repository(db_path=settings.db_path, settings=settings) blog_tag = repository.create_blog_label_tag(name="blog") company_tag = repository.create_blog_label_tag(name="company") other_tag = repository.create_blog_label_tag(name="other") diff --git a/tests/test_service_split.py b/tests/test_service_split.py index ab07cb9..10faa25 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -109,6 +109,7 @@ def test_persistence_service_exposes_supported_repository_data(tmp_path: Path) - db_path=tmp_path / "heyblog.sqlite", seed_path=tmp_path / "seed.csv", export_dir=tmp_path / "exports", + email_dev_expose_tokens=True, ) state = build_persistence_state(settings) app = create_persistence_app(state) @@ -562,6 +563,7 @@ def test_persistence_service_exposes_blog_labeling_endpoints(tmp_path: Path) -> db_path=tmp_path / "heyblog.sqlite", seed_path=tmp_path / "seed.csv", export_dir=tmp_path / "exports", + email_dev_expose_tokens=True, ) app = create_persistence_app(build_persistence_state(settings)) client = TestClient(app) From 13aa4e3c429933fc31af98743c1ea9630f2e2042 Mon Sep 17 00:00:00 2001 From: Lige <1304412077@qq.com> Date: Thu, 11 Jun 2026 17:29:23 +0100 Subject: [PATCH 35/35] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=AF=8F=E5=B0=8F=E6=97=B6=E8=87=AA=E5=8A=A8=E5=BC=80=E5=90=AF?= =?UTF-8?q?=E7=88=AC=E8=99=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + crawler/README.md | 1 + crawler/main.py | 27 ++++++ crawler/runtime/service.py | 56 ++++++++++++ doc/config-reference.md | 1 + docker-compose.yml | 1 + shared/config.py | 11 +++ shared/http_clients/persistence_http.py | 18 ++++ tests/test_crawler_service.py | 68 ++++++++------ tests/test_runtime.py | 113 ++++++++++++++++++++++++ tests/test_service_split.py | 36 ++++++++ 11 files changed, 307 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index eece7ed..56c6868 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,8 @@ HEYBLOG_MAX_PATH_PROBES_PER_BLOG=50 HEYBLOG_MAX_CANDIDATE_LINKS_PER_PAGE=50 HEYBLOG_CANDIDATE_PAGE_FETCH_CONCURRENCY=4 HEYBLOG_RUNTIME_WORKER_COUNT=3 +# Idle runtime auto-start check interval. Default is one hour. +HEYBLOG_RUNTIME_AUTO_START_INTERVAL_SECONDS=3600 # Set HEYBLOG_RAW_DISCOVERED_URL_LIMIT=-1 to disable the crawler raw URL limit. HEYBLOG_RAW_DISCOVERED_URL_LIMIT=1000000 diff --git a/crawler/README.md b/crawler/README.md index f9efc20..d6a49b2 100644 --- a/crawler/README.md +++ b/crawler/README.md @@ -90,6 +90,7 @@ crawler/ - 通过 `Settings.from_env()` 读取运行配置。 - 构建 `PersistenceHttpClient`,说明 crawler 服务默认不直接连数据库,而是通过 persistence-api 通信。 - 构建 `CrawlPipeline` 和 `CrawlerRuntimeService`。 +- 启动内置 runtime auto scheduler:默认每小时检查一次,若 runtime 已空闲则自动调用 start,以便用户新提交的 `WAITING` 博客被继续抓取。 - 暴露内部接口: - `GET /internal/health` - `POST /internal/crawl/bootstrap` diff --git a/crawler/main.py b/crawler/main.py index 06f84d0..2910ed7 100644 --- a/crawler/main.py +++ b/crawler/main.py @@ -72,6 +72,7 @@ def build_crawler_state(settings: Settings | None = None) -> CrawlerState: runtime=CrawlerRuntimeService( pipeline, worker_count=resolved.runtime_worker_count, + auto_start_interval_seconds=resolved.runtime_auto_start_interval_seconds, ), ) @@ -99,6 +100,32 @@ def create_app(state: CrawlerState | None = None) -> FastAPI: app.add_middleware(RequestIdMiddleware, service=SERVICE_NAME) app.state.crawler_state = state or build_crawler_state() + @app.on_event("startup") + def start_runtime_auto_scheduler() -> None: + """Start runtime auto scheduling when the ASGI app starts serving.""" + scheduler_result = app.state.crawler_state.runtime.start_auto_scheduler() + log_event( + LOGGER, + event="crawler.runtime.auto_scheduler.started", + message="crawler runtime auto scheduler started", + stage="runtime", + accepted=scheduler_result.get("accepted"), + interval_seconds=scheduler_result.get("interval_seconds"), + reason=scheduler_result.get("reason"), + ) + + @app.on_event("shutdown") + def stop_runtime_auto_scheduler() -> None: + """Stop runtime auto scheduling when the ASGI app shuts down.""" + scheduler_result = app.state.crawler_state.runtime.stop_auto_scheduler() + log_event( + LOGGER, + event="crawler.runtime.auto_scheduler.stopped", + message="crawler runtime auto scheduler stopped", + stage="runtime", + accepted=scheduler_result.get("accepted"), + ) + def get_state() -> CrawlerState: """Return the app-scoped crawler state container. diff --git a/crawler/runtime/service.py b/crawler/runtime/service.py index 3d29b1a..9d4bb46 100644 --- a/crawler/runtime/service.py +++ b/crawler/runtime/service.py @@ -42,6 +42,7 @@ def __init__( executor: SerialRuntimeExecutor | None = None, *, worker_count: int = 1, + auto_start_interval_seconds: float = 3600.0, ) -> None: """Initialize runtime state, workers, and synchronization primitives. @@ -49,6 +50,7 @@ def __init__( pipeline: Crawl pipeline reused by synchronous and background runs. executor: Optional executor used to start the background thread. worker_count: Requested number of runtime workers. + auto_start_interval_seconds: Seconds between idle runtime checks. Returns: ``None``. The runtime service stores its dependencies and prepares @@ -57,6 +59,7 @@ def __init__( self.pipeline = pipeline self.executor = executor or SerialRuntimeExecutor() self.worker_count = max(1, worker_count) + self.auto_start_interval_seconds = max(0.001, auto_start_interval_seconds) self.capacity_gate = getattr(pipeline, "capacity_gate", None) if self.capacity_gate is None: self.capacity_gate = CrawlerCapacityGate( @@ -74,6 +77,8 @@ def __init__( self._claim_lock = Lock() self._stop_event = Event() self._thread: Thread | None = None + self._scheduler_thread: Thread | None = None + self._scheduler_stop_event = Event() def status(self) -> dict[str, Any]: """Return the current full runtime snapshot. @@ -133,6 +138,42 @@ def start(self) -> dict[str, Any]: self._thread = self.executor.start(self._run_background_loop) return self._snapshot.as_dict() + def start_auto_scheduler(self) -> dict[str, Any]: + """Start the periodic idle-runtime wakeup loop if needed. + + Returns: + Payload describing whether the scheduler is running. + """ + with self._lock: + if self._scheduler_thread is not None and self._scheduler_thread.is_alive(): + return { + "accepted": False, + "reason": "scheduler_already_running", + "interval_seconds": self.auto_start_interval_seconds, + } + self._scheduler_stop_event.clear() + self._scheduler_thread = Thread( + target=self._run_auto_scheduler, + daemon=True, + name="crawler-auto-scheduler", + ) + self._scheduler_thread.start() + return { + "accepted": True, + "interval_seconds": self.auto_start_interval_seconds, + } + + def stop_auto_scheduler(self) -> dict[str, Any]: + """Request the periodic idle-runtime wakeup loop to stop. + + Returns: + Payload describing whether a scheduler stop was requested. + """ + self._scheduler_stop_event.set() + with self._lock: + running = self._scheduler_thread is not None and self._scheduler_thread.is_alive() + return {"accepted": running} + def stop(self) -> dict[str, Any]: """Request the background loop to stop at the next safe checkpoint. @@ -214,6 +255,21 @@ def _run_background_loop(self) -> None: self._finish_run_locked(result) self._stop_event.clear() + def _run_auto_scheduler(self) -> None: + """Periodically start the runtime when it is idle. + + Returns: + ``None``. The loop exits only when ``stop_auto_scheduler`` is + called or the process stops. + """ + while not self._scheduler_stop_event.is_set(): + with self._lock: + should_start = self._snapshot.runner_status in {"idle", "error"} + if should_start: + self.start() + if self._scheduler_stop_event.wait(self.auto_start_interval_seconds): + return + def _run_worker_pool(self, *, max_nodes: int | None) -> dict[str, Any]: """Run one worker-pool execution and aggregate the results. diff --git a/doc/config-reference.md b/doc/config-reference.md index 205c1e7..fb25226 100644 --- a/doc/config-reference.md +++ b/doc/config-reference.md @@ -64,6 +64,7 @@ Docker Compose 也会从仓库根目录的 `.env` 读取变量。 | `HEYBLOG_MAX_PATH_PROBES_PER_BLOG` | `50` | `crawler` | 单站点路径探测上限 | | `HEYBLOG_CANDIDATE_PAGE_FETCH_CONCURRENCY` | `4` | `crawler` | 友链候选页抓取并发度,最小为 `1` | | `HEYBLOG_RUNTIME_WORKER_COUNT` | `3` | `crawler` | runtime 持续抓取的 worker 数 | +| `HEYBLOG_RUNTIME_AUTO_START_INTERVAL_SECONDS` | `3600` | `crawler` | crawler 服务内置 idle 检测间隔;到点时若 runtime 不在工作则自动调用 runtime start | | `HEYBLOG_RAW_DISCOVERED_URL_LIMIT` | `1000000` | `crawler` | `raw_discovered_urls` 行数达到该值后拒绝启动 crawler,并让正在运行的 runtime 在下一次 claim 前自动停止;设为 `-1` 表示不限制 | | `HEYBLOG_MAX_FETCHED_PAGE_BYTES` | `2000000` | `crawler` | 单个页面允许读取的最大字节数;超限后当前 crawl attempt 记为 `FAILED` 并记录错误分类,超大页不会继续进入解析阶段;这不会撤销已接受博客的 `acceptance_status` | | `HEYBLOG_FRIEND_LINK_DOMAIN_BLOCKLIST` | 空 | `crawler` | 逗号分隔的域名黑名单 | diff --git a/docker-compose.yml b/docker-compose.yml index 6ebea3c..370e17e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,7 @@ services: HEYBLOG_MAX_PATH_PROBES_PER_BLOG: ${HEYBLOG_MAX_PATH_PROBES_PER_BLOG:-50} HEYBLOG_CANDIDATE_PAGE_FETCH_CONCURRENCY: ${HEYBLOG_CANDIDATE_PAGE_FETCH_CONCURRENCY:-4} HEYBLOG_RUNTIME_WORKER_COUNT: ${HEYBLOG_RUNTIME_WORKER_COUNT:-3} + HEYBLOG_RUNTIME_AUTO_START_INTERVAL_SECONDS: ${HEYBLOG_RUNTIME_AUTO_START_INTERVAL_SECONDS:-3600} HEYBLOG_RAW_DISCOVERED_URL_LIMIT: ${HEYBLOG_RAW_DISCOVERED_URL_LIMIT:-1000000} HEYBLOG_USER_AGENT: ${HEYBLOG_USER_AGENT:-HeyBlogBot/0.1 (+https://example.invalid/heyblog)} HEYBLOG_FRIEND_LINK_DOMAIN_BLOCKLIST: ${HEYBLOG_FRIEND_LINK_DOMAIN_BLOCKLIST:-} diff --git a/shared/config.py b/shared/config.py index 316734f..1ac7efa 100644 --- a/shared/config.py +++ b/shared/config.py @@ -14,6 +14,7 @@ DEFAULT_MAX_CANDIDATE_LINKS_PER_PAGE = 50 DEFAULT_CANDIDATE_PAGE_FETCH_CONCURRENCY = 4 DEFAULT_RUNTIME_WORKER_COUNT = 3 +DEFAULT_RUNTIME_AUTO_START_INTERVAL_SECONDS = 3600.0 DEFAULT_MAX_FETCHED_PAGE_BYTES = 2_000_000 DEFAULT_RAW_DISCOVERED_URL_LIMIT = 1_000_000 PROJECT_ROOT = Path(__file__).resolve().parent.parent @@ -105,6 +106,7 @@ class Settings: max_candidate_links_per_page: int = DEFAULT_MAX_CANDIDATE_LINKS_PER_PAGE candidate_page_fetch_concurrency: int = DEFAULT_CANDIDATE_PAGE_FETCH_CONCURRENCY runtime_worker_count: int = DEFAULT_RUNTIME_WORKER_COUNT + runtime_auto_start_interval_seconds: float = DEFAULT_RUNTIME_AUTO_START_INTERVAL_SECONDS max_fetched_page_bytes: int = DEFAULT_MAX_FETCHED_PAGE_BYTES raw_discovered_url_limit: int = DEFAULT_RAW_DISCOVERED_URL_LIMIT friend_link_domain_blocklist: tuple[str, ...] = () @@ -211,6 +213,15 @@ def from_env(cls) -> "Settings": ) ), ), + runtime_auto_start_interval_seconds=max( + 0.001, + float( + os.getenv( + "HEYBLOG_RUNTIME_AUTO_START_INTERVAL_SECONDS", + str(DEFAULT_RUNTIME_AUTO_START_INTERVAL_SECONDS), + ) + ), + ), max_fetched_page_bytes=max( 1, int( diff --git a/shared/http_clients/persistence_http.py b/shared/http_clients/persistence_http.py index 663b5c2..64ca354 100644 --- a/shared/http_clients/persistence_http.py +++ b/shared/http_clients/persistence_http.py @@ -743,6 +743,24 @@ def graph_snapshot(self, version: str) -> dict[str, Any]: def search_snapshot(self) -> dict[str, list[dict[str, Any]]]: return self._get("/internal/search-snapshot") + def list_blogs(self) -> list[dict[str, Any]]: + """Fetch all blog rows for graph export compatibility. + + Returns: + Blog payloads from the persistence service search snapshot. + """ + + return self.search_snapshot()["blogs"] + + def list_edges(self) -> list[dict[str, Any]]: + """Fetch all edge rows for graph export compatibility. + + Returns: + Edge payloads from the persistence service search snapshot. + """ + + return self.search_snapshot()["edges"] + def reset(self) -> dict[str, Any]: response = self.client.post("/internal/database/reset") response.raise_for_status() diff --git a/tests/test_crawler_service.py b/tests/test_crawler_service.py index eecdc7b..4bf2b22 100644 --- a/tests/test_crawler_service.py +++ b/tests/test_crawler_service.py @@ -46,6 +46,10 @@ def run_once(self, max_nodes: int | None = None) -> dict[str, object]: class StubRuntime: """Return fixed payloads for runtime endpoints.""" + def __init__(self) -> None: + self.scheduler_starts = 0 + self.scheduler_stops = 0 + def status(self) -> dict[str, object]: return { "runner_status": "idle", @@ -101,6 +105,14 @@ def current(self) -> dict[str, object]: ], } + def start_auto_scheduler(self) -> dict[str, object]: + self.scheduler_starts += 1 + return {"accepted": True, "interval_seconds": 3600.0} + + def stop_auto_scheduler(self) -> dict[str, object]: + self.scheduler_stops += 1 + return {"accepted": True} + def start(self) -> dict[str, object]: payload = self.status() payload["runner_status"] = "running" @@ -122,33 +134,35 @@ def run_batch(self, max_nodes: int) -> dict[str, object]: def test_crawler_service_routes_preserve_payload_shapes() -> None: """Crawler HTTP service should keep its public internal route contract stable.""" - app = create_app(CrawlerState(pipeline=StubPipeline(), runtime=StubRuntime())) - client = TestClient(app) - - assert client.get("/internal/health").json() == {"status": "ok"} - assert client.post("/internal/crawl/bootstrap").json() == {"seed_path": "seed.csv", "imported": 2} - assert client.post("/internal/crawl/run?max_nodes=5").json() == { - "processed": 5, - "discovered": 3, - "failed": 0, - "exports": {"graph_json": "graph.json"}, - } - status = client.get("/internal/runtime/status").json() - assert status["runner_status"] == "idle" - assert status["worker_count"] == 3 - current = client.get("/internal/runtime/current").json() - assert current["current_blog_id"] == 10 - assert current["current_worker_id"] == "worker-1" - assert current["active_workers"] == 1 - assert current["workers"][0]["worker_id"] == "worker-1" - assert client.post("/internal/runtime/start").json()["runner_status"] == "running" - assert client.post("/internal/runtime/stop").json()["runner_status"] == "stopping" - assert client.post("/internal/runtime/run-batch", json={"max_nodes": 4}).json()["result"] == { - "processed": 4, - "discovered": 1, - "failed": 0, - "exports": {}, - } + runtime = StubRuntime() + app = create_app(CrawlerState(pipeline=StubPipeline(), runtime=runtime)) + with TestClient(app) as client: + assert runtime.scheduler_starts == 1 + assert client.get("/internal/health").json() == {"status": "ok"} + assert client.post("/internal/crawl/bootstrap").json() == {"seed_path": "seed.csv", "imported": 2} + assert client.post("/internal/crawl/run?max_nodes=5").json() == { + "processed": 5, + "discovered": 3, + "failed": 0, + "exports": {"graph_json": "graph.json"}, + } + status = client.get("/internal/runtime/status").json() + assert status["runner_status"] == "idle" + assert status["worker_count"] == 3 + current = client.get("/internal/runtime/current").json() + assert current["current_blog_id"] == 10 + assert current["current_worker_id"] == "worker-1" + assert current["active_workers"] == 1 + assert current["workers"][0]["worker_id"] == "worker-1" + assert client.post("/internal/runtime/start").json()["runner_status"] == "running" + assert client.post("/internal/runtime/stop").json()["runner_status"] == "stopping" + assert client.post("/internal/runtime/run-batch", json={"max_nodes": 4}).json()["result"] == { + "processed": 4, + "discovered": 1, + "failed": 0, + "exports": {}, + } + assert runtime.scheduler_stops == 1 def test_services_crawler_main_remains_a_compatibility_shim() -> None: diff --git a/tests/test_runtime.py b/tests/test_runtime.py index fc4cd20..f1fd8bf 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -189,6 +189,33 @@ def write_exports(self) -> dict[str, object]: return {} +class IdleSchedulerPipeline: + """Pipeline stub that never has queued work but records start attempts.""" + + def __init__(self) -> None: + self.repository = QueueRepository([]) + self.capacity_gate = CrawlerCapacityGate(self.repository, raw_discovered_url_limit=-1) + self.export_calls = 0 + + def process_blog_row( + self, + row: dict[str, object], + *, + on_blog_start=None, + on_blog_finish=None, + on_blog_error=None, + ) -> dict[str, int]: + if on_blog_start is not None: + on_blog_start(row) + if on_blog_finish is not None: + on_blog_finish(row, {"discovered": 0}) + return {"processed": 1, "discovered": 0, "failed": 0} + + def write_exports(self) -> dict[str, object]: + self.export_calls += 1 + return {} + + def test_runtime_stop_waits_for_active_workers_to_finish_without_starting_more_blogs() -> None: """Stop should let the current worker set finish, then prevent any new blog from starting.""" pipeline = BlockingQueuePipeline([1, 2, 3, 4, 5, 6], target_active_runs=3) @@ -321,6 +348,92 @@ def test_runtime_rejects_start_when_raw_discovered_url_limit_is_reached() -> Non assert runtime.status()["runner_status"] == "idle" +def test_runtime_auto_scheduler_starts_idle_runtime() -> None: + """Hourly scheduler checks should wake an idle runtime by calling start.""" + pipeline = IdleSchedulerPipeline() + runtime = CrawlerRuntimeService( + pipeline, + worker_count=1, + auto_start_interval_seconds=0.01, + ) + + runtime.start_auto_scheduler() + try: + assert runtime._scheduler_thread is not None # noqa: SLF001 - test inspects scheduler thread. + assert runtime._scheduler_thread.is_alive() # noqa: SLF001 - test inspects scheduler thread. + assert pipeline.export_calls == 0 + + runtime._scheduler_stop_event.wait(0.05) # noqa: SLF001 - give the scheduler one tick window. + + assert pipeline.export_calls >= 1 + runtime.stop_auto_scheduler() + if runtime._scheduler_thread is not None: + runtime._scheduler_thread.join(timeout=2) + if runtime._thread is not None: + runtime._thread.join(timeout=2) + assert runtime.status()["runner_status"] == "idle" + finally: + runtime.stop_auto_scheduler() + if runtime._scheduler_thread is not None: + runtime._scheduler_thread.join(timeout=2) + + +def test_runtime_auto_scheduler_skips_busy_runtime() -> None: + """Scheduler checks should not restart a runtime that is already running.""" + pipeline = BlockingQueuePipeline([1], target_active_runs=1) + runtime = CrawlerRuntimeService( + pipeline, + worker_count=1, + auto_start_interval_seconds=0.01, + ) + + runtime.start() + assert pipeline.started.wait(timeout=1) + + scheduler_result = runtime.start_auto_scheduler() + assert scheduler_result["accepted"] is True + runtime._scheduler_stop_event.wait(0.03) # noqa: SLF001 - let the scheduler tick once. + + assert pipeline.run_calls == 1 + assert runtime.status()["runner_status"] in {"running", "stopping"} + + pipeline.release.set() + runtime._thread.join(timeout=2) # noqa: SLF001 - test waits for the background loop. + runtime.stop_auto_scheduler() + if runtime._scheduler_thread is not None: + runtime._scheduler_thread.join(timeout=2) + + +def test_runtime_auto_scheduler_retries_after_error_state() -> None: + """Scheduler checks should treat an errored runtime as not working.""" + pipeline = RecordingPipeline(QueueRepository([1, 2])) + runtime = CrawlerRuntimeService( + pipeline, + worker_count=1, + auto_start_interval_seconds=0.01, + ) + with runtime._lock: # noqa: SLF001 - test seeds a prior failed runtime state. + runtime._snapshot.runner_status = "error" + runtime._snapshot.last_error = "previous export failure" + + runtime.start_auto_scheduler() + try: + runtime._scheduler_stop_event.wait(0.05) # noqa: SLF001 - let the scheduler tick once. + + runtime.stop_auto_scheduler() + if runtime._scheduler_thread is not None: + runtime._scheduler_thread.join(timeout=2) + if runtime._thread is not None: + runtime._thread.join(timeout=2) + + assert pipeline.processed_ids == [1, 2] + assert runtime.status()["runner_status"] == "idle" + finally: + runtime.stop_auto_scheduler() + if runtime._scheduler_thread is not None: + runtime._scheduler_thread.join(timeout=2) + + def test_runtime_allows_start_when_raw_discovered_url_limit_is_disabled() -> None: """A -1 raw URL limit should disable crawler capacity gating.""" pipeline = BlockingQueuePipeline( diff --git a/tests/test_service_split.py b/tests/test_service_split.py index 10faa25..9ed72af 100644 --- a/tests/test_service_split.py +++ b/tests/test_service_split.py @@ -1115,6 +1115,42 @@ def test_settings_loads_candidate_link_page_limit(monkeypatch) -> None: assert settings.max_candidate_links_per_page == 17 +def test_settings_loads_runtime_auto_start_interval(monkeypatch) -> None: + """Environment loading should expose the crawler idle wakeup interval.""" + monkeypatch.setenv("HEYBLOG_RUNTIME_AUTO_START_INTERVAL_SECONDS", "42.5") + + settings = Settings.from_env() + + assert settings.runtime_auto_start_interval_seconds == 42.5 + + +def test_persistence_http_client_export_reads_use_search_snapshot() -> None: + """Crawler export compatibility reads should use the split persistence snapshot route.""" + seen_paths: list[str] = [] + + def handle_request(request: httpx.Request) -> httpx.Response: + seen_paths.append(request.url.path) + return httpx.Response( + 200, + request=request, + json={ + "blogs": [{"id": 1, "url": "https://blog.example.com/"}], + "edges": [{"id": 2, "from_blog_id": 1, "to_blog_id": 3}], + "logs": [], + }, + ) + + client = PersistenceHttpClient("http://persistence.test") + client.client = httpx.Client( + base_url="http://persistence.test", + transport=httpx.MockTransport(handle_request), + ) + + assert client.list_blogs() == [{"id": 1, "url": "https://blog.example.com/"}] + assert client.list_edges() == [{"id": 2, "from_blog_id": 1, "to_blog_id": 3}] + assert seen_paths == ["/internal/search-snapshot", "/internal/search-snapshot"] + + def test_settings_loads_smtp_email_delivery_configuration(monkeypatch) -> None: """Environment loading should expose SMTP lifecycle email settings.""" monkeypatch.setenv("HEYBLOG_EMAIL_PROVIDER", "smtp")