From 92eed19a5a4387c32502e152a70ab6911135e6eb Mon Sep 17 00:00:00 2001 From: KKKK Date: Tue, 10 Nov 2026 00:04:08 +0800 Subject: [PATCH 1/3] Connect official AI reports through the signed-in browser --- apps/browser-lab/server.mjs | 14 + apps/browser-lab/webmaster-reports.mjs | 254 ++++++++++++++++ apps/browser-lab/webmaster-reports.test.mjs | 138 +++++++++ .../src/components/ai-platform-reports.tsx | 272 +++--------------- .../src/components/ai-report-connections.tsx | 216 ++++++++++++++ apps/web/src/components/monitoring-start.tsx | 2 +- apps/web/src/components/trend-chart.tsx | 10 +- apps/web/src/lib/i18n/zh.json | 47 ++- .../__tests__/ai-report-connections.test.ts | 110 +++++++ apps/web/src/server/ai-report-connections.ts | 200 +++++++++++++ docs/beat-geo/ai-platform-reports.md | 18 +- packages/lib/src/ai-reports.test.ts | 48 +++- packages/lib/src/ai-reports.ts | 53 ++++ packages/lib/src/db/schema.ts | 6 +- scripts/start-local.mjs | 15 +- 15 files changed, 1154 insertions(+), 249 deletions(-) create mode 100644 apps/browser-lab/webmaster-reports.mjs create mode 100644 apps/browser-lab/webmaster-reports.test.mjs create mode 100644 apps/web/src/components/ai-report-connections.tsx create mode 100644 apps/web/src/server/__tests__/ai-report-connections.test.ts create mode 100644 apps/web/src/server/ai-report-connections.ts diff --git a/apps/browser-lab/server.mjs b/apps/browser-lab/server.mjs index 5f1c5ce..03cec3e 100644 --- a/apps/browser-lab/server.mjs +++ b/apps/browser-lab/server.mjs @@ -14,6 +14,7 @@ import { summarize, validateInput, } from "./core.mjs"; +import { readWebmasterReport } from "./webmaster-reports.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const directory = defaultRunDirectory(); @@ -134,6 +135,19 @@ async function handle(request, response) { const url = new URL(request.url, `http://127.0.0.1:${port}`); if (request.method === "GET") return handleRead(url, request, response); if (request.method === "POST" && url.pathname === "/api/browser/profile") return selectProfile(request, response); + if (request.method === "POST" && url.pathname === "/api/reports") { + if (!authorized(request)) return json(response, 403, { error: "Invalid local runner token" }); + const input = await bodyOf(request); + if (!["connect", "sync"].includes(input.action)) return json(response, 400, { error: "Invalid report action" }); + if (active) + return json(response, 409, { error: "The browser is busy. Retry after the current operation finishes." }); + active = { status: "reading-report" }; + try { + return json(response, 200, await readWebmasterReport(input)); + } finally { + active = null; + } + } if (request.method !== "POST" || !["/api/run", "/api/collect"].includes(url.pathname)) return json(response, 404, { error: "Not found" }); return handleCollection(request, response, url); diff --git a/apps/browser-lab/webmaster-reports.mjs b/apps/browser-lab/webmaster-reports.mjs new file mode 100644 index 0000000..5289221 --- /dev/null +++ b/apps/browser-lab/webmaster-reports.mjs @@ -0,0 +1,254 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import { createBrowser, parseRows } from "./core.mjs"; + +export function reportUrl(source, property) { + if (!["bing", "google"].includes(source)) throw new Error("Unsupported report source."); + if (typeof property !== "string" || property.length > 2048) throw new Error("Invalid website."); + const domain = property.startsWith("sc-domain:"); + const parsed = new URL(domain ? `https://${property.slice(10)}` : property); + if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.port) + throw new Error("Invalid website."); + if (source === "bing" && domain) throw new Error("Bing requires a website URL."); + const url = new URL( + source === "bing" + ? "https://www.bing.com/webmasters/aiperformance" + : "https://search.google.com/search-console/performance/search-analytics/ai", + ); + url.searchParams.set(source === "bing" ? "siteUrl" : "resource_id", property); + return url.href; +} + +// Read only responses generated by the official report page. No cookies, +// session tokens or undocumented request payloads are replayed or persisted. +export function responseJson(text) { + const start = text.indexOf("\n{"); + if (start < 0) throw new Error("Report response unavailable."); + const body = text + .slice(start + 1) + .split("\n")[0] + .trim(); + return JSON.parse(body); +} + +export function googleFrames(text) { + return text + .split("\n") + .filter((line) => line.startsWith('[["wrb.fr"')) + .flatMap((line) => JSON.parse(line)) + .filter((frame) => frame[0] === "wrb.fr" && typeof frame[2] === "string") + .map((frame) => ({ rpc: frame[1], data: JSON.parse(frame[2]) })); +} + +function count(value) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error("Invalid report count."); + return value; +} +function dates(rows) { + if (!rows.length) throw new Error("The report has no dated observations yet."); + const sorted = rows.map((row) => row.key).sort(); + return { startDate: sorted[0], endDate: sorted.at(-1) }; +} + +export function parseBingHistory(data, property) { + if (data.SiteUrl !== property || !Array.isArray(data.CitationStats)) throw new Error("Report website mismatch."); + const rows = data.CitationStats.map((row) => ({ key: row.Date.slice(0, 10), value: count(row.Citations) })); + return { ...dates(rows), rows, total: count(data.TotalCitations) }; +} + +export function parseGoogleReport(text, property) { + const frames = googleFrames(text).filter(({ data }) => data?.[0]?.[0] === property); + const history = frames.findLast( + ({ rpc, data }) => + rpc === "OLiH4d" && + data?.[6]?.[2]?.length === 1 && + data[6][2][0][0] === "IMPRESSIONS" && + data?.[1]?.[0]?.length > 1, + )?.data; + const pages = frames.findLast( + ({ rpc, data }) => rpc === "nDAfwb" && data?.[1]?.[1]?.[0]?.[9] === "PAGES" && data[1][1][1]?.[9] === "IMPRESSIONS", + )?.data; + if (!history || !pages) throw new Error("AI report unavailable or its format has changed."); + // These observed request descriptors distinguish the AI report and its filters. + if ( + history[0][5]?.[15] !== 4 || + pages[0][5]?.[15] !== 4 || + JSON.stringify(history[0][5][2]) !== "[[null,null,null,3]]" || + JSON.stringify(history[0][5][3]) !== '[[6,["WEB"]]]' || + JSON.stringify(history[0][5].slice(1)) !== JSON.stringify(pages[0][5].slice(1)) + ) + throw new Error("AI report filters could not be verified."); + const daily = history[1][0].map((row) => { + if (!Number.isFinite(row[0]) || row[1]?.length !== 1) throw new Error("Invalid daily report."); + return { key: new Date(row[0]).toISOString().slice(0, 10), value: count(row[1][0]) }; + }); + const pageRows = pages[1][0].map((row) => { + const cells = row?.[0]; + if (typeof cells?.[0]?.[40] !== "string" || cells?.[1]?.[8] !== 6) + throw new Error("AI page report format changed."); + return { key: cells[0][40], value: count(cells[1][1]) }; + }); + return { ...dates(daily), daily, pages: pageRows, total: count(history[1][1][1][0]) }; +} + +async function waitFor(read, delay, accept = () => true) { + for (let attempt = 0; attempt < 12; attempt++) { + try { + const value = await read(); + if (accept(value)) return value; + } catch { + /* Page is still loading. */ + } + await delay(1500); + } + throw new Error( + "The AI report could not be read. Check website access, sign-in and report availability, then retry.", + ); +} + +function appendBingBatch(rows, batch, name, key, total) { + if (!Number.isSafeInteger(batch.TotalNumOfRecords) || batch.TotalNumOfRecords < 0) + throw new Error("Report pagination could not be verified."); + if (total !== undefined && total !== batch.TotalNumOfRecords) + throw new Error("Report changed during synchronization. Retry."); + for (const row of batch[name]) { + if (typeof row[key] !== "string" || rows.has(row[key])) throw new Error("Duplicate report page. Retry."); + rows.set(row[key], { key: row[key], value: count(row.Citations) }); + } + return batch.TotalNumOfRecords; +} + +async function bingTable(call, name, property, delay) { + const endpoint = `aiperformance/${name === "Pages" ? "pages" : "searchqueries"}/stats`; + const key = name === "Pages" ? "PageUrl" : "GroundingQuery"; + await call("snapshot", {}); + const tabs = parseRows(await call("query", { selector: '[role="tab"]', extract: { text: ".@text", id: ".@id" } })); + const tab = tabs.find((item) => item.text === (name === "Pages" ? "Pages" : "Grounding Queries")); + if (!tab?.id) throw new Error("AI report tab unavailable."); + await call("click", { selector: `[id=${JSON.stringify(tab.id)}]` }); + const rows = new Map(); + let total; + for (let page = 0; page < 80; page++) { + const batch = await waitFor( + async () => responseJson(await call("network", { body: endpoint, maxBody: 2_000_000 })), + delay, + (data) => + data.SiteUrl === property && + Array.isArray(data[name]) && + (data[name].length === 0 || !rows.has(data[name][0][key])), + ); + total = appendBingBatch(rows, batch, name, key, total); + if (rows.size === total) return [...rows.values()]; + if (rows.size > total || total > 2000 || !batch[name].length) + throw new Error("Report exceeds the supported row limit."); + await call("snapshot", {}); + await call("click", { selector: 'button[aria-label="Go to next Page"]' }); + } + throw new Error("Report pagination did not finish. No partial report was saved."); +} + +export async function readWebmasterReport(input, options = {}) { + const url = reportUrl(input.source, input.property); + const browser = options.browser || createBrowser(); + const delay = options.delay || sleep; + const openingUrl = + input.source === "google" + ? `https://search.google.com/search-console?resource_id=${encodeURIComponent(input.property)}` + : url; + const space = JSON.parse( + await browser("spaces", { + action: "open", + url: openingUrl, + label: `BeatGEO · ${input.source} AI reports`, + leaseMs: 600_000, + profileId: input.profileId || process.env.BEAT_BROWSER_PROFILE_ID || undefined, + }), + ); + if (!space.spaceId || !Number.isInteger(space.tabId) || space.state !== "running") + throw new Error("Connect your Chrome first."); + const call = (tool, params) => browser(tool, { ...params, spaceId: space.spaceId, tabId: space.tabId }); + let synced = false; + try { + let history; + if (input.source === "bing") { + history = await waitFor( + async () => + parseBingHistory( + responseJson( + await call("network", { + body: "aiperformance/citationstats", + maxBody: 2_000_000, + }), + ), + input.property, + ), + delay, + ); + if (input.action === "connect") return { ready: true, property: input.property, url }; + const queries = await bingTable(call, "Queries", input.property, delay); + const pages = await bingTable(call, "Pages", input.property, delay); + synced = true; + return { + ready: true, + property: input.property, + url, + ...dates(history.rows), + total: history.total, + tables: [ + { dimension: "date", rows: history.rows }, + { dimension: "page", rows: pages }, + { dimension: "query", rows: queries }, + ], + coverage: "All rows returned by the official report; platform sampling applies.", + }; + } + const sectionSelector = '[aria-label="“效果”报告部分"], [aria-label="Performance report section"]'; + const sections = await waitFor( + async () => + parseRows(await call("query", { selector: sectionSelector, extract: { expanded: ".@aria-expanded" } })), + delay, + (rows) => rows.length > 0, + ); + if (sections[0].expanded === "false") { + await call("snapshot", {}); + await call("click", { selector: sectionSelector }); + } + await waitFor( + async () => + parseRows( + await call("query", { selector: 'a[href*="/performance/search-analytics/ai"]', extract: { href: ".@href" } }), + ), + delay, + (rows) => rows.length > 0, + ); + await call("snapshot", {}); + await call("click", { selector: 'a[href*="/performance/search-analytics/ai"]' }); + history = await waitFor( + async () => + parseGoogleReport( + await call("network", { + body: "rpcids=OLiH4d", + maxBody: 2_000_000, + }), + input.property, + ), + delay, + ); + if (input.action === "connect") return { ready: true, property: input.property, url }; + synced = true; + return { + ready: true, + property: input.property, + url, + startDate: history.startDate, + endDate: history.endDate, + total: history.total, + tables: [ + { dimension: "date", rows: history.daily }, + { dimension: "page", rows: history.pages }, + ], + coverage: "Rows returned by the official AI report; page results may be limited by Google.", + }; + } finally { + await browser("spaces", { action: "finish", spaceId: space.spaceId, keep: synced ? [] : "all" }).catch(() => {}); + } +} diff --git a/apps/browser-lab/webmaster-reports.test.mjs b/apps/browser-lab/webmaster-reports.test.mjs new file mode 100644 index 0000000..8bd1c07 --- /dev/null +++ b/apps/browser-lab/webmaster-reports.test.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + parseBingHistory, + parseGoogleReport, + readWebmasterReport, + reportUrl, + responseJson, +} from "./webmaster-reports.mjs"; + +function googleFixture({ property = "sc-domain:example.com", metric = "IMPRESSIONS", value = 7, feature = 4 } = {}) { + const descriptor = (dimension) => [ + [dimension], + null, + [[null, null, null, 3]], + [[6, ["WEB"]]], + null, + null, + null, + null, + null, + null, + true, + null, + null, + 2, + null, + feature, + ]; + const history = [ + [property, 27, null, null, null, descriptor(1)], + [ + [ + [Date.UTC(2026, 9, 1), [value]], + [Date.UTC(2026, 9, 2), [3]], + ], + [null, [10]], + ], + 1, + [0], + null, + null, + [null, null, [[metric]]], + ]; + const key = Array(41).fill(null); + key[40] = "https://docs.example.com/a"; + const column = (name) => { + const c = Array(10).fill(null); + c[9] = name; + return c; + }; + const pages = [ + [property, 32, null, null, null, descriptor(3)], + [[[[key, [null, 10, null, null, null, null, null, null, 6]]]], [column("PAGES"), column(metric)]], + ]; + return `200 report\n\n)]}'\n\n${JSON.stringify([ + ["wrb.fr", "OLiH4d", JSON.stringify(history)], + ["wrb.fr", "nDAfwb", JSON.stringify(pages)], + ])}\n`; +} + +test("reads exact AI impressions and dates from matching official report frames", () => { + const result = parseGoogleReport(googleFixture(), "sc-domain:example.com"); + assert.deepEqual(result.daily, [ + { key: "2026-10-01", value: 7 }, + { key: "2026-10-02", value: 3 }, + ]); + assert.deepEqual(result.pages, [{ key: "https://docs.example.com/a", value: 10 }]); + assert.equal(result.total, 10); +}); +test("rejects other properties, ordinary search reports and changed metric contracts", () => { + for (const options of [{ property: "sc-domain:other.test" }, { metric: "CLICKS" }, { feature: 1 }, { value: -1 }]) + assert.throws(() => parseGoogleReport(googleFixture(options), "sc-domain:example.com")); +}); +test("Bing uses exact numeric counts and verifies the returned website", () => { + const data = { + SiteUrl: "https://example.com/", + CitationStats: [{ Date: "2026-10-01T00:00:00", Citations: 1234 }], + TotalCitations: 1234, + }; + assert.equal(parseBingHistory(data, data.SiteUrl).rows[0].value, 1234); + assert.throws(() => parseBingHistory(data, "https://other.test/")); + assert.throws(() => parseBingHistory({ ...data, TotalCitations: "1.2K" }, data.SiteUrl)); + assert.deepEqual(responseJson(`200 /report\n\n${JSON.stringify(data)}\n`), data); +}); +test("website selection cannot redirect the reader to an arbitrary host", () => { + assert.equal(new URL(reportUrl("google", "sc-domain:example.com")).hostname, "search.google.com"); + assert.throws(() => reportUrl("bing", "https://user:password@example.com/")); + assert.throws(() => reportUrl("other", "https://example.com/")); +}); +test("Bing follows every report page before returning a snapshot", async () => { + let page = 0, + table = "Queries"; + const site = "https://example.com/"; + const handlers = { + spaces: (args) => JSON.stringify(args.action === "open" ? { spaceId: "s", tabId: 1, state: "running" } : {}), + snapshot: () => "AI Performance", + query: () => + JSON.stringify([ + { id: "q", text: "Grounding Queries" }, + { id: "p", text: "Pages" }, + ]), + click: (args) => { + if (args.selector.includes('id="p"')) { + table = "Pages"; + page = 0; + } else if (args.selector.includes("next Page")) page++; + return "Clicked"; + }, + network: (args) => { + if (args.body.includes("citationstats")) + return `200\n\n${JSON.stringify({ SiteUrl: site, CitationStats: [{ Date: "2026-10-01", Citations: 9 }], TotalCitations: 9 })}`; + const key = table === "Pages" ? "PageUrl" : "GroundingQuery"; + return `200\n\n${JSON.stringify({ SiteUrl: site, TotalNumOfRecords: 2, [table]: [{ [key]: table === "Pages" ? `${site}${page}` : `query ${page}`, Citations: 4 }] })}`; + }, + }; + const browser = async (tool, args) => handlers[tool](args); + const result = await readWebmasterReport( + { source: "bing", property: site, action: "sync" }, + { browser, delay: async () => {} }, + ); + assert.equal(result.tables.find((t) => t.dimension === "page").rows.length, 2); + assert.equal(result.tables.find((t) => t.dimension === "query").rows.length, 2); +}); +test("a missing report never returns a successful empty snapshot", async () => { + const browser = async (tool, args) => + tool === "spaces" + ? JSON.stringify(args.action === "open" ? { spaceId: "s", tabId: 1, state: "running" } : {}) + : "Sign in"; + await assert.rejects( + () => + readWebmasterReport( + { source: "bing", property: "https://example.com/", action: "sync" }, + { browser, delay: async () => {} }, + ), + /could not be read/, + ); +}); diff --git a/apps/web/src/components/ai-platform-reports.tsx b/apps/web/src/components/ai-platform-reports.tsx index ded5f06..7950de6 100644 --- a/apps/web/src/components/ai-platform-reports.tsx +++ b/apps/web/src/components/ai-platform-reports.tsx @@ -1,18 +1,14 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - type AiReport, - type AiReportDimension, - type AiReportInput, - type AiReportSource, - compareAiReports, - parseAiReport, -} from "@workspace/lib/ai-reports"; +import { type AiReport, compareAiReports } from "@workspace/lib/ai-reports"; import { Button } from "@workspace/ui/components/button"; import { Input } from "@workspace/ui/components/input"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useBrandId } from "@/hooks/use-brand-id"; import { useT } from "@/lib/i18n"; -import { deleteAiReportFn, importAiReportFn, listAiReportsFn } from "@/server/ai-reports"; +import { deleteAiReportFn, listAiReportsFn } from "@/server/ai-reports"; + +import { AiReportConnections } from "./ai-report-connections"; +import { TrendChart } from "./trend-chart"; const fieldClass = "h-10 w-full min-w-0 rounded-md border bg-background px-3 text-sm"; export function AiPlatformReports() { @@ -24,17 +20,7 @@ function ReportWorkspace({ brandId }: { brandId: string }) { client = useQueryClient(); const queryKey = ["ai-platform-reports", brandId]; const query = useQuery({ queryKey, queryFn: () => listAiReportsFn({ data: { brandId } }) }); - const [input, setInput] = useState({ - source: "bing", - dimension: "page", - site: "", - startDate: "", - endDate: "", - scope: "", - content: "", - }); - const [confirmed, setConfirmed] = useState(false), - [busy, setBusy] = useState(false), + const [busy, setBusy] = useState(false), [message, setMessage] = useState(""); const [selected, setSelected] = useState(""), [previous, setPrevious] = useState(""), @@ -42,84 +28,15 @@ function ReportWorkspace({ brandId }: { brandId: string }) { [offset, setOffset] = useState(0); const reports = query.data ?? []; const { report, comparison, rows } = reportView(reports, selected, previous, filter); - const preview = useMemo(() => { - if (!input.content) return { data: null, error: "" }; - try { - return { data: parseAiReport(input), error: "" }; - } catch (error) { - return { data: null, error: error instanceof Error ? error.message : "Invalid report." }; - } - }, [input]); - function update(patch: Partial) { - setInput((v) => ({ ...v, ...patch })); - setConfirmed(false); - setMessage(""); - } - async function save() { - if (!preview.data || !confirmed || busy) return; - setBusy(true); - setMessage(""); - try { - const result = await importAiReportFn({ data: { ...input, brandId } }); - await client.invalidateQueries({ queryKey }); - setSelected(result.id); - setPrevious(""); - setOffset(0); - setMessage(result.duplicate ? "This report is already saved; no counts were added." : "AI report saved."); - } catch (error) { - setMessage(error instanceof Error ? error.message : "Could not save AI report."); - } finally { - setBusy(false); - } - } return (

{t("AI platform reports")}

- {t( - "Import platform observations separately from your monitored questions. Imports do not enable automatic synchronization.", - )} + {t("Connect your website accounts and sync AI visibility directly. No files to download or upload.")}

-
- {(["bing", "google"] as const).map((source) => { - const latest = reports.find((r) => r.source === source); - return ( -
-

- {source === "bing" ? "Bing AI Performance" : "Google Search Console · AI"} -

-

- {t( - source === "bing" - ? "Citations · Microsoft and supported partners" - : "Impressions · Google AI Overviews and AI Mode", - )} -

-

- {query.isPending - ? t("Loading…") - : query.isError - ? t("Could not load AI reports.") - : latest - ? `${latest.startDate} – ${latest.endDate} · ${latest.site}` - : t("No AI report imported")} -

- - {t("Open official dashboard")} - -
- ); - })} -
+ client.invalidateQueries({ queryKey })} /> {query.isError && (

{t("Could not load AI reports.")}{" "} @@ -128,141 +45,31 @@ function ReportWorkspace({ brandId }: { brandId: string }) {

)} -
- {t("Import AI report CSV")} -

- {t( - "Export one table from the AI report, then provide its website, dates and filters. Supported: page, daily history, and Bing grounding queries. Use exact counts, not Topic Portfolio opportunity ranges. CSV only; extract ZIP exports first. Maximum 2 MB / 2,000 rows.", - )} -

-
- - - - - - - -
- {preview.error && ( -

- {t(preview.error)} -

- )} - {preview.data && ( -
-

- {t("Import preview")} · {preview.data.rows.length} {t("rows")} ·{" "} - {t(input.source === "bing" ? "Citations" : "Impressions")} -

-
- - - {preview.data.rows.slice(0, 5).map((r) => ( - - - - - ))} - -
{r.key}{r.value ?? "—"}
-
- -
- )} - -
{message && (

{t(message)}

)} + {!query.isPending && !query.isError && !report && ( +

+ {t("No AI data synchronized yet. Connect an account above, then choose Sync now.")} +

+ )} {report && (

{t("Saved AI observations")}

+
+
+

+ {t("Official report total")} · {t(report.source === "bing" ? "Citations" : "Impressions")} +

+

{report.total?.toLocaleString() ?? "—"}

+
+
+

{t("Returned observations")}

+

{report.rows.length.toLocaleString()}

+
+

- {report.site} · {report.startDate} – {report.endDate} · {report.scope} + {report.site} · {report.startDate} – {report.endDate} · {t(report.scope)}
- {t("Imported at")}: {new Date(report.importedAt).toLocaleString()} + {report.coverage && ( + <> + {t(report.coverage)} +
+ + )} + {t("Saved at")}: {new Date(report.importedAt).toLocaleString()}

{t( "Compare only equal-length, non-overlapping periods from the same source, dimension, website and filters. Missing rows are unknown, not zero. Daily reports remain separate observations. Row sums are not site totals; different platforms are never added together.", )}

+ {report.dimension === "date" && report.rows.length > 0 && ( + ({ date: row.key, value: row.value }))} + label={t(report.source === "bing" ? "Citations" : "Impressions")} + color="var(--primary)" + className="aspect-auto h-64 w-full" + unit="count" + /> + )} { - if (!window.confirm(t("Remove this imported report? The original file is unchanged."))) return; + if (!window.confirm(t("Remove this saved snapshot? The official report is unchanged."))) return; setBusy(true); try { await deleteAiReportFn({ data: { brandId, id: report.id } }); @@ -384,7 +206,7 @@ function ReportWorkspace({ brandId }: { brandId: string }) { } }} > - {t("Remove import")} + {t("Remove snapshot")}
diff --git a/apps/web/src/components/ai-report-connections.tsx b/apps/web/src/components/ai-report-connections.tsx new file mode 100644 index 0000000..71b6459 --- /dev/null +++ b/apps/web/src/components/ai-report-connections.tsx @@ -0,0 +1,216 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import type { AiReportSource } from "@workspace/lib/ai-reports"; +import { Button } from "@workspace/ui/components/button"; +import { useState } from "react"; +import { useBrandParams } from "@/hooks/use-route-params"; +import { useT } from "@/lib/i18n"; +import { disconnectAiConnectionFn, getAiConnectionsFn, readAiConnectionFn } from "@/server/ai-report-connections"; + +export function AiReportConnections({ brandId, onSynced }: { brandId: string; onSynced: () => Promise }) { + const params = useBrandParams(); + const t = useT(), + client = useQueryClient(); + const queryKey = ["ai-report-connections", brandId]; + const query = useQuery({ queryKey, queryFn: () => getAiConnectionsFn({ data: { brandId } }) }); + const [busy, setBusy] = useState(null), + [message, setMessage] = useState(""); + const [selected, setSelected] = useState>({}); + async function run(source: AiReportSource, property: string, action: "connect" | "sync" | "disconnect") { + setBusy(`${source}:${action}`); + setMessage(""); + try { + if (action === "disconnect") { + await disconnectAiConnectionFn({ data: { brandId, source } }); + setMessage("Connection removed. Saved reports are kept."); + } else { + const result = await readAiConnectionFn({ data: { brandId, source, property, action } }); + setMessage( + result.ok + ? action === "connect" + ? "Report access verified. Ready to sync." + : "AI report synchronized." + : result.error, + ); + } + await Promise.all([client.invalidateQueries({ queryKey }), onSynced()]); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Could not sync AI report."); + } finally { + setBusy(null); + } + } + if (query.isPending) return

{t("Loading…")}

; + if (query.isError) + return ( +
+ {t("Could not load connections.")} +
+ ); + return ( +
+
+

{t("Connect → Choose website → Sync AI data")}

+

+ {t( + "AI reports are read from your signed-in Chrome through BeatBrowser. Keep Chrome and BeatGEO open. Connecting does not start question monitoring.", + )} +

+

+ {t( + "Google search performance continues to use your existing Google authorization. AI report access is checked separately.", + )} +

+ + {t("Choose Chrome workspace")} + +
+
+ {(["bing", "google"] as const).map((source) => ( + c.source === source)} + selected={selected[source]} + onSelect={(value) => setSelected((v) => ({ ...v, [source]: value }))} + busy={busy} + run={run} + /> + ))} +
+ {message && ( +

+ {t(message)} +

+ )} +
+ ); +} + +function statusLabel(ready: boolean, state: string) { + if (ready) return "Access verified"; + return state === "needs-attention" ? "Connection needs attention" : "Not connected"; +} +function connectLabel(source: AiReportSource, busy: string | null, ready: boolean) { + if (busy === `${source}:connect`) return "Checking connection…"; + if (ready) return "Check connection"; + return source === "bing" ? "Connect Bing" : "Connect Google AI"; +} +function connectionFields(stored?: Record) { + const text = (key: string) => (typeof stored?.[key] === "string" ? (stored[key] as string) : ""); + return { + property: text("property"), + state: text("state"), + error: text("error"), + lastSyncedAt: text("lastSyncedAt"), + checkedAt: text("checkedAt"), + }; +} +function SourceCard({ + source, + websites, + stored, + selected, + onSelect, + busy, + run, +}: { + source: AiReportSource; + websites: string[]; + stored?: Record; + selected?: string; + onSelect: (value: string) => void; + busy: string | null; + run: (source: AiReportSource, property: string, action: "connect" | "sync" | "disconnect") => Promise; +}) { + const t = useT(); + const connection = connectionFields(stored); + const properties = reportProperties(source, websites); + const saved = properties.includes(connection.property) ? connection.property : ""; + const property = selected ?? (saved || properties[0] || ""); + const ready = connection.state === "ready" && saved === property; + const official = + source === "bing" + ? `https://www.bing.com/webmasters/aiperformance?siteUrl=${encodeURIComponent(property)}` + : `https://search.google.com/search-console/performance/search-analytics/ai?resource_id=${encodeURIComponent(property)}`; + return ( +
+
+

{source === "bing" ? "Bing Webmaster Tools" : "Google Search Console"}

+

+ {t(source === "bing" ? "AI citations · Pages · Grounding queries" : "AI impressions · Pages · Daily history")} +

+
+ +

{t(statusLabel(ready, connection.state))}

+ {connection.error && ( +

+ {t(connection.error)} +

+ )} + {connection.lastSyncedAt && ( +

+ {t("Last synchronized")}: {new Date(connection.lastSyncedAt).toLocaleString()} +

+ )} + {connection.checkedAt && ( +

+ {t("Access last checked")}: {new Date(connection.checkedAt).toLocaleString()} +

+ )} +
+ + + {connection.state && ( + + )} +
+ + {t("Open official dashboard / sign in")} + +

+ {t( + "Reads the official default three-month report. Actual dates and available rows are saved with each snapshot. Sync runs when you click; no background schedule is enabled.", + )} +

+
+ ); +} + +function reportProperties(source: AiReportSource, websites: string[]) { + return source === "bing" + ? websites + : [...new Set(websites.flatMap((site) => [`sc-domain:${new URL(site).hostname}`, site]))]; +} diff --git a/apps/web/src/components/monitoring-start.tsx b/apps/web/src/components/monitoring-start.tsx index 709e356..d22e5dd 100644 --- a/apps/web/src/components/monitoring-start.tsx +++ b/apps/web/src/components/monitoring-start.tsx @@ -38,7 +38,7 @@ export function MonitoringStart() { search={{ view: "reports" }} className="text-sm underline" > - {t("Import Bing or Google AI reports")} + {t("Connect Bing or Google AI reports")} ); diff --git a/apps/web/src/components/trend-chart.tsx b/apps/web/src/components/trend-chart.tsx index 10908d1..5a0c425 100644 --- a/apps/web/src/components/trend-chart.tsx +++ b/apps/web/src/components/trend-chart.tsx @@ -26,15 +26,18 @@ export function TrendChart({ label, color, className = "aspect-auto h-full w-full", + unit = "percent", }: { data: TrendPoint[]; label: string; color: string; className?: string; + unit?: "percent" | "count"; }) { const { locale } = useLocale(); const dateLocale = locale === "zh" ? "zh-CN" : "en-US"; const config = { value: { label, color } } satisfies ChartConfig; + const formatValue = (value: number) => (unit === "percent" ? `${value}%` : value.toLocaleString(dateLocale)); const observedCount = data.filter((point) => point.value != null).length; const observedIndex = data.findIndex((point) => point.value != null); @@ -60,7 +63,8 @@ export function TrendChart({ tickMargin={8} tickCount={4} tick={{ fontSize: 11 }} - tickFormatter={(value: number) => `${value}%`} + tickFormatter={formatValue} + allowDecimals={unit === "percent"} />
{label} - {value}% + {formatValue(value)}
); @@ -93,7 +97,7 @@ export function TrendChart({ strokeWidth={2} fill={color} fillOpacity={0.18} - connectNulls + connectNulls={unit === "percent"} // A lone observation has no line to sit on, so it would otherwise not be drawn at all. dot={({ cx, cy, index }: { cx?: number; cy?: number; index?: number }) => observedCount === 1 && index === observedIndex && cx != null && cy != null ? ( diff --git a/apps/web/src/lib/i18n/zh.json b/apps/web/src/lib/i18n/zh.json index ade9c3f..b244d4f 100644 --- a/apps/web/src/lib/i18n/zh.json +++ b/apps/web/src/lib/i18n/zh.json @@ -877,7 +877,7 @@ "Google authorization could not be completed. Connect again.": "Google 授权未能完成,请重新连接。", "Google authorization was cancelled. No connection was changed.": "已取消 Google 授权,连接未作修改。", "Log input limit is 10 MiB.": "日志大小上限为 10 MiB。", - "This connection is busy. Try again shortly.": "连接正在处理,请稍后重试。", + "This connection is busy. Try again shortly.": "该连接正在处理其他请求,请稍后重试。", "Connect Google Analytics first.": "请先连接 Google Analytics。", "Choose a GA property first.": "请先选择 GA 属性。", "This Google account cannot read the selected property.": "该 Google 账号无法读取所选属性。", @@ -947,7 +947,7 @@ "Open workspace": "进入工作区", "Optional. Connect Google to add your site's search performance and traffic data.": "可选。连接 Google,查看这个网站的搜索表现与访问数据。", "Aliases and advanced settings": "别名与高级设置", - "Not connected": "未连接", + "Not connected": "尚未连接", "Manage monitored questions": "管理监控问题", "Choose AI platforms": "选择 AI 平台", "Connected": "已连接", @@ -1361,5 +1361,46 @@ "Add an entry to SCRAPE_TARGETS in .env and restart pnpm local:start, for example": "在 .env 的 SCRAPE_TARGETS 中添加一项并重启 pnpm local:start,例如", "Platform setup guide": "平台配置说明", "Available": "可用", - "Optimize": "优化" + "Optimize": "优化", + "Connect your website accounts and sync AI visibility directly. No files to download or upload.": "连接网站账号,直接同步 AI 展示与引用数据,无需下载或上传文件。", + "Connect → Choose website → Sync AI data": "连接账号 → 选择网站 → 同步 AI 数据", + "AI reports are read from your signed-in Chrome through BeatBrowser. Keep Chrome and BeatGEO open. Connecting does not start question monitoring.": "通过 BeatBrowser 读取已登录 Chrome 中的官方 AI 报表。同步时请保持 Chrome 和 BeatGEO 运行。连接账号不会启动问题监测。", + "Google search performance continues to use your existing Google authorization. AI report access is checked separately.": "Google 普通搜索表现继续使用已有授权;AI 报表将单独检查访问权限。", + "AI citations · Pages · Grounding queries": "AI 引用次数 · 页面 · 检索查询", + "AI impressions · Pages · Daily history": "AI 展示次数 · 页面 · 每日趋势", + "Domain property": "域名资源(含子域名)", + "Website property": "网址资源", + "Access verified": "已验证报表访问权限", + "Connection needs attention": "连接需要处理", + "Access last checked": "上次检查", + "Last synchronized": "最近同步", + "Checking connection…": "正在检查连接…", + "Connect Bing": "连接必应", + "Connect Google AI": "连接 Google AI", + "Syncing report…": "正在读取报表…", + "Sync now": "立即同步", + "Open official dashboard / sign in": "打开官方后台 / 登录账号", + "Reads the official default three-month report. Actual dates and available rows are saved with each snapshot. Sync runs when you click; no background schedule is enabled.": "读取官方默认近三个月报表,保存实际日期和可用数据范围。点击后执行同步,暂未开启后台定时任务。", + "Connection removed. Saved reports are kept.": "连接已移除,历史报表仍保留。", + "Report access verified. Ready to sync.": "报表访问权限已验证,可以同步。", + "AI report synchronized.": "AI 报表已同步。", + "Could not sync AI report.": "AI 报表同步失败,上次成功数据仍保留。", + "Could not load connections.": "无法加载连接状态。", + "The AI report could not be read. Check website access, sign-in and report availability, then retry.": "暂时无法读取 AI 报表。请打开官方后台,检查登录、网站权限及 AI 报表是否可用,然后重试。", + "Restart BeatGEO to enable report connections.": "请重启 BeatGEO 以启用报表连接。", + "The browser is busy. Retry after the current operation finishes.": "浏览器正在执行其他任务,请完成后重试。", + "All rows returned by the official report; platform sampling applies.": "已读取官方报表返回的全部行;平台数据本身可能为抽样统计。", + "Rows returned by the official AI report; page results may be limited by Google.": "数据来自官方 AI 报表;页面列表可能受 Google 返回条数限制。", + "Saved at": "保存时间", + "Remove this saved snapshot? The official report is unchanged.": "移除这份本地快照?官方报表不会受影响。", + "Remove snapshot": "移除快照", + "Connect Bing or Google AI reports": "连接 Bing / Google AI 报表", + "All browser platforms": "全部网页平台", + "Official report total": "官方报表总量", + "Returned observations": "返回的记录数", + "No AI data synchronized yet. Connect an account above, then choose Sync now.": "尚未同步 AI 数据。请先连接上方账号,再点击「立即同步」。", + "Choose Chrome workspace": "选择 Chrome 工作区", + "Official AI report · default 3-month window · all filters": "官方 AI 报表 · 默认近三个月 · 全部筛选范围", + "Report website mismatch.": "报表网站与所选网站不一致,数据未保存。", + "AI report unavailable or its format has changed.": "AI 报表暂不可用或格式已变化,数据未保存。" } diff --git a/apps/web/src/server/__tests__/ai-report-connections.test.ts b/apps/web/src/server/__tests__/ai-report-connections.test.ts new file mode 100644 index 0000000..f4cd78a --- /dev/null +++ b/apps/web/src/server/__tests__/ai-report-connections.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const m = vi.hoisted(() => ({ + auth: vi.fn(), + brand: vi.fn(), + fetch: vi.fn(), + writes: [] as Array>, + config: null as unknown, + acquired: true, +})); +vi.mock("@tanstack/react-start", () => ({ + createServerFn: () => ({ validator: () => ({ handler: (fn: unknown) => fn }) }), +})); +vi.mock("@/lib/auth/helpers", () => ({ requireBrandSession: m.auth })); +vi.mock("../traffic-core", () => ({ trafficBrand: m.brand })); +vi.mock("@workspace/lib/db/db", () => ({ + db: { + transaction: async (fn: (tx: unknown) => unknown) => + fn({ + execute: async () => ({ rows: [{ acquired: m.acquired }] }), + select: () => ({ from: () => ({ where: async () => (m.config ? [{ config: m.config }] : []) }) }), + insert: () => ({ + values: (value: Record) => ({ + onConflictDoUpdate: async () => { + m.writes.push(value); + }, + }), + }), + }), + }, +})); + +import { readAiConnectionFn } from "../ai-report-connections"; + +const input = { brandId: "brand-a", source: "bing", property: "https://example.com/", action: "sync" }; +beforeEach(() => { + vi.clearAllMocks(); + m.writes = []; + m.config = null; + m.acquired = true; + vi.stubGlobal("fetch", m.fetch); + m.auth.mockResolvedValue({}); + m.brand.mockResolvedValue({ website: "https://example.com" }); + m.fetch.mockResolvedValue( + new Response( + JSON.stringify({ + ready: true, + property: input.property, + url: "https://www.bing.com/webmasters/aiperformance", + startDate: "2026-10-01", + endDate: "2026-10-02", + total: 10, + coverage: "Official rows", + tables: [{ dimension: "page", rows: [{ key: "https://example.com/a", value: 10 }] }], + }), + ), + ); +}); +it("requires brand access and website ownership before opening a report", async () => { + m.auth.mockRejectedValue(new Error("Forbidden")); + await expect(readAiConnectionFn({ data: input } as never)).rejects.toThrow("Forbidden"); + expect(m.fetch).not.toHaveBeenCalled(); + m.auth.mockResolvedValue({}); + await expect(readAiConnectionFn({ data: { ...input, property: "https://other.test/" } } as never)).rejects.toThrow( + "website saved", + ); + expect(m.fetch).not.toHaveBeenCalled(); +}); +it("saves a validated browser report and its last successful synchronization", async () => { + const result = await readAiConnectionFn({ data: input } as never); + expect(result.ok).toBe(true); + const report = m.writes[0].snapshot as Record; + expect(report.method).toBe("browser"); + expect(report.total).toBe(10); + expect(report.rows).toEqual([{ key: "https://example.com/a", value: 10 }]); + expect(m.writes[1].config).toMatchObject({ + aiReport: { state: "ready", property: input.property, lastSyncedAt: expect.any(String) }, + }); +}); +it("preserves the last successful sync and never writes an empty report after failure", async () => { + m.config = { site: "example.com", aiReport: { property: input.property, lastSyncedAt: "2026-10-01T00:00:00Z" } }; + m.fetch.mockRejectedValue(new Error("offline")); + const result = await readAiConnectionFn({ data: input } as never); + expect(result.ok).toBe(false); + expect(m.writes).toHaveLength(1); + expect(m.writes[0].config).toMatchObject({ + aiReport: { state: "needs-attention", lastSyncedAt: "2026-10-01T00:00:00Z" }, + }); +}); +it("does not accept a response for another website or an out-of-scope page", async () => { + m.fetch.mockResolvedValue( + new Response( + JSON.stringify({ + ready: true, + property: input.property, + url: "https://www.bing.com/webmasters/", + startDate: "2026-10-01", + endDate: "2026-10-02", + tables: [{ dimension: "page", rows: [{ key: "https://other.test/a", value: 5 }] }], + }), + ), + ); + expect((await readAiConnectionFn({ data: input } as never)).ok).toBe(false); + expect(m.writes.every((row) => String(row.kind).startsWith("ai-connection:"))).toBe(true); +}); +it("does not start another browser operation while a sync holds the lock", async () => { + m.acquired = false; + await expect(readAiConnectionFn({ data: input } as never)).rejects.toThrow("busy"); + expect(m.fetch).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/server/ai-report-connections.ts b/apps/web/src/server/ai-report-connections.ts new file mode 100644 index 0000000..741f21a --- /dev/null +++ b/apps/web/src/server/ai-report-connections.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto"; +import { createServerFn } from "@tanstack/react-start"; +import { reportHost, validateConnectedReport } from "@workspace/lib/ai-reports"; +import { db } from "@workspace/lib/db/db"; +import { brandDataSources } from "@workspace/lib/db/schema"; +import { and, eq, like, sql } from "drizzle-orm"; +import { z } from "zod"; +import { requireBrandSession } from "@/lib/auth/helpers"; +import { trafficBrand } from "./traffic-core"; + +const input = z.object({ + brandId: z.string().min(1).max(200), + source: z.enum(["bing", "google"]), + property: z.string().min(1).max(2048), +}); +const resultSchema = z.object({ + ready: z.literal(true), + property: z.string(), + url: z.string().url(), + startDate: z.string().optional(), + endDate: z.string().optional(), + coverage: z.string().max(500).optional(), + total: z.number().int().nonnegative().optional(), + tables: z + .array( + z.object({ + dimension: z.enum(["page", "query", "date"]), + rows: z + .array(z.object({ key: z.string().max(2048), value: z.number().int().nonnegative().nullable() })) + .max(2000), + }), + ) + .max(3) + .optional(), +}); +const where = (brandId: string, source: string) => + and(eq(brandDataSources.brandId, brandId), eq(brandDataSources.kind, `ai-connection:${source}`)); + +export async function reportBrowserRequest(data: { source: string; property: string; action: string }) { + const base = new URL(process.env.BROWSER_RUNNER_URL || "http://127.0.0.1:4318"); + if (base.protocol !== "http:" || !["localhost", "127.0.0.1"].includes(base.hostname)) + throw new Error("The browser service must run on this computer."); + let response: Response; + try { + response = await fetch(new URL("/api/reports", base), { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(240_000), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.BROWSER_RUNNER_TOKEN || ""}`, + }, + body: JSON.stringify(data), + }); + } catch { + throw new Error("Browser service unavailable. Start BeatGEO locally, then check again."); + } + if (response.status === 404) throw new Error("Restart BeatGEO to enable report connections."); + if (response.status === 409) throw new Error("The browser is busy. Retry after the current operation finishes."); + const result = await response.json(); + if (!response.ok) + throw new Error( + "The AI report could not be read. Check website access, sign-in and report availability, then retry.", + ); + return resultSchema.parse(result); +} + +export const getAiConnectionsFn = createServerFn({ method: "GET" }) + .validator(z.object({ brandId: z.string().min(1) })) + .handler(async ({ data }) => { + await requireBrandSession(data.brandId); + const brand = await trafficBrand(data.brandId); + const rows = await db + .select({ kind: brandDataSources.kind, config: brandDataSources.config }) + .from(brandDataSources) + .where(and(eq(brandDataSources.brandId, data.brandId), like(brandDataSources.kind, "ai-connection:%"))); + const websites = [ + ...new Set( + [brand.website, ...(brand.additionalDomains ?? [])].map( + (site) => new URL(site.includes("://") ? site : `https://${site}`).href, + ), + ), + ]; + return { + websites, + connections: rows.map((row) => ({ source: row.kind.slice("ai-connection:".length), ...row.config?.aiReport })), + }; + }); + +export const readAiConnectionFn = createServerFn({ method: "POST" }) + .validator(input.extend({ action: z.enum(["connect", "sync"]) })) + .handler(async ({ data }) => { + await requireBrandSession(data.brandId); + const brand = await trafficBrand(data.brandId); + const allowed = [brand.website, ...(brand.additionalDomains ?? [])].map(reportHost); + if (!allowed.includes(reportHost(data.property))) throw new Error("Choose a website saved in this brand."); + return db.transaction(async (tx) => { + const lock = await tx.execute<{ acquired: boolean }>( + sql`select pg_try_advisory_xact_lock(hashtextextended(${`ai-reports:${data.brandId}`},0)) as acquired`, + ); + if (!lock.rows[0]?.acquired) throw new Error("This connection is busy. Try again shortly."); + const [saved] = await tx.select().from(brandDataSources).where(where(data.brandId, data.source)); + let config: Record = saved?.config?.aiReport ?? {}; + try { + const result = await reportBrowserRequest(data); + if (result.property !== data.property) throw new Error("Report website mismatch."); + const now = new Date().toISOString(); + config = { + property: data.property, + checkedAt: now, + state: "ready", + error: "", + method: "browser", + lastSyncedAt: config.property === data.property ? config.lastSyncedAt : undefined, + }; + if (data.action === "sync") { + await saveReports(tx, data, result, now); + config.lastSyncedAt = now; + } + } catch (error) { + config = { + ...config, + state: "needs-attention", + attemptedProperty: data.property, + checkedAt: new Date().toISOString(), + error: error instanceof Error ? error.message : "Could not sync AI report.", + }; + } + await tx + .insert(brandDataSources) + .values({ + brandId: data.brandId, + kind: `ai-connection:${data.source}`, + config: { site: reportHost(data.property), aiReport: config }, + }) + .onConflictDoUpdate({ + target: [brandDataSources.brandId, brandDataSources.kind], + set: { config: { site: reportHost(data.property), aiReport: config }, updatedAt: new Date() }, + }); + return { ok: config.state === "ready", error: connectionError(config.error) }; + }); + }); + +export const disconnectAiConnectionFn = createServerFn({ method: "POST" }) + .validator(z.object({ brandId: z.string().min(1), source: z.enum(["bing", "google"]) })) + .handler(async ({ data }) => { + await requireBrandSession(data.brandId); + await db.transaction(async (tx) => { + const lock = await tx.execute<{ acquired: boolean }>( + sql`select pg_try_advisory_xact_lock(hashtextextended(${`ai-reports:${data.brandId}`},0)) as acquired`, + ); + if (!lock.rows[0]?.acquired) throw new Error("This connection is busy. Try again shortly."); + await tx.delete(brandDataSources).where(where(data.brandId, data.source)); + }); + return { disconnected: true }; + }); + +type ReportTx = Parameters[0]>[0]; +async function saveReports( + tx: ReportTx, + data: z.infer, + result: z.infer, + now: string, +) { + const { startDate, endDate, tables } = result; + if (!tables?.length || !startDate || !endDate) throw new Error("AI report unavailable or its format has changed."); + const reports = tables.map((table) => + validateConnectedReport({ + ...table, + source: data.source, + site: reportHost(data.property), + property: data.property, + startDate, + endDate, + method: "browser", + scope: "Official AI report · default 3-month window · all filters", + coverage: result.coverage, + total: result.total, + }), + ); + for (const report of reports) { + const id = createHash("sha256").update(JSON.stringify(report)).digest("hex"); + await tx + .insert(brandDataSources) + .values({ + brandId: data.brandId, + kind: `ai-report:${id}`, + config: { site: report.site }, + snapshot: { ...report, id, importedAt: now }, + }) + .onConflictDoUpdate({ + target: [brandDataSources.brandId, brandDataSources.kind], + set: { updatedAt: new Date(), snapshot: { ...report, id, importedAt: now } }, + }); + } +} + +function connectionError(value: unknown) { + return typeof value === "string" ? value : ""; +} diff --git a/docs/beat-geo/ai-platform-reports.md b/docs/beat-geo/ai-platform-reports.md index b747469..7eb2128 100644 --- a/docs/beat-geo/ai-platform-reports.md +++ b/docs/beat-geo/ai-platform-reports.md @@ -1,17 +1,15 @@ -# AI platform reports +# Connected AI platform reports -Use **Visibility → AI platform reports** to keep platform-reported citations and impressions separate from question-monitoring samples. No API credential is needed for imports, and importing does not schedule collections or refresh Google/Bing data. +Use **Visibility → AI platform reports** to connect Bing Webmaster Tools or Google Search Console, select a website saved on the brand, and synchronize its AI report. No CSV or Excel upload is required. Connecting checks access; synchronization saves observations. Neither action starts question monitoring or a recurring schedule. -Export a single CSV table from Bing AI Performance or Google Search Console's generative AI report. Extract ZIP downloads before selecting a CSV. This release accepts two-column tables: +AI reports use the selected BeatBrowser Chrome profile and its existing login. Ordinary Google search performance continues to use the Google OAuth connection independently. Keep Chrome and BeatGEO running during synchronization. A disconnected browser, expired login, unavailable report or changed page format produces an actionable failure; existing snapshots remain available. -- Page / Citations or Page / Impressions -- Date / Citations or Date / Impressions -- Grounding Query / Citations (Bing only) +The reader opens the official report, observes the responses generated by normal page navigation and saves only normalized counts and report provenance. It does not copy browser cookies or replay private request credentials. These are browser integrations, not public AI-report API integrations. Their parsers fail closed if the observed contract changes. -Common English and Simplified Chinese headers are recognized. Use exact integer counts; opportunity ranges and abbreviated counts are rejected. Provide the export's website, inclusive date range and filters, including market/search type/timezone where relevant. Google ordinary-search exports must not be relabelled as AI data. CSV alone cannot authenticate its source; the importer explicitly asks the user to confirm provenance. Exports with additional dimensions must be reduced to a supported table without changing its meaning. +Bing synchronization reads dated citation counts, pages and grounding queries, following pagination before saving. Google synchronization reads AI impression history and the page rows returned by the official report; Google may limit those rows. Domain properties include subdomains, while URL-prefix properties retain their prefix scope. Platform totals are stored separately from row sums. A returned empty table is distinct from an inaccessible report. -Each import is an independent snapshot scoped to the current brand. The same normalized report is saved only once. Different or overlapping snapshots remain separate and are not summed. Page/query comparisons require identical source, website, dimension and filters, equal-length non-overlapping periods. Missing rows remain unknown; daily tables are not paired across unrelated calendar dates. Detail rows are not summed into site totals. User-supplied filters must match exactly for comparison. +This version reads the official default three-month view. Each snapshot records the actual date interval, property and coverage, rather than assuming Google and Bing have the same window. Comparison requires matching source, property, dimension and filters with equal-length non-overlapping periods. Missing rows remain unknown. Browser question samples, API answers, Google impressions and Bing citations are not combined into one metric. -Limits: 2 MB per CSV, 2,000 rows and 100 saved imports per brand. Remove an unused import to free a slot; its original file is unaffected. Page reports must use a hostname saved on the brand; import separate hostnames separately. +Previously saved snapshots remain readable. Disconnect removes the local connection record, not Google/Bing authorization, Chrome login or saved reports. Removing a snapshot only removes that local snapshot. Raw account evidence and credentials remain untracked. -Validation uses synthetic CSV fixtures and mocked persistence/authorization tests. A real browser check exercises file selection and preview without saving synthetic observations into the owner's brand. Actual Bing/GSC export variants and account data still require validation with their real files. Automatic AI-report API synchronization and Topic Portfolio opportunity imports are not included. +For a workspace whose database is already initialized and current, `pnpm local:start --skip-migrations` restarts the services without applying migrations. This option does not initialize or upgrade a database. diff --git a/packages/lib/src/ai-reports.test.ts b/packages/lib/src/ai-reports.test.ts index c1c77d6..9152692 100644 --- a/packages/lib/src/ai-reports.test.ts +++ b/packages/lib/src/ai-reports.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { type AiReport, type AiReportInput, compareAiReports, parseAiReport } from "./ai-reports"; +import { + type AiReport, + type AiReportInput, + compareAiReports, + parseAiReport, + validateConnectedReport, +} from "./ai-reports"; const base: AiReportInput = { source: "bing", @@ -76,3 +82,43 @@ describe("AI platform report imports", () => { ); }); }); + +describe("connected AI reports", () => { + const report = { + source: "google" as const, + dimension: "page" as const, + site: "example.com", + property: "sc-domain:example.com", + startDate: "2026-10-01", + endDate: "2026-10-28", + scope: "Official AI report", + rows: [{ key: "https://docs.example.com/a", value: 5 }], + total: 12, + }; + it("keeps official totals separate from partial page rows and accepts domain subdomains", () => { + expect(validateConnectedReport(report)).toMatchObject({ total: 12, rows: report.rows }); + expect(validateConnectedReport({ ...report, rows: [] }).rows).toEqual([]); + }); + it.each(["https://example.com.evil.test/a", "https://other.test/a", "javascript:alert(1)"])( + "rejects unrelated page %s", + (key) => { + expect(() => validateConnectedReport({ ...report, rows: [{ key, value: 2 }] })).toThrow(); + }, + ); + it("restricts URL prefix properties", () => { + expect(() => + validateConnectedReport({ + ...report, + property: "https://example.com/docs/", + rows: [{ key: "https://example.com/blog/a", value: 2 }], + }), + ).toThrow(); + }); + it("rejects duplicate rows, invalid counts and dates outside the report", () => { + expect(() => validateConnectedReport({ ...report, rows: [...report.rows, ...report.rows] })).toThrow(); + expect(() => validateConnectedReport({ ...report, total: -1 })).toThrow(); + expect(() => + validateConnectedReport({ ...report, dimension: "date", rows: [{ key: "2026-11-01", value: 2 }] }), + ).toThrow(); + }); +}); diff --git a/packages/lib/src/ai-reports.ts b/packages/lib/src/ai-reports.ts index dfe9c1a..ca5b725 100644 --- a/packages/lib/src/ai-reports.ts +++ b/packages/lib/src/ai-reports.ts @@ -14,7 +14,59 @@ export type AiReport = Omit & { id: string; importedAt: string; rows: Array<{ key: string; value: number | null }>; + property?: string; + method?: "browser"; + coverage?: string; + total?: number; }; + +export function validateConnectedReport(report: Omit) { + if ( + !report.property || + !["bing", "google"].includes(report.source) || + !["page", "query", "date"].includes(report.dimension) || + (report.source === "google" && report.dimension === "query") + ) + throw new Error("Invalid AI report."); + if (!validDate(report.startDate) || !validDate(report.endDate) || report.startDate > report.endDate) + throw new Error("Invalid report period."); + const host = reportHost(report.property); + if (host !== report.site || report.rows.length > 2000) throw new Error("Invalid report website or row limit."); + const seen = new Set(); + for (const row of report.rows) { + validateConnectedRow(row, report, report.property); + if (seen.has(row.key)) throw new Error("Duplicate report row."); + seen.add(row.key); + } + if (report.total !== undefined && (!Number.isSafeInteger(report.total) || report.total < 0)) + throw new Error("Invalid total."); + return { + ...report, + schema: "beat-geo/ai-report@1" as const, + rows: [...report.rows].sort((a, b) => a.key.localeCompare(b.key)), + }; +} +function validateConnectedRow( + row: AiReport["rows"][number], + report: Omit, + property: string, +) { + if (!row.key || row.key.length > 2048 || (row.value !== null && (!Number.isSafeInteger(row.value) || row.value < 0))) + throw new Error("Invalid report row."); + if (report.dimension === "page") validatePropertyPage(row.key, property, report.source); + if (report.dimension === "date" && (!validDate(row.key) || row.key < report.startDate || row.key > report.endDate)) + throw new Error("Observation is outside the report period."); +} +function validatePropertyPage(key: string, property: string, source: AiReportSource) { + const page = new URL(key), + host = reportHost(property), + pageHost = reportHost(key); + const domainProperty = property.startsWith("sc-domain:") || source === "bing"; + const matches = domainProperty + ? pageHost === host || pageHost.endsWith(`.${host}`) + : page.origin === new URL(property).origin && page.href.startsWith(property); + if (!matches) throw new Error("Page URL is outside the connected property."); +} export function reportHost(value: string): string { const url = new URL( value.startsWith("sc-domain:") ? `https://${value.slice(10)}` : value.includes("://") ? value : `https://${value}`, @@ -148,6 +200,7 @@ export function compareAiReports(current: AiReport, previous: AiReport) { const days = (r: AiReport) => Date.parse(r.endDate) - Date.parse(r.startDate); if ( current.source !== previous.source || + current.property !== previous.property || current.dimension !== previous.dimension || current.site !== previous.site || current.scope !== previous.scope || diff --git a/packages/lib/src/db/schema.ts b/packages/lib/src/db/schema.ts index a82e329..986d9fe 100644 --- a/packages/lib/src/db/schema.ts +++ b/packages/lib/src/db/schema.ts @@ -388,7 +388,11 @@ export const brandDataSources = pgTable( .references(() => brands.id, { onDelete: "cascade" }), kind: text("kind").notNull(), encryptedCredentials: text("encrypted_credentials"), - config: jsonb("config").$type<{ property?: { id: string; name: string }; site: string }>(), + config: jsonb("config").$type<{ + property?: { id: string; name: string }; + site: string; + aiReport?: Record; + }>(), snapshot: jsonb("snapshot").$type< | import("../traffic/ga.mjs").GaSummary | import("../traffic/crawls.mjs").CrawlSummary diff --git a/scripts/start-local.mjs b/scripts/start-local.mjs index c03ec36..36823ea 100644 --- a/scripts/start-local.mjs +++ b/scripts/start-local.mjs @@ -30,8 +30,11 @@ if (process.platform !== "win32" && !process.env.BEATGEO_LAUNCHER) { } const root = fileURLToPath(new URL("../", import.meta.url)); -const envPath = process.argv[2] || path.join(root, ".env"); -ensureLocalSetup(root, { envFile: process.argv[2] }); +const startupArgs = process.argv.slice(2); +const skipMigrations = startupArgs.includes("--skip-migrations"); +const envFile = startupArgs.find((arg) => arg !== "--skip-migrations"); +const envPath = envFile || path.join(root, ".env"); +ensureLocalSetup(root, { envFile }); Object.assign(process.env, parseEnv(readFileSync(envPath, "utf8"))); requireBundledStorage(process.env); const app = new URL(process.env.APP_URL || "http://localhost:3140"); @@ -76,9 +79,11 @@ try { await prepareExtension(root); database = await startEmbeddedDatabase(root, process.env); // Migrations are applied before either application process starts. - execFileSync(process.execPath, ["node_modules/drizzle-kit/bin.cjs", "migrate"], { - cwd: path.join(root, "packages/lib"), stdio: "inherit", env: process.env, - }); + if (!skipMigrations) { + execFileSync(process.execPath, ["node_modules/drizzle-kit/bin.cjs", "migrate"], { + cwd: path.join(root, "packages/lib"), stdio: "inherit", env: process.env, + }); + } start(process.execPath, ["apps/browser-lab/server.mjs"]); // The shared bridge may serve other agents, so it is not stopped with BeatGEO. try { From 86192376e11acee641137c8908913b04bf5013c6 Mon Sep 17 00:00:00 2001 From: KKKK Date: Tue, 10 Nov 2026 00:04:59 +0800 Subject: [PATCH 2/3] Keep browser visibility separate from API answer samples --- .changeset/connect-ai-reports.md | 7 ++++ .../src/lib/__tests__/model-filter.test.ts | 11 +++++- .../postgres-read.integration.test.ts | 17 +++++--- apps/web/src/lib/model-filter.ts | 12 +++--- apps/web/src/lib/postgres-read.ts | 39 +++++++++---------- apps/web/src/server/brands.ts | 2 +- packages/config/src/model-filter.ts | 32 +++++---------- 7 files changed, 62 insertions(+), 58 deletions(-) create mode 100644 .changeset/connect-ai-reports.md diff --git a/.changeset/connect-ai-reports.md b/.changeset/connect-ai-reports.md new file mode 100644 index 0000000..1a05c6f --- /dev/null +++ b/.changeset/connect-ai-reports.md @@ -0,0 +1,7 @@ +--- +"@workspace/web": patch +"@workspace/lib": patch +"@workspace/config": patch +--- + +Connect Google and Bing AI reports through your signed-in browser without file uploads, and separate browser visibility from API answers. diff --git a/apps/web/src/lib/__tests__/model-filter.test.ts b/apps/web/src/lib/__tests__/model-filter.test.ts index 74e7c40..bfe2fe7 100644 --- a/apps/web/src/lib/__tests__/model-filter.test.ts +++ b/apps/web/src/lib/__tests__/model-filter.test.ts @@ -76,6 +76,15 @@ describe("labelForModelFilter", () => { }); it("labels the no-filter sentinel", () => { - expect(labelForModelFilter(ALL_MODELS_VALUE)).toBe("All models"); + expect(labelForModelFilter(ALL_MODELS_VALUE)).toBe("All browser platforms"); }); }); + +it("keeps browser observations and ungrounded API answers independently selectable", () => { + const browser = target("chatgpt"); + const api: TrackedTarget = { ...browser, value: targetFilterValue("chatgpt", false, "api"), tier: "api" }; + expect(getAvailableModels([browser, api])).toEqual(["all", "chatgpt", "chatgpt::api"]); + expect(parseModelFilter(api.value)).toEqual({ model: "chatgpt", premium: false, api: true }); + expect(labelForModelFilter(api.value)).toBe("ChatGPT · API"); + expect(getAvailableModels([api])).toEqual(["all", "chatgpt::api"]); +}); diff --git a/apps/web/src/lib/__tests__/postgres-read.integration.test.ts b/apps/web/src/lib/__tests__/postgres-read.integration.test.ts index a3db398..bd7907d 100644 --- a/apps/web/src/lib/__tests__/postgres-read.integration.test.ts +++ b/apps/web/src/lib/__tests__/postgres-read.integration.test.ts @@ -264,15 +264,23 @@ describe("model filter", () => { [promptId], "chatgpt::premium", ); - expect(standard).toMatchObject({ total_runs: 2, brand_mentioned_runs: 0 }); + expect(standard).toMatchObject({ total_runs: 1, brand_mentioned_runs: 0 }); expect(premium).toMatchObject({ total_runs: 1, brand_mentioned_runs: 1 }); }); + it("keeps API answers out of the default dashboard while allowing explicit API inspection", async () => { + const [summary] = await getDashboardSummary(brandId, "2026-03-02", "2026-03-02", "UTC"); + expect(summary).toMatchObject({ total_runs: 1, avg_visibility: 0 }); + const api = await getBrandMentionTotals(brandId, "2026-03-02", "2026-03-02", "UTC", [promptId], "chatgpt::api"); + expect(api).toMatchObject({ total_runs: 1, brand_mentioned_runs: 0 }); + }); + it("attributes citations through the run that produced them", async () => { const window: [string, string, string, string[]] = ["2026-03-02", "2026-03-02", "UTC", [promptId]]; expect(await getCitationsTotalCount(brandId, ...window, "chatgpt")).toBe(2); expect(await getCitationsTotalCount(brandId, ...window, "chatgpt::premium")).toBe(1); - expect(await getCitationsTotalCount(brandId, ...window)).toBe(3); + expect(await getCitationsTotalCount(brandId, ...window)).toBe(2); + expect(await getCitationsTotalCount(brandId, ...window, "chatgpt::api")).toBe(0); }); it("counts each model's citations the way its bare model filter does", async () => { @@ -284,10 +292,7 @@ describe("model filter", () => { it("breaks mention rate down by model", async () => { const rows = await getBrandMentionRateByModel(brandId, "2026-03-02", "2026-03-02", "UTC", [promptId]); - expect(rows).toEqual([ - { model: "chatgpt", runs: 3, brand_mentioned_count: 1 }, - { model: "claude", runs: 1, brand_mentioned_count: 1 }, - ]); + expect(rows).toEqual([{ model: "chatgpt", runs: 1, brand_mentioned_count: 0 }]); }); }); diff --git a/apps/web/src/lib/model-filter.ts b/apps/web/src/lib/model-filter.ts index e430f51..9004302 100644 --- a/apps/web/src/lib/model-filter.ts +++ b/apps/web/src/lib/model-filter.ts @@ -60,12 +60,10 @@ export function groupTrackedTargets(targets: readonly TrackedTarget[]): { .filter((group) => group.values.length > 0); } -/** - * Dropdown options for a brand's targets, with "All" on top once there is more - * than one. A single-target brand gets no "all" entry, since the filter is - * redundant (callers hide the dropdown entirely). - */ +/** Keep the browser aggregate available when a brand also samples API answers. */ export function getAvailableModels(targets: readonly TrackedTarget[]): string[] { - const values = targets.map((target) => target.value); - return values.length > 1 ? [ALL_MODELS_VALUE, ...values] : values; + const values = [...new Set(targets.map((target) => target.value))]; + return values.length > 1 || targets.some((target) => target.tier !== "scraped") + ? [ALL_MODELS_VALUE, ...values] + : values; } diff --git a/apps/web/src/lib/postgres-read.ts b/apps/web/src/lib/postgres-read.ts index 9a1cdb0..daa67b0 100644 --- a/apps/web/src/lib/postgres-read.ts +++ b/apps/web/src/lib/postgres-read.ts @@ -128,32 +128,19 @@ function promptIdFilter(enabledPromptIds?: string[]): SQL { return sql`AND prompt_id IN (${uuidList(enabledPromptIds)})`; } -/** - * Provider ids that reach a model by calling it directly. Combined with - * `web_search_enabled`, this is what separates a grounded API answer from the - * same model scraped off its consumer product — `prompt_runs` records the - * provider, and both rows carry `model = 'chatgpt'` with web search on. - * - * Caveat: a provider that picks its route per target (DataForSEO scrapes by - * default and calls the API when a target pins a version) is classified by its - * default here, since the row doesn't record which route ran. - */ +/** Grounded API filters also require a run to have web search enabled. */ const API_PROVIDER_IDS = getAllProviders() .filter((provider) => provider.access === "api") .map((provider) => provider.id); -/** - * Narrow to one target. A bare model id means the standard platform; the - * `::premium` variant means the grounded API call (the model's own web search). - * - * The provider list is bound one parameter per id rather than as an array: - * drizzle flattens a JS array into a single text parameter, which `ANY(...)` - * then can't compare element-wise. - */ +/** Default and bare model filters describe browser observations; API results require an explicit suffix. */ function modelFilter(model?: string, opts?: { alias?: string; source?: "prompt_runs" | "citations" }): SQL { const target = model ? parseModelFilter(model) : null; - if (!target) return sql``; const prefix = opts?.alias ? sql.raw(`${opts.alias}.`) : sql``; + if (!target?.premium) { + const channel = answerChannel(prefix, opts?.source, !!target?.api); + return sql`AND ${channel} ${target ? sql`AND ${prefix}model = ${target.model}` : sql``}`; + } const grounded = groundedCondition(prefix, opts?.source); // No API providers configured means nothing can be grounded, so the premium // side matches nothing and the standard side matches everything. @@ -163,6 +150,16 @@ function modelFilter(model?: string, opts?: { alias?: string; source?: "prompt_r return sql`AND ${prefix}model = ${target.model} AND ${target.premium ? grounded : sql`NOT ${grounded}`}`; } +function answerChannel(prefix: SQL, source: "prompt_runs" | "citations" | undefined, api: boolean): SQL { + const provider = api ? "beatapi" : "beat-browser"; + if (source === "citations") { + return sql`EXISTS (SELECT 1 FROM prompt_runs AS channel_run + WHERE channel_run.id = ${prefix}prompt_run_id AND channel_run.provider = ${provider} + ${api ? sql`AND NOT channel_run.web_search_enabled` : sql``})`; + } + return sql`${prefix}provider = ${provider} ${api ? sql`AND NOT ${prefix}web_search_enabled` : sql``}`; +} + function groundedCondition(prefix: SQL, source?: "prompt_runs" | "citations"): SQL | null { if (API_PROVIDER_IDS.length === 0) return null; const providers = sql.join( @@ -204,6 +201,7 @@ export async function getDashboardSummary( WHERE brand_id = ${brandId} ${dateFilter(fromDate, toDate, timezone)} ${promptIdFilter(enabledPromptIds)} + ${modelFilter()} `); return rows; } @@ -415,7 +413,6 @@ export async function getCitationsCountByModel( enabledPromptIds?: string[], ): Promise { if (enabledPromptIds && enabledPromptIds.length === 0) return []; - const grounded = groundedCondition(sql``, "citations"); const rows = await queryPg(sql` SELECT model, count(*)::int AS count FROM citations @@ -423,7 +420,7 @@ export async function getCitationsCountByModel( AND created_at >= ${windowStart(fromDate, timezone)} AND created_at < ${windowEnd(toDate, timezone)} ${promptIdFilter(enabledPromptIds)} - ${grounded ? sql`AND NOT ${grounded}` : sql``} + ${modelFilter(undefined, { source: "citations" })} GROUP BY model `); return rows.map((row) => ({ model: row.model, count: Number(row.count) })); diff --git a/apps/web/src/server/brands.ts b/apps/web/src/server/brands.ts index 34b6510..54b3761 100644 --- a/apps/web/src/server/brands.ts +++ b/apps/web/src/server/brands.ts @@ -54,7 +54,7 @@ function computeTrackedTargets(brand: Brand): TrackedTarget[] { return plan.targets.map((target) => { const premium = isGroundedApiTarget(target.config); return { - value: targetFilterValue(target.config.model, premium), + value: targetFilterValue(target.config.model, premium, resolveProviderAccess(target.config)), model: target.config.model, premium, // Scraped surface, bare API call, or grounded one — the same split the diff --git a/packages/config/src/model-filter.ts b/packages/config/src/model-filter.ts index 427ab61..7034cf2 100644 --- a/packages/config/src/model-filter.ts +++ b/packages/config/src/model-filter.ts @@ -1,44 +1,32 @@ -/** - * The model filter's vocabulary: what a brand can be filtered down to, how that - * travels in a URL, and what to call it. - * - * A brand can run the same model two ways — ChatGPT scraped from the consumer - * product and ChatGPT called directly with its own web search on — and those - * answer different questions, so they are separate entries. A model id alone - * cannot tell them apart (both are `chatgpt`, both web-search), which is why a - * filter keyed on model id listed ChatGPT twice and filtered to neither. - * - * The discriminator is grounded-API-or-not rather than the vendor behind it, so - * a URL survives an operator swapping BrightData for Olostep. - * - * Lives in config rather than in the dashboard because the deployment packages - * name targets too, and every surface that names one has to agree. - */ +/** Browser, API and grounded API answers need separate filters even when they share a model id. */ import { getModelMeta } from "./models"; import { premiumModelLabel } from "./platforms"; -/** Sentinel for "no model filter". */ +/** Default browser-platform aggregate. */ export const ALL_MODELS_VALUE = "all"; /** Marks the grounded-API variant of a model in a filter value. */ const PREMIUM_SUFFIX = "::premium"; -export function targetFilterValue(model: string, premium: boolean): string { - return premium ? `${model}${PREMIUM_SUFFIX}` : model; +export function targetFilterValue(model: string, premium: boolean, access?: string): string { + return premium ? `${model}${PREMIUM_SUFFIX}` : access === "api" ? `${model}::api` : model; } -export function parseModelFilter(value: string): { model: string; premium: boolean } | null { +export function parseModelFilter(value: string): { model: string; premium: boolean; api?: boolean } | null { if (!value || value === ALL_MODELS_VALUE) return null; + if (value.endsWith("::api")) return { model: value.slice(0, -5), premium: false, api: true }; return value.endsWith(PREMIUM_SUFFIX) ? { model: value.slice(0, -PREMIUM_SUFFIX.length), premium: true } : { model: value, premium: false }; } export function labelForModelFilter(value: string): string { - if (value === ALL_MODELS_VALUE) return "All models"; + if (value === ALL_MODELS_VALUE) return "All browser platforms"; const parsed = parseModelFilter(value); if (!parsed) return value; - return parsed.premium ? premiumModelLabel(parsed.model) : getModelMeta(parsed.model).label; + return parsed.premium + ? premiumModelLabel(parsed.model) + : `${getModelMeta(parsed.model).label}${parsed.api ? " · API" : ""}`; } export function iconIdForModelFilter(value: string): string { From 15a6c72d30680897962a73b204614263d4386e33 Mon Sep 17 00:00:00 2001 From: KKKK Date: Tue, 10 Nov 2026 00:05:16 +0800 Subject: [PATCH 3/3] Show the most visible report pages first --- apps/web/src/components/ai-platform-reports.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/ai-platform-reports.tsx b/apps/web/src/components/ai-platform-reports.tsx index 7950de6..62ae105 100644 --- a/apps/web/src/components/ai-platform-reports.tsx +++ b/apps/web/src/components/ai-platform-reports.tsx @@ -220,5 +220,7 @@ function reportView(reports: AiReport[], selected: string, previous: string, fil const baseline = reports.find((r) => r.id === previous); const comparison = report && baseline ? compareAiReports(report, baseline) : null; const rows = (comparison ?? report?.rows ?? []).filter((r) => r.key.toLowerCase().includes(filter.toLowerCase())); + if (report?.dimension !== "date") + rows.sort((a, b) => (b.value ?? -1) - (a.value ?? -1) || a.key.localeCompare(b.key)); return { report, comparison, rows }; }