From 818ab0855d6ead46a78208bc422c3d5a439ca430 Mon Sep 17 00:00:00 2001 From: KKKK Date: Mon, 9 Nov 2026 17:41:28 +0800 Subject: [PATCH 1/3] Add validated AI platform report storage and comparisons --- .../src/server/__tests__/ai-reports.test.ts | 79 +++++++++ apps/web/src/server/ai-reports.ts | 68 +++++++ packages/lib/package.json | 3 +- packages/lib/src/ai-reports.test.ts | 78 ++++++++ packages/lib/src/ai-reports.ts | 166 ++++++++++++++++++ packages/lib/src/db/schema.ts | 1 + 6 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/server/__tests__/ai-reports.test.ts create mode 100644 apps/web/src/server/ai-reports.ts create mode 100644 packages/lib/src/ai-reports.test.ts create mode 100644 packages/lib/src/ai-reports.ts diff --git a/apps/web/src/server/__tests__/ai-reports.test.ts b/apps/web/src/server/__tests__/ai-reports.test.ts new file mode 100644 index 0000000..7f41192 --- /dev/null +++ b/apps/web/src/server/__tests__/ai-reports.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const m = vi.hoisted(() => ({ + auth: vi.fn(), + brand: vi.fn(), + transaction: vi.fn(), + select: vi.fn(), + insert: vi.fn(), + remove: vi.fn(), + existing: [] as { kind: string }[], +})); +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: m.transaction, select: m.select, delete: m.remove } })); + +import { deleteAiReportFn, importAiReportFn, listAiReportsFn } from "../ai-reports"; + +const input = { + brandId: "brand-a", + source: "bing", + dimension: "page", + site: "example.com", + startDate: "2026-10-01", + endDate: "2026-10-28", + scope: "All", + content: "Page,Citations\nhttps://example.com/a,12", +}; +beforeEach(() => { + vi.clearAllMocks(); + m.existing = []; + m.auth.mockResolvedValue({ user: { id: "owner" } }); + m.brand.mockResolvedValue({ website: "https://example.com" }); + m.transaction.mockImplementation(async (fn) => + fn({ + execute: vi.fn(), + select: () => ({ from: () => ({ where: async () => m.existing }) }), + insert: () => ({ values: m.insert }), + }), + ); +}); +it.each([listAiReportsFn, importAiReportFn, deleteAiReportFn])( + "requires brand access before touching report data", + async (fn) => { + m.auth.mockRejectedValue(new Error("Forbidden")); + await expect(fn({ data: input } as never)).rejects.toThrow("Forbidden"); + expect(m.transaction).not.toHaveBeenCalled(); + expect(m.select).not.toHaveBeenCalled(); + expect(m.remove).not.toHaveBeenCalled(); + }, +); +it("rejects reports belonging to a different brand website", async () => { + await expect( + importAiReportFn({ + data: { ...input, site: "other.test", content: "Page,Citations\nhttps://other.test/a,12" }, + } as never), + ).rejects.toThrow("website saved"); + expect(m.transaction).not.toHaveBeenCalled(); +}); +it("stores one snapshot and makes a duplicate import idempotent", async () => { + const first = await importAiReportFn({ data: input } as never); + expect(first.duplicate).toBe(false); + expect(m.insert).toHaveBeenCalledTimes(1); + const record = m.insert.mock.calls[0][0]; + expect(record.snapshot.rows).toEqual([{ key: "https://example.com/a", value: 12 }]); + expect(record.brandId).toBe("brand-a"); + m.existing = [{ kind: record.kind }]; + const second = await importAiReportFn({ data: input } as never); + expect(second).toEqual({ id: first.id, duplicate: true }); + expect(m.insert).toHaveBeenCalledTimes(1); +}); +it("refuses excess history without discarding existing reports", async () => { + m.existing = Array.from({ length: 100 }, (_, i) => ({ kind: `ai-report:${i}` })); + await expect(importAiReportFn({ data: input } as never)).rejects.toThrow("Report limit"); + expect(m.insert).not.toHaveBeenCalled(); + expect(m.remove).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/server/ai-reports.ts b/apps/web/src/server/ai-reports.ts new file mode 100644 index 0000000..a4c11ab --- /dev/null +++ b/apps/web/src/server/ai-reports.ts @@ -0,0 +1,68 @@ +import { createHash } from "node:crypto"; +import { createServerFn } from "@tanstack/react-start"; +import { type AiReport, parseAiReport, reportHost } from "@workspace/lib/ai-reports"; +import { db } from "@workspace/lib/db/db"; +import { brandDataSources } from "@workspace/lib/db/schema"; +import { and, desc, eq, like, sql } from "drizzle-orm"; +import { z } from "zod"; +import { requireBrandSession } from "@/lib/auth/helpers"; +import { trafficBrand } from "./traffic-core"; + +const brandInput = z.object({ brandId: z.string().min(1).max(200) }); +const reportWhere = (brandId: string) => + and(eq(brandDataSources.brandId, brandId), like(brandDataSources.kind, "ai-report:%")); +export const listAiReportsFn = createServerFn({ method: "GET" }) + .validator(brandInput) + .handler(async ({ data }) => { + await requireBrandSession(data.brandId); + const rows = await db + .select({ snapshot: brandDataSources.snapshot }) + .from(brandDataSources) + .where(reportWhere(data.brandId)) + .orderBy(desc(brandDataSources.updatedAt)) + .limit(100); + return rows.map((r) => r.snapshot).filter((r): r is AiReport => r?.schema === "beat-geo/ai-report@1"); + }); +export const importAiReportFn = createServerFn({ method: "POST" }) + .validator( + brandInput.extend({ + source: z.enum(["bing", "google"]), + dimension: z.enum(["page", "query", "date"]), + site: z.string().max(2048), + startDate: z.string().max(10), + endDate: z.string().max(10), + scope: z.string().min(1).max(500), + content: z.string().max(2_000_000), + }), + ) + .handler(async ({ data }) => { + await requireBrandSession(data.brandId); + const brand = await trafficBrand(data.brandId); + const parsed = parseAiReport(data); + const hosts = [brand.website, ...(brand.additionalDomains ?? [])].map(reportHost); + if (!hosts.includes(parsed.site)) throw new Error("Choose a website saved in this brand."); + const id = createHash("sha256").update(JSON.stringify(parsed)).digest("hex"); + const report: AiReport = { ...parsed, id, importedAt: new Date().toISOString() }; + return db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`ai-reports:${data.brandId}`},0))`); + const existing = await tx + .select({ kind: brandDataSources.kind }) + .from(brandDataSources) + .where(reportWhere(data.brandId)); + if (existing.some((r) => r.kind === `ai-report:${id}`)) return { id, duplicate: true }; + if (existing.length >= 100) throw new Error("Report limit reached. Remove an unused import first."); + await tx + .insert(brandDataSources) + .values({ brandId: data.brandId, kind: `ai-report:${id}`, config: { site: parsed.site }, snapshot: report }); + return { id, duplicate: false }; + }); + }); +export const deleteAiReportFn = createServerFn({ method: "POST" }) + .validator(brandInput.extend({ id: z.string().regex(/^[a-f0-9]{64}$/) })) + .handler(async ({ data }) => { + await requireBrandSession(data.brandId); + await db + .delete(brandDataSources) + .where(and(eq(brandDataSources.brandId, data.brandId), eq(brandDataSources.kind, `ai-report:${data.id}`))); + return { deleted: true }; + }); diff --git a/packages/lib/package.json b/packages/lib/package.json index d43ffb7..5736445 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -50,7 +50,8 @@ "./traffic/ga": "./src/traffic/ga.mjs", "./traffic/gsc": "./src/traffic/gsc.mjs", "./traffic/crawls": "./src/traffic/crawls.mjs", - "./search-analysis": "./src/search-analysis.ts" + "./search-analysis": "./src/search-analysis.ts", + "./ai-reports": "./src/ai-reports.ts" }, "dependencies": { "@ai-sdk/anthropic": "^4.0.71", diff --git a/packages/lib/src/ai-reports.test.ts b/packages/lib/src/ai-reports.test.ts new file mode 100644 index 0000000..c1c77d6 --- /dev/null +++ b/packages/lib/src/ai-reports.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { type AiReport, type AiReportInput, compareAiReports, parseAiReport } from "./ai-reports"; + +const base: AiReportInput = { + source: "bing", + dimension: "page", + site: "example.com", + startDate: "2026-10-01", + endDate: "2026-10-28", + scope: "All countries", + content: 'Page,Citations\nhttps://example.com/a,"1,200"\nhttps://example.com/b,-', +}; +const saved = (input: AiReportInput): AiReport => ({ + ...parseAiReport(input), + id: "test", + importedAt: "2026-11-01T00:00:00Z", +}); +describe("AI platform report imports", () => { + it("preserves exact counts and missing observations in a BOM CSV", () => { + const result = parseAiReport({ ...base, content: `\uFEFF${base.content}` }); + expect(result.rows).toEqual([ + { key: "https://example.com/a", value: 1200 }, + { key: "https://example.com/b", value: null }, + ]); + }); + it("accepts localized Google page exports", () => { + expect( + parseAiReport({ ...base, source: "google", content: "排名靠前的网页,曝光次数\nhttps://example.com/a,12" }).rows[0] + .value, + ).toBe(12); + }); + it.each([ + "Page,Citations\nhttps://evil.test/a,12", + "Page,Citations\nhttps://example.com/a,2.5K", + "Page,Citations\nhttps://example.com/a,5K-10K", + "Page,Citations\nhttps://example.com/a,-1", + "Page,Citations\nhttps://example.com/a,1\nhttps://example.com/a,2", + 'Page,Citations\n"https://example.com/a,2', + ])("rejects ambiguous or invalid imports: %s", (content) => { + expect(() => parseAiReport({ ...base, content })).toThrow(); + }); + it("rejects ordinary search exports and opportunity tables", () => { + expect(() => + parseAiReport({ ...base, source: "google", content: "Page,Clicks,Impressions\nhttps://example.com/a,1,3" }), + ).toThrow(); + expect(() => + parseAiReport({ ...base, dimension: "query", content: "Opportunity Query,Citation Range\napi,5K-10K" }), + ).toThrow(); + }); + it("validates dates and preserves missing days without filling zeros", () => { + expect(() => parseAiReport({ ...base, startDate: "2026-02-30" })).toThrow(); + const daily = { ...base, dimension: "date" as const, content: "Date,Citations\n2026-10-03,5" }; + expect(parseAiReport(daily).rows).toHaveLength(1); + expect(() => parseAiReport({ ...daily, content: "Date,Citations\n2026-09-01,5" })).toThrow(); + }); + it("compares matching pages without converting absent rows to zero", () => { + const previous = saved({ + ...base, + startDate: "2026-09-01", + endDate: "2026-09-28", + content: "Page,Citations\nhttps://example.com/a,1100", + }); + const current = saved(base); + expect(compareAiReports(current, previous)?.map((r) => r.delta)).toEqual([100, null]); + expect(compareAiReports(current, { ...previous, source: "google" })).toBeNull(); + expect(compareAiReports(current, { ...previous, scope: "US" })).toBeNull(); + expect(compareAiReports(current, current)).toBeNull(); + expect(compareAiReports(current, { ...previous, endDate: "2026-09-29" })).toBeNull(); + }); + it("canonicalizes equivalent CSV files for duplicate identification", () => { + expect(parseAiReport(base)).toEqual( + parseAiReport({ + ...base, + content: "Page,Citations\r\nhttps://example.com/b,-\r\nhttps://example.com/a,1200\r\n", + }), + ); + }); +}); diff --git a/packages/lib/src/ai-reports.ts b/packages/lib/src/ai-reports.ts new file mode 100644 index 0000000..dfe9c1a --- /dev/null +++ b/packages/lib/src/ai-reports.ts @@ -0,0 +1,166 @@ +export type AiReportSource = "bing" | "google"; +export type AiReportDimension = "page" | "query" | "date"; +export type AiReportInput = { + source: AiReportSource; + dimension: AiReportDimension; + site: string; + startDate: string; + endDate: string; + scope: string; + content: string; +}; +export type AiReport = Omit & { + schema: "beat-geo/ai-report@1"; + id: string; + importedAt: string; + rows: Array<{ key: string; value: number | null }>; +}; +export function reportHost(value: string): string { + const url = new URL( + value.startsWith("sc-domain:") ? `https://${value.slice(10)}` : value.includes("://") ? value : `https://${value}`, + ); + if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.port) + throw new Error("Invalid report website."); + return url.hostname.toLowerCase(); +} +function validDate(value: string) { + return ( + /^\d{4}-\d{2}-\d{2}$/.test(value) && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString().slice(0, 10) === value + ); +} +class CsvReader { + rows: string[][] = []; + row: string[] = []; + cell = ""; + quoted = false; + closed = false; + quotedChar(c: string | undefined, next: string | undefined) { + if (c === undefined) throw new Error("Unclosed CSV quote."); + if (c === '"' && next === '"') { + this.cell += '"'; + return true; + } + if (c === '"') { + this.quoted = false; + this.closed = true; + } else this.cell += c; + return false; + } + char(c: string | undefined) { + if (c === "," || c === "\n" || c === undefined) { + this.row.push(this.cell.trim()); + this.cell = ""; + this.closed = false; + if (c === ",") return; + if (this.row.some(Boolean)) this.rows.push(this.row); + this.row = []; + if (this.rows.length > 2001) throw new Error("CSV has more than 2,000 rows. Export a smaller report."); + return; + } + if (c === '"' && !this.cell && !this.closed) { + this.quoted = true; + return; + } + if (this.closed || c === '"') throw new Error("Invalid CSV quoting."); + this.cell += c; + } +} +function csvRows(content: string): string[][] { + if (content.length > 2_000_000) throw new Error("CSV is too large (maximum 2 MB)."); + const parser = new CsvReader(); + const text = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n"); + for (let i = 0; i <= text.length; i++) { + if (parser.quoted) { + if (parser.quotedChar(text[i], text[i + 1])) i++; + } else parser.char(text[i]); + } + return parser.rows; +} +function reportCount(raw: string) { + if (["", "-", "~", "n/a"].includes(raw.toLowerCase())) return null; + if (!/^(\d+|\d{1,3}(,\d{3})+)$/.test(raw)) + throw new Error("Use exact non-negative counts; ranges and K/M abbreviations are not supported."); + const value = Number(raw.replaceAll(",", "")); + if (!Number.isSafeInteger(value)) throw new Error("Invalid report count."); + return value; +} +function validateKey(key: string, input: AiReportInput, site: string) { + if (input.dimension === "page") { + const url = new URL(key); + if (!["https:", "http:"].includes(url.protocol) || reportHost(key) !== site) + throw new Error("Page URLs must belong to the report website."); + } + if (input.dimension === "date" && (!validDate(key) || key < input.startDate || key > input.endDate)) + throw new Error("A date is outside the report period."); +} +const headers = { + page: ["page", "pages", "url", "top pages", "网页", "页面", "排名靠前的网页"], + query: ["grounding query", "grounding queries", "查询", "检索查询"], + date: ["date", "day", "日期", "天"], + bing: ["citations", "citation count", "引用", "引用次数"], + google: ["impressions", "曝光次数", "总曝光次数", "展现次数", "展示次数"], +}; +export function parseAiReport(input: AiReportInput): Omit { + if (!["bing", "google"].includes(input.source) || !["page", "query", "date"].includes(input.dimension)) + throw new Error("Unsupported AI report."); + if (input.source === "google" && input.dimension === "query") + throw new Error("Google AI query exports are not supported."); + if (!validDate(input.startDate) || !validDate(input.endDate) || input.startDate > input.endDate) + throw new Error("Choose valid report dates."); + if (!input.scope.trim() || input.scope.length > 500) throw new Error("Describe the report filters and coverage."); + const site = reportHost(input.site), + table = csvRows(input.content); + const header = table.shift()?.map((s) => s.toLowerCase()) ?? []; + const keyIndex = header.findIndex((h) => headers[input.dimension].includes(h)); + const valueIndex = header.findIndex((h) => headers[input.source].includes(h)); + // A two-column report prevents ordinary search clicks/CTR and opportunity ranges from becoming AI counts. + if (header.length !== 2 || keyIndex < 0 || valueIndex < 0 || keyIndex === valueIndex) + throw new Error("Use a two-column AI report: Page/Date (or Bing Grounding Query) and Citations/Impressions."); + if (!table.length) throw new Error("The CSV contains no observations."); + const seen = new Set(); + const rows = table + .map((cells) => { + if (cells.length !== 2) throw new Error("Each CSV row must have two columns."); + const key = cells[keyIndex], + raw = cells[valueIndex]; + if (!key || key.length > 2048 || seen.has(key)) + throw new Error("Empty or duplicate report rows are not supported."); + seen.add(key); + validateKey(key, input, site); + const value = reportCount(raw); + return { key, value }; + }) + .sort((a, b) => a.key.localeCompare(b.key)); + return { + schema: "beat-geo/ai-report@1", + source: input.source, + dimension: input.dimension, + site, + startDate: input.startDate, + endDate: input.endDate, + scope: input.scope.trim(), + rows, + }; +} +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.dimension !== previous.dimension || + current.site !== previous.site || + current.scope !== previous.scope || + days(current) !== days(previous) || + previous.endDate >= current.startDate + ) + return null; + const before = new Map(previous.rows.map((r) => [r.key, r.value])); + // Dates refer to different days, so keep daily observations separate instead of pairing unrelated dates. + if (current.dimension === "date") return null; + return current.rows.map((row) => ({ + ...row, + previous: before.get(row.key) ?? null, + delta: row.value !== null && before.get(row.key) != null ? row.value - (before.get(row.key) as number) : null, + })); +} diff --git a/packages/lib/src/db/schema.ts b/packages/lib/src/db/schema.ts index 365055c..a82e329 100644 --- a/packages/lib/src/db/schema.ts +++ b/packages/lib/src/db/schema.ts @@ -393,6 +393,7 @@ export const brandDataSources = pgTable( | import("../traffic/ga.mjs").GaSummary | import("../traffic/crawls.mjs").CrawlSummary | import("../traffic/gsc.mjs").GscSummary + | import("../ai-reports").AiReport >(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }, From b4fb032e629548133c3520ade1762cde01cf73c2 Mon Sep 17 00:00:00 2001 From: KKKK Date: Mon, 9 Nov 2026 17:41:28 +0800 Subject: [PATCH 2/3] Guide monitoring setup and expose separate AI platform reports --- .changeset/ai-platform-reports.md | 6 + .../src/components/ai-platform-reports.tsx | 402 ++++++++++++++++++ apps/web/src/components/app-sidebar.tsx | 18 +- apps/web/src/components/google-connection.tsx | 7 +- apps/web/src/components/gsc-panel.tsx | 4 + apps/web/src/components/monitoring-start.tsx | 45 ++ apps/web/src/lib/i18n/zh.json | 83 +++- apps/web/src/routes/_authed/app/index.tsx | 2 +- .../app/org/$org/brand/$brand/index.tsx | 15 +- .../org/$org/brand/$brand/settings/llms.tsx | 6 +- .../app/org/$org/brand/$brand/visibility.tsx | 52 ++- docs/beat-geo/ai-platform-reports.md | 17 + 12 files changed, 629 insertions(+), 28 deletions(-) create mode 100644 .changeset/ai-platform-reports.md create mode 100644 apps/web/src/components/ai-platform-reports.tsx create mode 100644 apps/web/src/components/monitoring-start.tsx create mode 100644 docs/beat-geo/ai-platform-reports.md diff --git a/.changeset/ai-platform-reports.md b/.changeset/ai-platform-reports.md new file mode 100644 index 0000000..95ee5b8 --- /dev/null +++ b/.changeset/ai-platform-reports.md @@ -0,0 +1,6 @@ +--- +"@workspace/web": patch +"@workspace/lib": patch +--- + +Add separate Bing and Google AI report imports with comparable history and guide users through their first monitoring run. diff --git a/apps/web/src/components/ai-platform-reports.tsx b/apps/web/src/components/ai-platform-reports.tsx new file mode 100644 index 0000000..ded5f06 --- /dev/null +++ b/apps/web/src/components/ai-platform-reports.tsx @@ -0,0 +1,402 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + type AiReport, + type AiReportDimension, + type AiReportInput, + type AiReportSource, + compareAiReports, + parseAiReport, +} 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 { useBrandId } from "@/hooks/use-brand-id"; +import { useT } from "@/lib/i18n"; +import { deleteAiReportFn, importAiReportFn, listAiReportsFn } from "@/server/ai-reports"; + +const fieldClass = "h-10 w-full min-w-0 rounded-md border bg-background px-3 text-sm"; +export function AiPlatformReports() { + const brandId = useBrandId(); + return ; +} +function ReportWorkspace({ brandId }: { brandId: string }) { + const t = useT(), + 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), + [message, setMessage] = useState(""); + const [selected, setSelected] = useState(""), + [previous, setPrevious] = useState(""), + [filter, setFilter] = useState(""), + [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.", + )} +

+
+
+ {(["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")} + +
+ ); + })} +
+ {query.isError && ( +

+ {t("Could not load AI reports.")}{" "} + +

+ )} +
+ {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)} +

+ )} + {report && ( +
+

{t("Saved AI observations")}

+
+ + +
+

+ {report.site} · {report.startDate} – {report.endDate} · {report.scope} +
+ {t("Imported 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.", + )} +

+ { + setFilter(e.target.value); + setOffset(0); + }} + /> +
+ + + + + + {comparison && ( + <> + + + + )} + + + + {rows.slice(offset, offset + 50).map((row) => ( + + + + {comparison && ( + <> + + + + )} + + ))} + +
{t(report.dimension)}{t(report.source === "bing" ? "Citations" : "Impressions")}{t("Previous period")}{t("Change")}
+ {report.dimension === "page" ? ( + + {row.key} + + ) : ( + row.key + )} + {row.value?.toLocaleString() ?? "—"}{"previous" in row ? (row.previous?.toLocaleString() ?? "—") : "—"}{"delta" in row ? (row.delta?.toLocaleString() ?? "—") : "—"}
+
+ {!rows.length &&

{t("No matching observations.")}

} +
+ + + {rows.length ? offset + 1 : 0}–{Math.min(offset + 50, rows.length)} / {rows.length} + + + +
+
+ )} +
+ ); +} + +function reportView(reports: AiReport[], selected: string, previous: string, filter: string) { + const report = reports.find((r) => r.id === selected) ?? reports[0]; + 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())); + return { report, comparison, rows }; +} diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index e9b2182..d3a223a 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -66,11 +66,6 @@ function brandGroups(organization: OrganizationSummary, brand: SidebarBrand, seo link: { to: "/app/org/$org/brand/$brand/opportunities", params }, icon: IconTarget, }, - { - title: "GEO actions", - link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "actions" }, - icon: IconFileText, - }, ); const groups: NavGroup[] = []; @@ -83,11 +78,6 @@ function brandGroups(organization: OrganizationSummary, brand: SidebarBrand, seo link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "search" }, icon: IconTarget, }, - { - title: "SEO actions", - link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "seo-actions" }, - icon: IconFileText, - }, ...SEO_PAGES.map( (page): NavItem => ({ title: page.title, @@ -110,7 +100,7 @@ function brandGroups(organization: OrganizationSummary, brand: SidebarBrand, seo }, ], }); - groups.push({ label: "GEO", items: dashboard }); + groups.unshift({ label: "GEO", items: dashboard }); groups.push({ label: "Analytics", items: [ @@ -131,7 +121,11 @@ function brandGroups(organization: OrganizationSummary, brand: SidebarBrand, seo link: { to: "/app/org/$org/brand/$brand/settings/competitors", params }, icon: IconBuildings, }, - { title: "Models", link: { to: "/app/org/$org/brand/$brand/settings/llms", params }, icon: IconCpu }, + { + title: "Monitoring platforms", + link: { to: "/app/org/$org/brand/$brand/settings/llms", params }, + icon: IconCpu, + }, ], }); diff --git a/apps/web/src/components/google-connection.tsx b/apps/web/src/components/google-connection.tsx index e7c5e65..b255059 100644 --- a/apps/web/src/components/google-connection.tsx +++ b/apps/web/src/components/google-connection.tsx @@ -42,9 +42,14 @@ export function GoogleConnection({ )}

)} +

+ {t( + "Saved authorization does not guarantee the next refresh will succeed. Connection errors do not erase the saved report shown below.", + )} +

  1. - {t("1. Authorize Google")} · {t(connected ? "Connected" : "Not connected")} + {t("1. Authorize Google")} · {t(connected ? "Authorization saved" : "Not connected")}
  2. {t("2. Choose Search Console property")} · {t(data.gsc?.config?.property ? "Selected" : "Not selected")} diff --git a/apps/web/src/components/gsc-panel.tsx b/apps/web/src/components/gsc-panel.tsx index 8a6ac1e..57b32d2 100644 --- a/apps/web/src/components/gsc-panel.tsx +++ b/apps/web/src/components/gsc-panel.tsx @@ -94,6 +94,10 @@ export function GscPanel({

    {result.startDate} – {result.endDate} · {t("Google Search, web results")} +
    + {t("Last successful data fetch")}: {new Date(result.fetchedAt).toLocaleString()} +
    + {t("Saved search data is not a live connection check or a separate AI report.")}

    {result.totals && (
    diff --git a/apps/web/src/components/monitoring-start.tsx b/apps/web/src/components/monitoring-start.tsx new file mode 100644 index 0000000..709e356 --- /dev/null +++ b/apps/web/src/components/monitoring-start.tsx @@ -0,0 +1,45 @@ +import { Link } from "@tanstack/react-router"; +import { useBrandParams } from "@/hooks/use-route-params"; +import { useT } from "@/lib/i18n"; +export function MonitoringStart() { + const t = useT(), + params = useBrandParams(); + return ( +
    +

    {t("Your AI monitoring workflow")}

    +
    + {( + [ + ["1. Check connections", "/app/org/$org/brand/$brand/settings/brand"], + ["2. Choose monitoring platforms", "/app/org/$org/brand/$brand/settings/llms"], + ["3. Review monitoring questions", "/app/org/$org/brand/$brand/settings/prompts"], + ["4. Collect and review answers", "/app/org/$org/brand/$brand/opportunities"], + ] as const + ).map(([label, to]) => ( + + {t(label)} + + ))} +
    +

    + {t( + "Question monitoring measures completed answer samples. Platform reports record site impressions or citations. SEO research and referral visits are separate evidence.", + )} +

    + + {t("Import Bing or Google AI reports")} + +
    + ); +} diff --git a/apps/web/src/lib/i18n/zh.json b/apps/web/src/lib/i18n/zh.json index 030fc75..76e5d23 100644 --- a/apps/web/src/lib/i18n/zh.json +++ b/apps/web/src/lib/i18n/zh.json @@ -145,7 +145,7 @@ "Chrome profile": "Chrome 配置", "Citation Categories": "引用类别", "Citation Page Types": "引用页面类型", - "Citations": "引用", + "Citations": "引用次数", "Clear": "清空", "Clear filters": "清除筛选", "Clear search": "清空搜索", @@ -1185,7 +1185,7 @@ "comparable": "可对比", "incompatible": "数据口径不兼容", "insufficient_data": "数据不足", - "query": "关键词", + "query": "查询", "page": "页面", "date": "日期", "clicks": "点击次数", @@ -1281,5 +1281,82 @@ "Page key events": "页面关键事件数", "Page session key event rate": "页面会话关键事件率", "Not enough recent answers to generate a new report. Your saved report is shown below.": "近期回答不足以生成新报告,下面仍展示已保存的报告。", - "Acquisition, organic search, AI referral visits and crawler observations.": "渠道获客、自然搜索、AI 来源访问和爬虫观测。" + "Acquisition, organic search, AI referral visits and crawler observations.": "渠道获客、自然搜索、AI 来源访问和爬虫观测。", + "AI platform reports": "AI 平台报表", + "Question monitoring": "问题监测", + "AI visibility views": "AI 可见度视图", + "Import platform observations separately from your monitored questions. Imports do not enable automatic synchronization.": "平台统计与监控问题分别记录。导入报表不会开启自动同步。", + "Citations · Microsoft and supported partners": "引用次数 · 微软及支持的合作伙伴", + "Impressions · Google AI Overviews and AI Mode": "展示次数 · Google AI Overviews 与 AI Mode", + "Could not load AI reports.": "无法加载 AI 报表,请重试。", + "No AI report imported": "尚未导入 AI 报表", + "Open official dashboard": "打开官方后台", + "Import AI report CSV": "导入 AI 报表 CSV", + "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.": "从 AI 报表导出单张表,并填写该报表的网站、日期和筛选条件。支持页面、每日历史及 Bing 检索查询。请使用精确次数,不支持主题机会区间。仅支持 CSV;ZIP 请先解压。最多 2 MB、2,000 行。", + "Report source": "报表来源", + "Report dimension": "统计维度", + "Daily history": "每日历史", + "Grounding queries": "检索查询", + "Report website": "报表网站", + "Report start date": "统计开始日期", + "Report end date": "统计结束日期", + "Filters and coverage": "筛选条件与覆盖范围", + "For example: all countries, text search, no page filter": "例如:所有国家、文本搜索、未筛选页面", + "AI report CSV": "AI 报表 CSV 文件", + "Import preview": "导入预览", + "I confirm this file comes from the selected AI report, and the website, dates and filters match the export. Ordinary search impressions are not AI impressions.": "我确认文件来自所选的 AI 专项报表,网站、日期和筛选条件与导出时一致。普通搜索曝光不能作为 AI 曝光导入。", + "Save AI report": "保存 AI 报表", + "AI report saved.": "AI 报表已保存。", + "This report is already saved; no counts were added.": "此报表已保存,没有重复计入。", + "Saved AI observations": "已保存的 AI 观测", + "Saved report": "历史报表", + "Compare with earlier report": "对比更早的报表", + "No comparison": "不对比", + "Imported at": "导入时间", + "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.": "仅对比来源、维度、网站、筛选条件相同,且等长、不重叠的期间。缺失行表示未知,不算零;每日历史独立展示。行数值相加不代表全站总量,不同平台不合计。", + "Filter observations": "筛选观测记录", + "No matching observations.": "没有匹配的记录。", + "Remove this imported report? The original file is unchanged.": "移除此导入报表?原始文件不会被修改。", + "Remove import": "移除导入", + "Could not remove AI report.": "无法移除 AI 报表。", + "Could not save AI report.": "无法保存 AI 报表。", + "Could not read CSV.": "无法读取 CSV 文件。", + "Your AI monitoring workflow": "开始 AI 数据监控", + "1. Check connections": "1. 检查数据连接", + "2. Choose monitoring platforms": "2. 选择监测平台", + "3. Review monitoring questions": "3. 确认监控问题", + "4. Collect and review answers": "4. 采集并检查回答", + "Question monitoring measures completed answer samples. Platform reports record site impressions or citations. SEO research and referral visits are separate evidence.": "问题监测统计已完成的回答样本;平台报表记录网站展示或引用。SEO 研究和引荐流量作为独立证据查看。", + "Import Bing or Google AI reports": "导入 Bing / Google AI 报表", + "Monitoring platforms": "监测平台", + "Last successful data fetch": "最近成功获取数据", + "Saved search data is not a live connection check or a separate AI report.": "此处为已保存的搜索数据,不代表连接实时正常,也不是独立 AI 报表。", + "Authorization saved": "已保存授权", + "Saved authorization does not guarantee the next refresh will succeed. Connection errors do not erase the saved report shown below.": "已保存授权不代表下次刷新一定成功。连接报错时,下方已保存的历史报表仍可查看。", + "Could not reach Google authorization. Try again.": "暂时无法连接 Google 授权服务,请重试。", + "Invalid report website.": "报表网站格式无效。", + "CSV is too large (maximum 2 MB).": "CSV 过大,最多支持 2 MB。", + "Unclosed CSV quote.": "CSV 引号未闭合。", + "Invalid CSV quoting.": "CSV 引号格式无效。", + "CSV has more than 2,000 rows. Export a smaller report.": "CSV 超过 2,000 行,请缩小导出范围。", + "Unsupported AI report.": "不支持此 AI 报表类型。", + "Google AI query exports are not supported.": "暂不支持 Google AI 查询词导入。", + "Choose valid report dates.": "请选择有效的统计日期。", + "Describe the report filters and coverage.": "请填写报表的筛选条件与覆盖范围。", + "Use a two-column AI report: Page/Date (or Bing Grounding Query) and Citations/Impressions.": "请使用两列 AI 报表:页面/日期(或 Bing 检索查询)及引用次数/展示次数。", + "The CSV contains no observations.": "CSV 没有观测数据。", + "Each CSV row must have two columns.": "CSV 每行必须有两列。", + "Empty or duplicate report rows are not supported.": "不支持空白或重复的记录。", + "Page URLs must belong to the report website.": "页面网址必须属于填写的报表网站。", + "A date is outside the report period.": "数据日期超出了填写的统计期间。", + "Use exact non-negative counts; ranges and K/M abbreviations are not supported.": "请使用精确的非负整数,不支持区间或 K/M 缩写。", + "Invalid report count.": "报表次数无效。", + "Choose a website saved in this brand.": "请选择当前品牌已保存的网站。", + "Report limit reached. Remove an unused import first.": "已达到 100 份报表上限,请先移除不再需要的导入。", + "Invalid report.": "报表格式无效。", + "Pages": "页面", + "Loading…": "加载中…", + "Saving…": "正在保存…", + "rows": "行", + "Change": "变化" } diff --git a/apps/web/src/routes/_authed/app/index.tsx b/apps/web/src/routes/_authed/app/index.tsx index f1d391d..77a8030 100644 --- a/apps/web/src/routes/_authed/app/index.tsx +++ b/apps/web/src/routes/_authed/app/index.tsx @@ -6,7 +6,7 @@ export const Route = createFileRoute("/_authed/app/")({ loader: async ({ context }) => { const { organizations } = await context.queryClient.ensureQueryData(organizationsQuery); const website = context.localNavigation?.website ?? firstWebsite(organizations); - if (website) throw redirect({ to: "/app/org/$org/brand/$brand/seo", params: website }); + if (website) throw redirect({ to: "/app/org/$org/brand/$brand", params: website }); throw redirect({ to: "/setup" }); }, }); diff --git a/apps/web/src/routes/_authed/app/org/$org/brand/$brand/index.tsx b/apps/web/src/routes/_authed/app/org/$org/brand/$brand/index.tsx index 7b08c6e..ed994fb 100644 --- a/apps/web/src/routes/_authed/app/org/$org/brand/$brand/index.tsx +++ b/apps/web/src/routes/_authed/app/org/$org/brand/$brand/index.tsx @@ -9,6 +9,7 @@ import { Skeleton } from "@workspace/ui/components/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components/tooltip"; import { type ReactNode, useEffect } from "react"; import { FirstCollectionStatus } from "@/components/first-collection-status"; +import { MonitoringStart } from "@/components/monitoring-start"; import { PageHeader } from "@/components/page-header"; import PromptWizard from "@/components/prompt-wizard"; import { SiteIcon } from "@/components/site-icon"; @@ -508,12 +509,22 @@ function DashboardPage() { const rankingDomains = rankingDomainsFor(brand); if (!isLoadingBrand && !brand?.onboarded) { - return ; + return ( +
    + + +
    + ); } // No runs yet: the dashboard has nothing to plot, so point at what to do next. if (!loadingVisibility && totalRuns === 0) { - return 0} />; + return ( +
    + + 0} /> +
    + ); } return ( diff --git a/apps/web/src/routes/_authed/app/org/$org/brand/$brand/settings/llms.tsx b/apps/web/src/routes/_authed/app/org/$org/brand/$brand/settings/llms.tsx index a488330..a015a1a 100644 --- a/apps/web/src/routes/_authed/app/org/$org/brand/$brand/settings/llms.tsx +++ b/apps/web/src/routes/_authed/app/org/$org/brand/$brand/settings/llms.tsx @@ -1,3 +1,4 @@ +import { MonitoringStart } from "@/components/monitoring-start"; import { useT } from "@/lib/i18n"; /** * Which platforms a brand is tracked against, grouped by what each group tells @@ -32,7 +33,7 @@ import { useWriteErrorMessage } from "@/lib/write-errors"; import { getModelPickerStateFn, type ModelPickerState, updateEnabledModelsFn } from "@/server/platform-picks"; export const Route = createFileRoute("/_authed/app/org/$org/brand/$brand/settings/llms")({ - staticData: { crumb: "Models" }, + staticData: { crumb: "Monitoring platforms" }, loader: async ({ context }): Promise<{ picker: ModelPickerState }> => ({ picker: await getModelPickerStateFn({ data: { brandId: context.brandId } }), }), @@ -46,8 +47,9 @@ function LlmsSettingsPage() { return (
    +
    -

    {t("Models")}

    +

    {t("Monitoring platforms")}

    {t( "Your questions are evaluated against these AI models to track how your brand appears across different types of AI search.", diff --git a/apps/web/src/routes/_authed/app/org/$org/brand/$brand/visibility.tsx b/apps/web/src/routes/_authed/app/org/$org/brand/$brand/visibility.tsx index 2ab0bf8..3152847 100644 --- a/apps/web/src/routes/_authed/app/org/$org/brand/$brand/visibility.tsx +++ b/apps/web/src/routes/_authed/app/org/$org/brand/$brand/visibility.tsx @@ -1,4 +1,6 @@ import { createFileRoute, Link } from "@tanstack/react-router"; +import { AiPlatformReports } from "@/components/ai-platform-reports"; +import { MonitoringStart } from "@/components/monitoring-start"; import { PromptsDisplay } from "@/components/prompts-display"; import { useBrandParams } from "@/hooks/use-route-params"; import { useT } from "@/lib/i18n"; @@ -7,9 +9,12 @@ import { pageHead } from "@/lib/route-head"; export const Route = createFileRoute("/_authed/app/org/$org/brand/$brand/visibility")({ staticData: { crumb: "Visibility" }, - validateSearch: (search: Record): { order?: PromptOrder } => { + validateSearch: (search: Record): { order?: PromptOrder; view?: "reports" } => { const order = coercePromptOrder(search.order); - return order === DEFAULT_PROMPT_ORDER ? {} : { order }; + return { + ...(order === DEFAULT_PROMPT_ORDER ? {} : { order }), + ...(search.view === "reports" ? { view: "reports" as const } : {}), + }; }, head: pageHead({ description: "Track how LLMs respond to prompts about your brand." }), component: VisibilityPage, @@ -18,6 +23,7 @@ export const Route = createFileRoute("/_authed/app/org/$org/brand/$brand/visibil function VisibilityPage() { const t = useT(); const params = useBrandParams(); + const { view } = Route.useSearch(); const infoContent = ( <> @@ -30,10 +36,42 @@ function VisibilityPage() { ); return ( - +

    + + {view === "reports" ? ( + + ) : ( + <> + + + + )} +
    ); } diff --git a/docs/beat-geo/ai-platform-reports.md b/docs/beat-geo/ai-platform-reports.md new file mode 100644 index 0000000..b747469 --- /dev/null +++ b/docs/beat-geo/ai-platform-reports.md @@ -0,0 +1,17 @@ +# 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. + +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: + +- Page / Citations or Page / Impressions +- Date / Citations or Date / Impressions +- Grounding Query / Citations (Bing only) + +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. + +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. + +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. + +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. From da5f4eb69afddf9b48dd1179035045ce676a1146 Mon Sep 17 00:00:00 2001 From: KKKK Date: Mon, 9 Nov 2026 20:52:16 +0800 Subject: [PATCH 3/3] Track platforms only through BeatBrowser and BeatAPI Monitoring now reaches AI platforms two ways: BeatBrowser reads the consumer page in the user's own Chrome, and BeatAPI asks each platform's default model (overridable per target) through one key. The inherited third-party scraper and direct-model providers, their keys, parsers, cost entries, SDK dependencies and status script are removed, and onboarding research runs through BeatAPI only. Also remove the inherited product analytics (PostHog, Clarity, Plausible, cookie consent, worker telemetry), unused upstream testimonials and placeholder repository files, and reorder the sidebar into SEO, GEO and Optimize groups. --- .changeset/beatapi-monitoring-platforms.md | 8 + .github/FUNDING.yml | 1 - .github/contributors.txt | 1 - AGENTS.md | 2 +- CLA.md | 3 - apps/web/package.json | 1 - .../__tests__/response-markdown.test.tsx | 76 +- apps/web/src/components/app-sidebar.tsx | 126 ++- .../web/src/components/prompt-suggestions.tsx | 2 - apps/web/src/components/prompt-wizard.tsx | 11 - apps/web/src/components/prompts-editor.tsx | 8 - apps/web/src/env.d.ts | 25 - .../src/lib/__tests__/fanout-analysis.test.ts | 12 +- .../postgres-read.integration.test.ts | 8 +- apps/web/src/lib/breadcrumbs.ts | 9 +- apps/web/src/lib/clarity.ts | 52 -- apps/web/src/lib/i18n/zh.json | 23 +- apps/web/src/lib/platform-groups.ts | 4 +- apps/web/src/lib/posthog.ts | 90 -- apps/web/src/lib/security-headers.ts | 6 +- apps/web/src/routeTree.gen.ts | 43 - apps/web/src/routes/__root.tsx | 30 +- .../app/org/$org/brand/$brand/index.tsx | 11 +- .../org/$org/brand/$brand/settings/llms.tsx | 65 +- .../app/org/$org/settings/api-keys.tsx | 2 - apps/web/src/routes/_authed/reports/index.tsx | 5 - .../src/routes/api/plausible/event/index.ts | 57 -- .../routes/api/plausible/js/script/index.ts | 70 -- .../agent-workflow.integration.test.ts | 2 +- apps/web/src/server/config.ts | 10 - apps/web/src/server/platform-picks.ts | 29 +- apps/web/src/stories/_mocks/config-client.ts | 7 - apps/web/src/stories/app-sidebar.stories.tsx | 1 - apps/web/src/stories/brand-kit.stories.tsx | 1 - .../stories/chart-accessibility.stories.tsx | 1 - .../stories/chart-export-preview.stories.tsx | 1 - apps/web/src/stories/prompt-chart.stories.tsx | 1 - .../src/test/integration/stats-fixtures.ts | 2 +- apps/worker/package.json | 3 +- apps/worker/scripts/github-summary.ts | 9 - apps/worker/scripts/test-provider.ts | 484 ----------- apps/worker/src/index.ts | 2 - apps/worker/src/jobs/process-prompt.ts | 12 +- apps/worker/src/telemetry.ts | 48 -- docs/beat-geo/beatapi-product-loop.md | 2 +- docs/privacy.md | 2 +- docs/providers.md | 190 +--- docs/self-hosting.md | 1 - e2e/seed.ts | 2 +- packages/config/src/env-registry.test.ts | 8 +- packages/config/src/env-registry.ts | 137 +-- packages/config/src/env.test.ts | 23 +- packages/config/src/models.ts | 27 +- packages/config/src/platforms.ts | 4 +- packages/config/src/scrape-targets.test.ts | 98 ++- packages/config/src/scrape-targets.ts | 100 +-- packages/config/src/types.ts | 7 - packages/lib/package.json | 8 - packages/lib/scripts/compare-onboarding.ts | 443 ---------- packages/lib/src/onboarding/analyze.test.ts | 2 +- packages/lib/src/onboarding/llm.test.ts | 95 +- packages/lib/src/onboarding/llm.ts | 36 +- packages/lib/src/providers/config.test.ts | 282 +----- packages/lib/src/providers/config.ts | 67 +- packages/lib/src/providers/coverage.test.ts | 36 +- packages/lib/src/providers/index.ts | 24 +- packages/lib/src/providers/registry/ai-sdk.ts | 38 - .../providers/registry/anthropic-api.test.ts | 121 --- .../src/providers/registry/anthropic-api.ts | 130 --- .../src/providers/registry/beatapi.test.ts | 32 +- .../lib/src/providers/registry/beatapi.ts | 18 +- .../src/providers/registry/brightdata.test.ts | 122 --- .../lib/src/providers/registry/brightdata.ts | 310 ------- .../lib/src/providers/registry/cloro.test.ts | 412 --------- packages/lib/src/providers/registry/cloro.ts | 197 ----- .../providers/registry/dataforseo-shared.ts | 81 -- .../src/providers/registry/dataforseo.test.ts | 373 -------- .../lib/src/providers/registry/dataforseo.ts | 334 ------- .../providers/registry/mistral-api.test.ts | 46 - .../lib/src/providers/registry/mistral-api.ts | 159 ---- .../lib/src/providers/registry/olostep.ts | 157 ---- .../src/providers/registry/openai-api.test.ts | 173 ---- .../lib/src/providers/registry/openai-api.ts | 134 --- .../src/providers/registry/openrouter.test.ts | 68 -- .../lib/src/providers/registry/openrouter.ts | 173 ---- .../src/providers/registry/oxylabs.test.ts | 167 ---- .../lib/src/providers/registry/oxylabs.ts | 191 ---- .../providers/registry/scrape-shared.test.ts | 24 - .../src/providers/registry/scrape-shared.ts | 67 -- .../providers/registry/sdk-contract.test.ts | 232 ----- .../src/providers/registry/searchapi.test.ts | 261 ------ .../lib/src/providers/registry/searchapi.ts | 161 ---- .../providers/registry/structured-output.ts | 14 + packages/lib/src/providers/runner.test.ts | 10 +- .../lib/src/run-policy/brand-plans.test.ts | 2 +- .../lib/src/run-policy/maintenance.test.ts | 11 +- packages/lib/src/run-policy/policy.test.ts | 8 +- packages/lib/src/secrets/crypto.test.ts | 6 +- packages/lib/src/secrets/store.test.ts | 88 +- packages/lib/src/text-extraction.test.ts | 816 +----------------- packages/lib/src/text-extraction.ts | 643 +------------- packages/lib/src/usage/cost.test.ts | 16 +- packages/lib/src/usage/cost.ts | 29 +- packages/ui/src/brand/customers.tsx | 95 -- packages/ui/src/lib/cookie-consent.ts | 187 ---- pnpm-lock.yaml | 286 +----- scripts/check-skill-install.sh | 1 - scripts/local-runtime.mjs | 1 - scripts/test-local-ui.mjs | 2 +- turbo.json | 17 - 110 files changed, 571 insertions(+), 8800 deletions(-) create mode 100644 .changeset/beatapi-monitoring-platforms.md delete mode 100644 .github/FUNDING.yml delete mode 100644 .github/contributors.txt delete mode 100644 CLA.md delete mode 100644 apps/web/src/lib/clarity.ts delete mode 100644 apps/web/src/lib/posthog.ts delete mode 100644 apps/web/src/routes/api/plausible/event/index.ts delete mode 100644 apps/web/src/routes/api/plausible/js/script/index.ts delete mode 100644 apps/worker/scripts/github-summary.ts delete mode 100644 apps/worker/scripts/test-provider.ts delete mode 100644 apps/worker/src/telemetry.ts delete mode 100644 packages/lib/scripts/compare-onboarding.ts delete mode 100644 packages/lib/src/providers/registry/ai-sdk.ts delete mode 100644 packages/lib/src/providers/registry/anthropic-api.test.ts delete mode 100644 packages/lib/src/providers/registry/anthropic-api.ts delete mode 100644 packages/lib/src/providers/registry/brightdata.test.ts delete mode 100644 packages/lib/src/providers/registry/brightdata.ts delete mode 100644 packages/lib/src/providers/registry/cloro.test.ts delete mode 100644 packages/lib/src/providers/registry/cloro.ts delete mode 100644 packages/lib/src/providers/registry/dataforseo-shared.ts delete mode 100644 packages/lib/src/providers/registry/dataforseo.test.ts delete mode 100644 packages/lib/src/providers/registry/dataforseo.ts delete mode 100644 packages/lib/src/providers/registry/mistral-api.test.ts delete mode 100644 packages/lib/src/providers/registry/mistral-api.ts delete mode 100644 packages/lib/src/providers/registry/olostep.ts delete mode 100644 packages/lib/src/providers/registry/openai-api.test.ts delete mode 100644 packages/lib/src/providers/registry/openai-api.ts delete mode 100644 packages/lib/src/providers/registry/openrouter.test.ts delete mode 100644 packages/lib/src/providers/registry/openrouter.ts delete mode 100644 packages/lib/src/providers/registry/oxylabs.test.ts delete mode 100644 packages/lib/src/providers/registry/oxylabs.ts delete mode 100644 packages/lib/src/providers/registry/scrape-shared.test.ts delete mode 100644 packages/lib/src/providers/registry/scrape-shared.ts delete mode 100644 packages/lib/src/providers/registry/sdk-contract.test.ts delete mode 100644 packages/lib/src/providers/registry/searchapi.test.ts delete mode 100644 packages/lib/src/providers/registry/searchapi.ts create mode 100644 packages/lib/src/providers/registry/structured-output.ts delete mode 100644 packages/ui/src/brand/customers.tsx delete mode 100644 packages/ui/src/lib/cookie-consent.ts diff --git a/.changeset/beatapi-monitoring-platforms.md b/.changeset/beatapi-monitoring-platforms.md new file mode 100644 index 0000000..ffc84b2 --- /dev/null +++ b/.changeset/beatapi-monitoring-platforms.md @@ -0,0 +1,8 @@ +--- +"@workspace/web": patch +"@workspace/lib": patch +"@workspace/config": patch +"@workspace/worker": patch +--- + +Track platforms only through BeatBrowser or BeatAPI with a default model per platform, remove product analytics, and reorder the sidebar into SEO, GEO and Optimize. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index e86c868..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -# Sponsorship is not configured for BeatGEO. diff --git a/.github/contributors.txt b/.github/contributors.txt deleted file mode 100644 index 274873b..0000000 --- a/.github/contributors.txt +++ /dev/null @@ -1 +0,0 @@ -# BeatGEO does not require the upstream CLA. Contributor history is preserved in Git. diff --git a/AGENTS.md b/AGENTS.md index cdd1f09..187adb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Before handing work back or opening a PR, run `pnpm lint` and get it passing; CI ## Environment -`.env` must exist at **both** the repo root and `apps/web/.env` (Vite reads its project root; the worker reads `apps/web/.env` via `--env-file`). Minimum for the local workspace: `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BEAT_GEO_ENCRYPTION_KEY` (`openssl rand -base64 32`), `APP_URL`, `DISABLE_TELEMETRY=1`, and `SCRAPE_TARGETS` plus the keys of the providers it names. Local workspaces establish a private owner/session automatically and expose no registration, login, password, team or organization-switching UI. Local setup remains accessible without a research key; industry starter questions and browser monitoring need no research provider. AI-assisted onboarding and Opportunities require a research provider: `BEATAPI_API_KEY` plus `BEATAPI_RESEARCH_MODEL`, or one direct LLM key (`OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `MISTRAL_API_KEY`) for onboarding and the Opportunities report. For UI-only work, `SCRAPE_TARGETS=stub:stub` and `ONBOARDING_LLM_TARGET=stub:stub` need no keys at all. +`.env` must exist at **both** the repo root and `apps/web/.env` (Vite reads its project root; the worker reads `apps/web/.env` via `--env-file`). Minimum for the local workspace: `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BEAT_GEO_ENCRYPTION_KEY` (`openssl rand -base64 32`), `APP_URL` and `SCRAPE_TARGETS` plus the keys of the providers it names. Local workspaces establish a private owner/session automatically and expose no registration, login, password, team or organization-switching UI. Local setup remains accessible without a research key; industry starter questions and browser monitoring need no research provider. AI-assisted onboarding and Opportunities require `BEATAPI_API_KEY` plus `BEATAPI_RESEARCH_MODEL`. Monitoring reaches platforms only through BeatBrowser (`beat-browser`) or BeatAPI (`beatapi`); see `docs/providers.md`. For UI-only work, `SCRAPE_TARGETS=stub:stub` and `ONBOARDING_LLM_TARGET=stub:stub` need no keys at all. ## Git workflow diff --git a/CLA.md b/CLA.md deleted file mode 100644 index 9b39951..0000000 --- a/CLA.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contributions - -BeatGEO uses the MIT license. See CONTRIBUTING.md and THIRD_PARTY_NOTICES.md. No separate upstream contributor agreement is required. diff --git a/apps/web/package.json b/apps/web/package.json index 2bb14f3..ad02583 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,7 +49,6 @@ "lucide-react": "^1.51.0", "pg": "^8.23.1", "pg-boss": "^12.36.0", - "posthog-js": "^1.435.8", "react": "^19.3.0", "react-dom": "^19.3.0", "react-icons": "^5.7.0", diff --git a/apps/web/src/components/__tests__/response-markdown.test.tsx b/apps/web/src/components/__tests__/response-markdown.test.tsx index 2fc39dd..7540f6a 100644 --- a/apps/web/src/components/__tests__/response-markdown.test.tsx +++ b/apps/web/src/components/__tests__/response-markdown.test.tsx @@ -1,12 +1,7 @@ /** - * Every provider stores its answers in a different shape, and each one reaches - * the prompt detail page through `extractTextContent` before it is rendered. - * A provider whose extraction hands back flattened text or glued-together - * blocks looks identical to a broken renderer from the page's side, so the two - * halves are checked together here rather than in isolation. - * - * The shapes mirror the fixtures in each provider's own tests in - * `packages/lib/src/providers/registry`. + * Stored answers reach the prompt detail page through `extractTextContent` + * before they are rendered, so extraction and rendering are checked together: + * flattened text looks identical to a broken renderer from the page's side. */ import { extractTextContent } from "@workspace/lib/text-extraction"; import { renderToStaticMarkup } from "react-dom/server"; @@ -14,79 +9,22 @@ import { describe, expect, it } from "vitest"; import { ResponseMarkdown } from "@/components/response-markdown"; const PROSE = "Here are the **top** trackers:"; -const TABLE = "| Tool | Price |\n| --- | --- |\n| Profound | $99 |"; +const TABLE = "| Tool | Price |\n| --- | --- |\n| Example Tracker | $99 |"; const ANSWER = `${PROSE}\n\n${TABLE}`; const render = (rawOutput: unknown, provider: string) => renderToStaticMarkup({extractTextContent(rawOutput, provider)}); -/** One answer, one shape per provider surface. */ const SURFACES: [name: string, provider: string, rawOutput: unknown][] = [ - ["openai-api", "openai-api", { output: [{ type: "message", content: [{ type: "output_text", text: ANSWER }] }] }], - ["anthropic-api", "anthropic-api", { content: [{ type: "text", text: ANSWER }] }], - ["mistral-api", "mistral-api", { outputs: [{ content: [{ type: "text", text: ANSWER }] }] }], - ["openrouter", "openrouter", { choices: [{ message: { content: ANSWER } }] }], - ["olostep", "olostep", { json_content: JSON.stringify({ result: { markdown_content: ANSWER } }) }], - ["brightdata chatbot", "brightdata", [{ answer_text_markdown: ANSWER, answer_text: "flattened" }]], - ["brightdata AI Overview", "brightdata", [{ ai_overview: { markdown: ANSWER } }]], - ["oxylabs ChatGPT", "oxylabs", { results: [{ content: { markdown_text: ANSWER } }] }], - ["oxylabs Perplexity", "oxylabs", { results: [{ content: { answer_results_md: ANSWER } }] }], - ["cloro chatbot", "cloro", { markdown: ANSWER, text: "flattened" }], - ["cloro AI Overview", "cloro", { aioverview: { markdown: ANSWER, text: "flattened" } }], - ["dataforseo scraper", "dataforseo", { tasks: [{ result: [{ markdown: ANSWER, sources: [] }] }] }], - [ - "dataforseo LLM Responses", - "dataforseo", - { tasks: [{ result: [{ items: [{ type: "message", sections: [{ type: "text", text: ANSWER }] }] }] }] }, - ], - [ - "dataforseo AI Overview", - "dataforseo", - { tasks: [{ result: [{ items: [{ type: "ai_overview", markdown: ANSWER }] }] }] }, - ], - // Rows written before the provider column existed dispatch on the model. - [ - "legacy row without a provider", - "chatgpt", - { output: [{ type: "message", content: [{ type: "output_text", text: ANSWER }] }] }, - ], + ["BeatBrowser observation", "beat-browser", { status: "completed", answer: ANSWER, citations: [] }], + ["BeatAPI answer", "beatapi", { choices: [{ finish_reason: "stop", message: { content: ANSWER } }] }], ]; describe("answer rendering across providers", () => { - it.each(SURFACES)("renders %s answers as markdown", (_name, provider, rawOutput) => { + it.each(SURFACES)("renders %s as markdown", (_name, provider, rawOutput) => { const html = render(rawOutput, provider); expect(html).toContain(""); expect(html).toContain("top"); - expect(html).not.toContain("flattened"); - }); - - /** - * These three surfaces arrive as a list of blocks rather than one string. - * Joined with a single newline they render as one run-on paragraph, which - * is what the extraction's blank-line join exists to prevent. - */ - it.each([ - [ - "brightdata AI Overview", - "brightdata", - [{ ai_overview: { texts: [{ snippet: "First." }, { snippet: "Second." }] } }], - ], - [ - "dataforseo scraper items", - "dataforseo", - { tasks: [{ result: [{ sources: [], items: [{ markdown: "First." }, { markdown: "Second." }] }] }] }, - ], - [ - "oxylabs AI Overview", - "oxylabs", - { - results: [ - { content: { ai_overviews: [{ answer_text: [{ fragments: [{ text: "First." }, { text: "Second." }] }] }] } }, - ], - }, - ], - ])("keeps %s blocks as separate paragraphs", (_name, provider, rawOutput) => { - expect(render(rawOutput, provider).match(/

    /g)).toHaveLength(2); }); it("does not load relative or protocol-relative images", () => { diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index d3a223a..01c03b0 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -43,13 +43,45 @@ export type AppSidebarProps = ShellScope; type SidebarBrand = Pick; +/** + * SEO comes first because search is the foundation AI answers draw on; GEO + * carries the most entries because monitoring AI answers is the product's core; + * Optimize closes the loop with actions and matched retests. + */ function brandGroups(organization: OrganizationSummary, brand: SidebarBrand, seoEnabled: boolean): NavGroup[] { const params = brandLinkParams(organization, brand); - const dashboard: NavItem[] = [ - { title: "Overview", link: { to: "/app/org/$org/brand/$brand", params }, icon: IconDashboard, exact: true }, + const seoPage = (view: "home" | "actions") => SEO_PAGES.find((page) => page.view === view)!; + + const seo: NavItem[] = [ + { + title: "Search performance", + link: { to: "/app/org/$org/brand/$brand/search-console", params }, + icon: IconChartBar, + }, + { + title: "Search diagnosis", + link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "search" }, + icon: IconSearch, + }, ]; + if (seoEnabled) + seo.push( + { + title: seoPage("home").title, + link: { to: "/app/org/$org/brand/$brand/seo", params, hash: "" }, + icon: IconWorld, + exact: true, + }, + { + title: seoPage("actions").title, + link: { to: "/app/org/$org/brand/$brand/seo", params, hash: "actions" }, + icon: IconFileText, + exact: true, + }, + ); - dashboard.push( + const geo: NavItem[] = [ + { title: "Overview", link: { to: "/app/org/$org/brand/$brand", params }, icon: IconDashboard, exact: true }, { title: "Questions", link: { to: "/app/org/$org/brand/$brand/settings/prompts", params }, @@ -61,75 +93,37 @@ function brandGroups(organization: OrganizationSummary, brand: SidebarBrand, seo matchLinks: INSIGHT_PAGES.slice(1).map((page) => ({ to: page.to, params })), icon: IconChartBar, }, + { title: "Traffic analysis", link: { to: "/app/org/$org/brand/$brand/traffic", params }, icon: IconLink }, + { + title: "Competitors", + link: { to: "/app/org/$org/brand/$brand/settings/competitors", params }, + icon: IconBuildings, + }, + { + title: "Monitoring platforms", + link: { to: "/app/org/$org/brand/$brand/settings/llms", params }, + icon: IconCpu, + }, + ]; + + const optimize: NavItem[] = [ { title: "GEO recommendations", link: { to: "/app/org/$org/brand/$brand/opportunities", params }, icon: IconTarget, }, - ); - - const groups: NavGroup[] = []; - if (seoEnabled) - groups.push({ - label: "SEO", - items: [ - { - title: "Search diagnosis", - link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "search" }, - icon: IconTarget, - }, - ...SEO_PAGES.map( - (page): NavItem => ({ - title: page.title, - link: { to: "/app/org/$org/brand/$brand/seo", params, hash: page.view === "home" ? "" : page.view }, - icon: { - home: IconDashboard, - domain: IconWorld, - keywords: IconSearch, - backlinks: IconLink, - results: IconHistory, - actions: IconFileText, - }[page.view], - exact: true, - }), - ), - { - title: "Search performance", - link: { to: "/app/org/$org/brand/$brand/search-console", params }, - icon: IconChartBar, - }, - ], - }); - groups.unshift({ label: "GEO", items: dashboard }); - groups.push({ - label: "Analytics", - items: [ - { title: "Traffic analysis", link: { to: "/app/org/$org/brand/$brand/traffic", params }, icon: IconChartBar }, - { - title: "All improvement actions", - link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "all-actions" }, - icon: IconFileText, - }, - ], - }); - - groups.push({ - label: "Settings", - items: [ - { - title: "Competitors", - link: { to: "/app/org/$org/brand/$brand/settings/competitors", params }, - icon: IconBuildings, - }, - { - title: "Monitoring platforms", - link: { to: "/app/org/$org/brand/$brand/settings/llms", params }, - icon: IconCpu, - }, - ], - }); + { + title: "Actions & retests", + link: { to: "/app/org/$org/brand/$brand/opportunities", params, hash: "all-actions" }, + icon: IconHistory, + }, + ]; - return groups; + return [ + { label: "SEO", items: seo }, + { label: "GEO", items: geo }, + { label: "Optimize", items: optimize }, + ]; } /** Developer and learning pages, shared by every page so the rail never changes shape. */ diff --git a/apps/web/src/components/prompt-suggestions.tsx b/apps/web/src/components/prompt-suggestions.tsx index ef326cd..c800852 100644 --- a/apps/web/src/components/prompt-suggestions.tsx +++ b/apps/web/src/components/prompt-suggestions.tsx @@ -7,7 +7,6 @@ import { type Ref, type RefObject, useEffect, useRef, useState } from "react"; import { type EditablePrompt, newPromptEntry } from "@/components/prompts-list-editor"; import { usePolledBrandJob } from "@/hooks/use-polled-brand-job"; import { useT } from "@/lib/i18n"; -import { trackEvent } from "@/lib/posthog"; import { cancelPromptSuggestionsFn, getPromptSuggestionsStatusFn, @@ -76,7 +75,6 @@ export function usePromptSuggestions({ start: () => { setError(null); setNothingNew(false); - trackEvent("prompt_suggestions_requested"); job.run(); }, cancel: job.cancel, diff --git a/apps/web/src/components/prompt-wizard.tsx b/apps/web/src/components/prompt-wizard.tsx index 0bd8d25..894e99a 100644 --- a/apps/web/src/components/prompt-wizard.tsx +++ b/apps/web/src/components/prompt-wizard.tsx @@ -25,7 +25,6 @@ import { dashboardKeys } from "@/hooks/use-dashboard-summary"; import { usePolledBrandJob } from "@/hooks/use-polled-brand-job"; import { promptsSummaryKeys } from "@/hooks/use-prompts-summary"; import { useBrandParams } from "@/hooks/use-route-params"; -import { trackEvent } from "@/lib/posthog"; import { useWriteErrorMessage } from "@/lib/write-errors"; import { cancelAnalyzeBrandFn, @@ -159,10 +158,6 @@ export default function PromptWizard() { ), }); setReviewing(true); - trackEvent("onboarding_analyzed", { - competitor_count: suggestion.competitors.length, - prompt_count: suggestion.suggestedPrompts.length, - }); }, onError: setError, startErrorFallback: "Analysis failed", @@ -222,12 +217,6 @@ export default function PromptWizard() { }, }); - trackEvent("wizard_completed", { - prompts_created: promptsPayload.length, - competitors_created: competitorsPayload.length, - skipped: false, - }); - // Deployments without an onboardingRedirectUrlTemplate (e.g. local mode) skip the full reload, so caches fetched while !onboarded must be busted explicitly. queryClient.invalidateQueries({ queryKey: brandKeys.all }); queryClient.invalidateQueries({ queryKey: dashboardKeys.all }); diff --git a/apps/web/src/components/prompts-editor.tsx b/apps/web/src/components/prompts-editor.tsx index 6c1ac2b..b5ea274 100644 --- a/apps/web/src/components/prompts-editor.tsx +++ b/apps/web/src/components/prompts-editor.tsx @@ -12,7 +12,6 @@ import { type EditablePrompt, PromptsListEditor } from "@/components/prompts-lis import { UnsavedChangesBar } from "@/components/unsaved-changes-bar"; import { useInvalidatePromptsSummary } from "@/hooks/use-prompts-summary"; import { useT } from "@/lib/i18n"; -import { trackEvent } from "@/lib/posthog"; import { useWriteErrorMessage } from "@/lib/write-errors"; import { updatePromptsFn } from "@/server/prompts"; @@ -144,13 +143,6 @@ export function PromptsEditor({ initialPrompts, brandId, pageTitle, pageDescript const saved = await updatePromptsFn({ data: { brandId, prompts: allPrompts } }); - trackEvent("prompts_updated", { - added: addedCount, - edited: editedCount, - deleted: removedCount, - suggested: validPrompts.filter((p) => p.suggested && !p.id).length, - }); - // Adopt the server's rows so newly inserted prompts carry their real // ids — re-saving without them would insert duplicates. const next = toEditablePrompts(saved); diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts index cdd53a3..559e9b4 100644 --- a/apps/web/src/env.d.ts +++ b/apps/web/src/env.d.ts @@ -3,17 +3,6 @@ // Every var here must have an entry in packages/config/src/env-registry.ts // (enforced by env-registry.test.ts). -interface ImportMetaEnv { - // Analytics - readonly VITE_PLAUSIBLE_DOMAIN?: string; - readonly VITE_CLARITY_PROJECT_ID?: string; - readonly VITE_POSTHOG_KEY?: string; -} - -interface ImportMeta { - readonly env: ImportMetaEnv; -} - // Server-side environment variables (accessed via process.env in server functions) // and Vite define globals declare global { @@ -33,31 +22,17 @@ declare global { readonly BEAT_BROWSER_ARGS_JSON?: string; readonly BROWSER_RUNNER_TOKEN?: string; - readonly OPENAI_API_KEY: string; - readonly ANTHROPIC_API_KEY: string; - readonly MISTRAL_API_KEY?: string; - readonly OPENROUTER_API_KEY?: string; - readonly SEARCHAPI_API_KEY?: string; - readonly OLOSTEP_API_KEY?: string; - readonly BRIGHTDATA_API_TOKEN?: string; - readonly OXYLABS_USERNAME?: string; - readonly OXYLABS_PASSWORD?: string; - readonly CLORO_API_KEY?: string; readonly JINA_API_KEY?: string; readonly BEATAPI_API_KEY?: string; readonly BEATAPI_RESEARCH_MODEL?: string; readonly ONBOARDING_LLM_TARGET?: string; readonly BEAT_GEO_ENCRYPTION_KEY?: string; - readonly BEAT_GEO_POSTHOG_KEY?: string; readonly BEAT_GEO_ENCRYPTION_KEY_OLD?: string; - readonly DATAFORSEO_LOGIN: string; - readonly DATAFORSEO_PASSWORD: string; readonly BETTER_AUTH_SECRET?: string; readonly ADMIN_API_KEYS?: string; readonly DEFAULT_BRAND_DOMAINS?: string; readonly ENVIRONMENT?: string; - readonly DISABLE_TELEMETRY?: string; } } } diff --git a/apps/web/src/lib/__tests__/fanout-analysis.test.ts b/apps/web/src/lib/__tests__/fanout-analysis.test.ts index 62b716a..ab91687 100644 --- a/apps/web/src/lib/__tests__/fanout-analysis.test.ts +++ b/apps/web/src/lib/__tests__/fanout-analysis.test.ts @@ -161,14 +161,14 @@ describe("computeFanoutAnalysis: 'unavailable' sentinel", () => { // store ["unavailable"] in web_queries when a search happened but the strings // aren't exposed. It's not a real fan-out query and must never reach the page. const breakdown: FanoutBreakdownRow[] = [ - { prompt_id: "p1", model: "openrouter-claude", query: UNAVAILABLE_SENTINEL, count: 6, brand_mentions: 2 }, - { prompt_id: "p1", model: "openrouter-gpt", query: "Unavailable", count: 3, brand_mentions: 1 }, // mixed case + { prompt_id: "p1", model: "api-claude", query: UNAVAILABLE_SENTINEL, count: 6, brand_mentions: 2 }, + { prompt_id: "p1", model: "api-gpt", query: "Unavailable", count: 3, brand_mentions: 1 }, // mixed case { prompt_id: "p1", model: "chatgpt", query: "best crm software 2026", count: 4, brand_mentions: 3 }, ]; // The SQL filters the sentinel, so OpenRouter's model totals come back at 0. const modelTotals: FanoutModelTotalRow[] = [ - { model: "openrouter-claude", runs: 8, fanout_runs: 0, total_queries: 0 }, - { model: "openrouter-gpt", runs: 5, fanout_runs: 0, total_queries: 0 }, + { model: "api-claude", runs: 8, fanout_runs: 0, total_queries: 0 }, + { model: "api-gpt", runs: 5, fanout_runs: 0, total_queries: 0 }, { model: "chatgpt", runs: 4, fanout_runs: 4, total_queries: 4 }, ]; const a = computeFanoutAnalysis(breakdown, modelTotals, promptMap); @@ -184,8 +184,8 @@ describe("computeFanoutAnalysis: 'unavailable' sentinel", () => { it("surfaces sentinel-only providers in the per-model breakdown with zero fan-out", () => { const byModel = Object.fromEntries(a.byModel.map((m) => [m.model, m])); - expect(byModel["openrouter-claude"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 }); - expect(byModel["openrouter-gpt"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 }); + expect(byModel["api-claude"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 }); + expect(byModel["api-gpt"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 }); expect(byModel.chatgpt.totalQueries).toBe(4); }); }); 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 d019a28..a3db398 100644 --- a/apps/web/src/lib/__tests__/postgres-read.integration.test.ts +++ b/apps/web/src/lib/__tests__/postgres-read.integration.test.ts @@ -243,12 +243,12 @@ describe("model filter", () => { const grounded = await createRun(brandId, promptId, { at, brandMentioned: true, - provider: "openai-api", + provider: "beatapi", webSearch: true, }); - const scraped = await createRun(brandId, promptId, { at, brandMentioned: false, provider: "brightdata" }); - await createRun(brandId, promptId, { at, brandMentioned: false, provider: "openai-api", webSearch: false }); - await createRun(brandId, promptId, { at, brandMentioned: true, model: "claude", provider: "anthropic-api" }); + const scraped = await createRun(brandId, promptId, { at, brandMentioned: false, provider: "beat-browser" }); + await createRun(brandId, promptId, { at, brandMentioned: false, provider: "beatapi", webSearch: false }); + await createRun(brandId, promptId, { at, brandMentioned: true, model: "claude", provider: "beatapi" }); await createCitation(grounded, "https://a.example/1"); await createCitation(scraped, "https://b.example/1"); await createCitation(scraped, "https://b.example/2"); diff --git a/apps/web/src/lib/breadcrumbs.ts b/apps/web/src/lib/breadcrumbs.ts index 3c7c3b9..ae02313 100644 --- a/apps/web/src/lib/breadcrumbs.ts +++ b/apps/web/src/lib/breadcrumbs.ts @@ -74,18 +74,17 @@ function pageCrumbs(label: string | undefined, pathname: string, hash: string): if (label === "Opportunities" && ["search", "seo-actions", "all-actions"].includes(hash)) return [ { - label: hash === "all-actions" ? "Analytics" : "SEO", - href: pathname.replace(/opportunities$/, hash === "all-actions" ? "traffic" : "seo"), + label: hash === "all-actions" ? "Optimize" : "SEO", + href: hash === "all-actions" ? pathname : pathname.replace(/opportunities$/, "seo"), }, { - label: - hash === "search" ? "Search diagnosis" : hash === "seo-actions" ? "SEO actions" : "All improvement actions", + label: hash === "search" ? "Search diagnosis" : hash === "seo-actions" ? "SEO actions" : "Actions & retests", href: `${pathname}#${hash}`, }, ]; if (label === "Traffic analysis") return [ - { label: "Analytics", href: pathname }, + { label: "GEO", href: pathname.replace(/traffic$/, "") }, { label, href: pathname }, ]; if (label === "Search performance") diff --git a/apps/web/src/lib/clarity.ts b/apps/web/src/lib/clarity.ts deleted file mode 100644 index 0afb7cc..0000000 --- a/apps/web/src/lib/clarity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { onAnalyticsConsent } from "@workspace/ui/lib/cookie-consent"; - -type ClarityApi = ((command: string, ...args: unknown[]) => void) & { q?: unknown[][] }; - -declare global { - interface Window { - clarity?: ClarityApi; - } -} - -let injected = false; - -function load(projectId: string): void { - if (injected) return; - injected = true; - // Clarity replays this queue once its tag loads, so a choice made while the - // script is still in flight isn't lost. - window.clarity ??= Object.assign( - (...args: unknown[]) => { - window.clarity?.q?.push(args); - }, - { q: [] as unknown[][] }, - ); - const script = document.createElement("script"); - script.async = true; - script.src = `https://www.clarity.ms/tag/${projectId}`; - document.head.appendChild(script); -} - -/** - * Load Microsoft Clarity only while analytics consent is in effect. - * - * Clarity records session replays, so the tag is never fetched before the - * visitor has answered — which is the whole reason it isn't a `