AI Visibility: competitor Share of Voice + clarity fixes for AI citations (#248)

This commit is contained in:
Ben Senescu 2026-06-09 22:33:11 -04:00 committed by GitHub
parent 2db6fdec71
commit 8a85b7b794
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2551 additions and 633 deletions

View File

@ -345,6 +345,16 @@ Searching ten pages deep costs 8x more than one page. Tracking both devices cost
- Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each. - Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each.
- Exact cost can vary slightly based on returned rows and DataForSEO pricing. - Exact cost can vary slightly based on returned rows and DataForSEO pricing.
### 6) AI Search — Brand Lookup
- One lookup = 6 DataForSEO AI Optimization calls (`aggregated_metrics` + `top_pages` + `mentions_search` across ChatGPT and Google AI Overview): up to about `$0.85` per lookup.
- `aggregated_metrics`: `$0.101` per platform.
- `top_pages`: page-ranked cited sources per platform.
- `mentions_search`: row-priced; `$0.20` per platform at the app's full 100-row sample (lower-volume brands return fewer rows and cost less).
- Adding competitors (Share of Voice) adds 2 `cross_aggregated_metrics` calls: about `$0.10` each, `$0.20` total.
- Results are cached for 24 hours, so repeating the same lookup (same target + competitor set) is free within a day.
- Re-measure anytime with `pnpm billing:brand-lookup --target=example.com --competitors=a.com,b.com --confirmLive=true`.
### Planning examples ### Planning examples
- 100 keyword research requests at the default 150 results: `$3.50` - 100 keyword research requests at the default 150 results: `$3.50`

View File

@ -4,11 +4,14 @@ import {
CHATGPT_LANGUAGE_CODE, CHATGPT_LANGUAGE_CODE,
CHATGPT_LOCATION_CODE, CHATGPT_LOCATION_CODE,
fetchLlmAggregatedMetrics, fetchLlmAggregatedMetrics,
fetchLlmCrossAggregatedMetrics,
fetchLlmMentionsSearch, fetchLlmMentionsSearch,
fetchLlmTopPages, fetchLlmTopPages,
type LlmPlatform, type LlmPlatform,
} from "@/server/lib/dataforseo/ai"; } from "@/server/lib/dataforseo/ai";
import { applyBillingMarkupUsd } from "@/shared/billing"; import { applyBillingMarkupUsd } from "@/shared/billing";
import { resolveCompetitorGroups } from "@/server/features/ai-search/services/shareOfVoice";
import { parseCompetitorList } from "@/types/schemas/ai-search";
import { loadLocalEnv, parseArgs } from "./cli-utils"; import { loadLocalEnv, parseArgs } from "./cli-utils";
loadLocalEnv(); loadLocalEnv();
@ -48,8 +51,22 @@ async function main() {
const userLocationCode = parsePositiveInteger(args.locationCode, 2840); const userLocationCode = parsePositiveInteger(args.locationCode, 2840);
const userLanguageCode = args.languageCode ?? "en"; const userLanguageCode = args.languageCode ?? "en";
const repeat = parsePositiveInteger(args.repeat, 1); const repeat = parsePositiveInteger(args.repeat, 1);
// Optional comma-separated competitors — adds the Share of Voice
// cross_aggregated_metrics call per platform, mirroring the service.
const competitors = parseCompetitorList(args.competitors ?? "");
const competitorGroups = resolveCompetitorGroups(target, competitors);
const llmTarget = buildLlmTarget({ type: targetType, value: target }); const llmTarget = buildLlmTarget({ type: targetType, value: target });
const crossGroups = [
{ key: target, target: llmTarget },
...competitorGroups.map((competitor) => {
const detected = competitor.detected;
return {
key: competitor.label,
target: buildLlmTarget({ type: detected.type, value: detected.value }),
};
}),
];
const platforms: LlmPlatform[] = ["chat_gpt", "google"]; const platforms: LlmPlatform[] = ["chat_gpt", "google"];
const allRuns: RunSummary[] = []; const allRuns: RunSummary[] = [];
@ -83,20 +100,42 @@ async function main() {
}); });
calls.push(toRecord(platform, "top_pages", topPages.billing)); calls.push(toRecord(platform, "top_pages", topPages.billing));
// Prompt rows provide examples for the cited-source table.
const mentions = await fetchLlmMentionsSearch({ const mentions = await fetchLlmMentionsSearch({
target: llmTarget, target: llmTarget,
platform, platform,
locationCode, locationCode,
languageCode, languageCode,
limit: 25, limit: 100,
}); });
calls.push(toRecord(platform, "mentions_search", mentions.billing)); calls.push(toRecord(platform, "mentions_search", mentions.billing));
if (competitorGroups.length > 0) {
const cross = await fetchLlmCrossAggregatedMetrics({
groups: crossGroups,
platform,
locationCode,
languageCode,
});
calls.push(
toRecord(platform, "cross_aggregated_metrics", cross.billing),
);
}
} }
const totalRawUsd = sum(calls.map((c) => c.rawUsd)); const totalRawUsd = sum(calls.map((c) => c.rawUsd));
const crossRawUsd = sum(
calls
.filter((c) => c.endpoint === "cross_aggregated_metrics")
.map((c) => c.rawUsd),
);
allRuns.push({ allRuns.push({
run: runIndex + 1, run: runIndex + 1,
calls, calls,
// Split out so the base "Est. $X" and the "+$Y to compare competitors"
// UI constants can each be checked against reality.
baseRawUsd: round(totalRawUsd - crossRawUsd),
crossRawUsd: round(crossRawUsd),
totalRawUsd: round(totalRawUsd), totalRawUsd: round(totalRawUsd),
totalBilledUsd: applyBillingMarkupUsd(totalRawUsd), totalBilledUsd: applyBillingMarkupUsd(totalRawUsd),
}); });
@ -113,6 +152,7 @@ async function main() {
targetType, targetType,
userLocationCode, userLocationCode,
userLanguageCode, userLanguageCode,
competitors: competitorGroups.map((group) => group.label),
repeat, repeat,
}, },
runs: allRuns, runs: allRuns,
@ -140,6 +180,8 @@ type CallRecord = {
type RunSummary = { type RunSummary = {
run: number; run: number;
calls: CallRecord[]; calls: CallRecord[];
baseRawUsd: number;
crossRawUsd: number;
totalRawUsd: number; totalRawUsd: number;
totalBilledUsd: number; totalBilledUsd: number;
}; };
@ -183,7 +225,7 @@ function round(value: number): number {
function printUsageAndExit(message: string): never { function printUsageAndExit(message: string): never {
console.error(message); console.error(message);
console.error( console.error(
"Usage: pnpm billing:brand-lookup --target=example.com --confirmLive=true [--targetType=domain|keyword] [--locationCode=2840] [--languageCode=en] [--repeat=1] [--allowCi=true]", "Usage: pnpm billing:brand-lookup --target=example.com --confirmLive=true [--targetType=domain|keyword] [--competitors=a.com,b.com] [--locationCode=2840] [--languageCode=en] [--repeat=1] [--allowCi=true]",
); );
process.exit(1); process.exit(1);
} }

View File

@ -23,24 +23,29 @@ import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSear
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate"; import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess"; import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory"; import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search"; import {
BRAND_LOOKUP_MAX_INPUT_LENGTH,
parseCompetitorList,
} from "@/types/schemas/ai-search";
import { detectTarget } from "@/shared/targetDetection";
type Props = { type Props = {
projectId: string; projectId: string;
initialQuery: string; initialQuery: string;
onQueryChange: (next: string) => void; initialCompetitors: string[];
onSearchChange: (nextQuery: string, nextCompetitors: string[]) => void;
}; };
const BRAND_LOOKUP_BULLETS = [ const BRAND_LOOKUP_BULLETS = [
{ {
icon: TrendingUp, icon: TrendingUp,
title: "Track AI visibility", title: "Track AI visibility",
body: "Count how often ChatGPT and Google AI Overview cite your brand, and watch the trend month over month.", body: "See estimated counts for ChatGPT and Google AI Overview answers that cite your brand, and watch the trend month over month.",
}, },
{ {
icon: Quote, icon: Quote,
title: "See the prompts", title: "See the prompts",
body: "View the actual user questions where LLMs reference your domain — the real demand driving AI traffic.", body: "View sample user questions where LLMs reference your brand or domain.",
}, },
{ {
icon: BarChart3, icon: BarChart3,
@ -60,24 +65,38 @@ export function BrandLookupPage(props: Props) {
function BrandLookupPageInner({ function BrandLookupPageInner({
projectId, projectId,
initialQuery, initialQuery,
onQueryChange, initialCompetitors,
onSearchChange,
planGate, planGate,
}: Props & { planGate: HostedPlanGateState }) { }: Props & { planGate: HostedPlanGateState }) {
const [query, setQuery] = useState(initialQuery); const [query, setQuery] = useState(initialQuery);
const [validationError, setValidationError] = useState<string | null>(null); // Raw comma-separated competitor text; parsed into a deduped array on submit.
const [competitorsInput, setCompetitorsInput] = useState(
initialCompetitors.join(", "),
);
// Field-tagged so the error styling lands on the input that caused it.
const [validationError, setValidationError] = useState<{
field: "query" | "competitors";
message: string;
} | null>(null);
const access = useAiSearchAccess(projectId); const access = useAiSearchAccess(projectId);
const trimmedInitialQuery = initialQuery.trim(); const trimmedInitialQuery = initialQuery.trim();
const hasActiveQuery = trimmedInitialQuery.length > 0; const hasActiveQuery = trimmedInitialQuery.length > 0;
// The URL `c` param is the source of truth for the active lookup; the local
// `competitorsInput` text only drives the input until the next submit. A
// stable string key, since `initialCompetitors` is a fresh array each render.
const competitorKey = initialCompetitors.join(",");
const lookupQuery = useQuery({ const lookupQuery = useQuery({
queryKey: ["brand-lookup", projectId, trimmedInitialQuery], queryKey: ["brand-lookup", projectId, trimmedInitialQuery, competitorKey],
queryFn: () => queryFn: () =>
lookupBrand({ lookupBrand({
data: { data: {
projectId, projectId,
query: trimmedInitialQuery, query: trimmedInitialQuery,
competitors: initialCompetitors,
locationCode: 2840, locationCode: 2840,
languageCode: "en", languageCode: "en",
}, },
@ -96,38 +115,83 @@ function BrandLookupPageInner({
// Dedup ref prevents repeat adds — `addSearch` identity is not stable // Dedup ref prevents repeat adds — `addSearch` identity is not stable
// across renders, so we'd otherwise re-write the same item every render. // across renders, so we'd otherwise re-write the same item every render.
const lastAddedQueryRef = useRef<string | null>(null); // Key on query + competitors so changing competitors records a fresh entry.
const lastAddedKeyRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (!hasActiveQuery || !lookupQuery.isSuccess) return; if (!hasActiveQuery || !lookupQuery.isSuccess) return;
if (lastAddedQueryRef.current === trimmedInitialQuery) return; const addedKey = `${trimmedInitialQuery}::${competitorKey}`;
lastAddedQueryRef.current = trimmedInitialQuery; if (lastAddedKeyRef.current === addedKey) return;
addSearch({ query: trimmedInitialQuery }); lastAddedKeyRef.current = addedKey;
}, [hasActiveQuery, lookupQuery.isSuccess, trimmedInitialQuery, addSearch]); addSearch({
query: trimmedInitialQuery,
competitors: competitorKey ? competitorKey.split(",") : [],
});
}, [
hasActiveQuery,
lookupQuery.isSuccess,
trimmedInitialQuery,
competitorKey,
addSearch,
]);
const handleSubmit = (event: FormEvent) => { const handleSubmit = (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
const trimmed = query.trim(); const trimmed = query.trim();
if (trimmed.length === 0) { if (trimmed.length === 0) {
setValidationError("Enter a brand name or domain"); setValidationError({
field: "query",
message: "Enter a brand name or domain",
});
return; return;
} }
if (trimmed.length > BRAND_LOOKUP_MAX_INPUT_LENGTH) { if (trimmed.length > BRAND_LOOKUP_MAX_INPUT_LENGTH) {
setValidationError( setValidationError({
`Keep it under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`, field: "query",
message: `Keep it under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`,
});
return;
}
const competitors = parseCompetitorList(competitorsInput);
// Mirror the server's input schema (per-item max) and its competitor
// resolution (a competitor that resolves to the target is dropped) so the
// user gets an inline message instead of a generic server error or a
// silently missing Share of Voice section.
const tooLong = competitors.find(
(competitor) => competitor.length > BRAND_LOOKUP_MAX_INPUT_LENGTH,
); );
if (tooLong) {
setValidationError({
field: "competitors",
message: `Keep each competitor under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`,
});
return;
}
const targetValue = detectTarget(trimmed).value.toLowerCase();
const matchesTarget = competitors.find(
(competitor) =>
detectTarget(competitor).value.toLowerCase() === targetValue,
);
if (matchesTarget) {
setValidationError({
field: "competitors",
message: `"${matchesTarget}" matches the brand you're looking up — remove it from competitors`,
});
return; return;
} }
setValidationError(null); setValidationError(null);
onQueryChange(trimmed); onSearchChange(trimmed, competitors);
}; };
// The query input is reset whenever the URL `q` changes — including the // The form inputs are reset whenever the URL `q`/`c` changes — including the
// browser-back path and Cmd+click navigation. This keeps local form state // browser-back path and Cmd+click navigation. This keeps local form state in
// in sync with the URL source-of-truth. // sync with the URL source-of-truth. Depend on the stable `competitorKey`
// string (not the fresh-each-render `initialCompetitors` array) so typing in
// the competitor field isn't clobbered on every render.
useEffect(() => { useEffect(() => {
setQuery(initialQuery); setQuery(initialQuery);
setCompetitorsInput(competitorKey.split(",").join(", "));
setValidationError(null); setValidationError(null);
}, [initialQuery]); }, [initialQuery, competitorKey]);
const isLoading = hasActiveQuery && lookupQuery.isPending; const isLoading = hasActiveQuery && lookupQuery.isPending;
const errorMessage = const errorMessage =
@ -159,7 +223,7 @@ function BrandLookupPageInner({
) : planGate.isFreePlan ? ( ) : planGate.isFreePlan ? (
<AiSearchPaidPlanGate <AiSearchPaidPlanGate
feature="Brand Lookup" feature="Brand Lookup"
description="See how ChatGPT and Google AI Overview cite any brand or domain — total mentions, the prompts driving them, and the pages cited alongside yours." description="See how ChatGPT and Google AI Overview cite any brand or domain — total mentions, sample prompts where it appears, and the pages cited alongside it."
bullets={BRAND_LOOKUP_BULLETS} bullets={BRAND_LOOKUP_BULLETS}
/> />
) : ( ) : (
@ -170,6 +234,11 @@ function BrandLookupPageInner({
setQuery(next); setQuery(next);
if (validationError) setValidationError(null); if (validationError) setValidationError(null);
}} }}
competitors={competitorsInput}
onCompetitorsChange={(next) => {
setCompetitorsInput(next);
if (validationError) setValidationError(null);
}}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isLoading} isLoading={isLoading}
validationError={validationError} validationError={validationError}
@ -194,7 +263,7 @@ function BrandLookupPageInner({
from="/p/$projectId/brand-lookup" from="/p/$projectId/brand-lookup"
to="/p/$projectId/brand-lookup" to="/p/$projectId/brand-lookup"
params={{ projectId }} params={{ projectId }}
search={{ q: undefined }} search={{ q: undefined, c: undefined }}
replace replace
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent" className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
> >
@ -202,7 +271,7 @@ function BrandLookupPageInner({
Recent searches Recent searches
</Link> </Link>
</div> </div>
<BrandLookupResults result={resultData} /> <BrandLookupResults result={resultData} projectId={projectId} />
</> </>
) : !errorMessage ? ( ) : !errorMessage ? (
<BrandLookupHistorySection <BrandLookupHistorySection

View File

@ -44,7 +44,11 @@ export function filterTopPages(
const excludeTerms = parseTerms(filters.exclude); const excludeTerms = parseTerms(filters.exclude);
return rows.filter((row) => { return rows.filter((row) => {
const textFields = [row.url, row.domain] const textFields = [
row.url,
row.domain,
...row.keywords.map((keyword) => keyword.question),
]
.filter((v): v is string => Boolean(v)) .filter((v): v is string => Boolean(v))
.join(" "); .join(" ");

View File

@ -1,11 +1,15 @@
import { useState } from "react";
import { createColumnHelper, type Table } from "@tanstack/react-table"; import { createColumnHelper, type Table } from "@tanstack/react-table";
import { ExternalLink } from "lucide-react"; import { Link } from "@tanstack/react-router";
import { ExternalLink, Sparkles } from "lucide-react";
import { AppDataTable } from "@/client/components/table/AppDataTable"; import { AppDataTable } from "@/client/components/table/AppDataTable";
import { SortableHeader } from "@/client/components/table/SortableHeader"; import { SortableHeader } from "@/client/components/table/SortableHeader";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { numericNullsLast } from "@/client/components/table/nullSafeSort"; import { numericNullsLast } from "@/client/components/table/nullSafeSort";
import { import {
formatCount, formatCount,
formatPlatformLabel, PLATFORM_DOT_CLASS,
PLATFORM_SHORT_LABEL,
} from "@/client/features/ai-search/platformLabels"; } from "@/client/features/ai-search/platformLabels";
import { formatUrlForDisplay } from "@/client/components/table/url"; import { formatUrlForDisplay } from "@/client/components/table/url";
import type { BrandLookupResult } from "@/types/schemas/ai-search"; import type { BrandLookupResult } from "@/types/schemas/ai-search";
@ -14,56 +18,230 @@ type TopPageRow = BrandLookupResult["topPages"][number];
type TopQueryRow = BrandLookupResult["topQueries"][number]; type TopQueryRow = BrandLookupResult["topQueries"][number];
type PlatformKey = TopPageRow["platform"]; type PlatformKey = TopPageRow["platform"];
const PLATFORM_BADGE_CLASS: Record<PlatformKey, string> = { /** Uppercase column header with a hover/focus popover explaining the column. */
chat_gpt: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500", function HeaderWithHelp({
google: "border-sky-500/40 bg-sky-500/10 text-sky-500", label,
}; helpText,
}: {
function PlatformBadge({ platform }: { platform: PlatformKey }) { label: string;
helpText: string;
}) {
return ( return (
<span className={`badge badge-sm border ${PLATFORM_BADGE_CLASS[platform]}`}> <span className="uppercase tracking-wider">
{formatPlatformLabel(platform)} <HeaderHelpLabel label={label} helpText={helpText} />
</span> </span>
); );
} }
const PLATFORM_HELP =
"Which AI surface produced the answer — ChatGPT or Google AI Overview.";
/**
* Platform indicator used only when a table actually spans >1 platform. A dot +
* short label replaces the old full-width pill that repeated identically on
* every row.
*/
function PlatformCell({ platform }: { platform: PlatformKey }) {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-base-content/70">
<span
className={`size-1.5 rounded-full ${PLATFORM_DOT_CLASS[platform]}`}
/>
{PLATFORM_SHORT_LABEL[platform]}
</span>
);
}
function urlPath(rawUrl: string): string {
try {
const url = new URL(rawUrl);
const path = `${url.pathname}${url.search}`;
return path === "/" ? "" : path;
} catch {
return "";
}
}
function normalizeDomain(value: string): string {
return value.replace(/^www\./i, "").toLowerCase();
}
/**
* The lookup targets a domain with include_subdomains, so the target's own
* pages can surface under any subdomain (docs.acme.com for acme.com) those
* must get the "You" badge too.
*/
function isTargetDomain(domain: string, targetDomain: string): boolean {
const candidate = normalizeDomain(domain);
const target = normalizeDomain(targetDomain);
return candidate === target || candidate.endsWith(`.${target}`);
}
/** Domain-led cited page: bold domain + truncated path, links out. */
function PageUrlCell({
row,
targetDomain,
}: {
row: TopPageRow;
targetDomain: string | null;
}) {
const path = urlPath(row.url);
const isOwn =
targetDomain != null &&
row.domain != null &&
isTargetDomain(row.domain, targetDomain);
return (
<a
href={row.url}
target="_blank"
rel="noreferrer"
className="group block max-w-xl"
>
<span className="inline-flex items-center gap-1.5">
<span className="font-medium text-base-content group-hover:underline">
{row.domain ?? formatUrlForDisplay(row.url)}
</span>
{isOwn ? (
<span className="badge badge-primary badge-xs border-0">You</span>
) : null}
<ExternalLink className="size-3 shrink-0 text-base-content/40" />
</span>
{path ? (
<span className="block truncate text-xs text-base-content/50">
{path}
</span>
) : null}
</a>
);
}
/**
* The prompts (keywords) whose answers cited this page. Shows the top 3 inline;
* if there are more, a "+N more" toggle reveals the rest. Each prompt links into
* Prompt Explorer prefilled with it.
*/
function KeywordsCell({
keywords,
projectId,
brand,
}: {
keywords: TopPageRow["keywords"];
projectId: string;
brand: string;
}) {
const [expanded, setExpanded] = useState(false);
if (keywords.length === 0) {
return <span className="text-base-content/40"></span>;
}
const visible = expanded ? keywords : keywords.slice(0, 3);
const remaining = keywords.length - visible.length;
return (
<div className="space-y-1">
<ul className="space-y-0.5">
{visible.map((keyword) => (
<li key={keyword.question}>
<Link
to="/p/$projectId/prompt-explorer"
params={{ projectId }}
search={{ q: keyword.question, hb: brand || undefined }}
className="group/kw inline-flex items-baseline gap-2 text-xs"
title="Run this prompt in Prompt Explorer"
>
<span className="text-base-content/80 group-hover/kw:underline">
{keyword.question}
</span>
<span
className="shrink-0 tabular-nums text-base-content/40"
title="Prompt volume in the fetched sample"
>
{formatCount(keyword.aiSearchVolume)} vol.
</span>
</Link>
</li>
))}
</ul>
{keywords.length > 3 ? (
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="text-xs text-base-content/50 hover:text-base-content"
>
{expanded ? "Show less" : `+${remaining} more`}
</button>
) : null}
</div>
);
}
const pagesHelper = createColumnHelper<TopPageRow>(); const pagesHelper = createColumnHelper<TopPageRow>();
const queriesHelper = createColumnHelper<TopQueryRow>(); const queriesHelper = createColumnHelper<TopQueryRow>();
export const topPagesColumns = [ export function buildTopPagesColumns({
showPlatform,
targetDomain,
projectId,
brand,
}: {
showPlatform: boolean;
targetDomain: string | null;
projectId: string;
brand: string;
}) {
return [
pagesHelper.accessor("url", { pagesHelper.accessor("url", {
id: "url", id: "url",
header: () => <span className="uppercase tracking-wider">URL</span>, header: () => (
<HeaderWithHelp
label="Source"
helpText="A page cited as a source in AI answers where the searched brand or domain appears."
/>
),
enableSorting: false, enableSorting: false,
cell: ({ row }) => ( cell: ({ row }) => (
<> <PageUrlCell row={row.original} targetDomain={targetDomain} />
<a
href={row.original.url}
target="_blank"
rel="noreferrer"
className="link link-primary inline-flex items-start gap-1 break-all"
>
<span className="break-all">
{formatUrlForDisplay(row.original.url)}
</span>
<ExternalLink className="mt-1 size-3 shrink-0" />
</a>
{row.original.domain ? (
<p className="text-xs text-base-content/50">{row.original.domain}</p>
) : null}
</>
), ),
}), }),
...(showPlatform
? [
pagesHelper.accessor("platform", { pagesHelper.accessor("platform", {
id: "platform", id: "platform",
header: () => <span className="uppercase tracking-wider">Platform</span>, header: () => (
<HeaderWithHelp label="Platform" helpText={PLATFORM_HELP} />
),
enableSorting: false, enableSorting: false,
cell: ({ getValue }) => <PlatformBadge platform={getValue()} />, cell: ({ getValue }) => <PlatformCell platform={getValue()} />,
}), }),
pagesHelper.accessor("mentions", { ]
id: "mentions", : []),
pagesHelper.display({
id: "keywords",
header: () => (
<HeaderWithHelp
label="Cited for"
helpText="Example prompts from the fetched sample where this page was cited."
/>
),
cell: ({ row }) => (
<KeywordsCell
keywords={row.original.keywords}
projectId={projectId}
brand={brand}
/>
),
}),
pagesHelper.accessor("capturedVolume", {
id: "capturedVolume",
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader column={column} label="Mentions" align="right" /> <SortableHeader
column={column}
label="Source vol."
helpText="Estimated monthly prompt demand DataForSEO reports for this cited source, across prompts where the searched brand or domain appears."
align="right"
/>
), ),
cell: ({ getValue }) => ( cell: ({ getValue }) => (
<span className="tabular-nums">{formatCount(getValue())}</span> <span className="tabular-nums">{formatCount(getValue())}</span>
@ -72,11 +250,26 @@ export const topPagesColumns = [
sortDescFirst: true, sortDescFirst: true,
}), }),
]; ];
}
export const topQueriesColumns = [ export function buildTopQueriesColumns({
showPlatform,
projectId,
brand,
}: {
showPlatform: boolean;
projectId: string;
brand: string;
}) {
return [
queriesHelper.accessor("question", { queriesHelper.accessor("question", {
id: "question", id: "question",
header: () => <span className="uppercase tracking-wider">Query</span>, header: () => (
<HeaderWithHelp
label="Query"
helpText="A sampled user prompt whose AI answer cited the searched brand or domain in its text or sources. The prompt itself may not name the brand."
/>
),
enableSorting: false, enableSorting: false,
cell: ({ row }) => ( cell: ({ row }) => (
<> <>
@ -89,16 +282,27 @@ export const topQueriesColumns = [
</> </>
), ),
}), }),
...(showPlatform
? [
queriesHelper.accessor("platform", { queriesHelper.accessor("platform", {
id: "platform", id: "platform",
header: () => <span className="uppercase tracking-wider">Platform</span>, header: () => (
<HeaderWithHelp label="Platform" helpText={PLATFORM_HELP} />
),
enableSorting: false, enableSorting: false,
cell: ({ getValue }) => <PlatformBadge platform={getValue()} />, cell: ({ getValue }) => <PlatformCell platform={getValue()} />,
}), }),
]
: []),
queriesHelper.accessor("aiSearchVolume", { queriesHelper.accessor("aiSearchVolume", {
id: "aiSearchVolume", id: "aiSearchVolume",
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader column={column} label="AI search vol." align="right" /> <SortableHeader
column={column}
label="AI search vol."
helpText="Estimated monthly search demand for this prompt's topic. This is prompt demand, not the number of brand mentions."
align="right"
/>
), ),
cell: ({ getValue }) => ( cell: ({ getValue }) => (
<span className="tabular-nums">{formatCount(getValue())}</span> <span className="tabular-nums">{formatCount(getValue())}</span>
@ -106,13 +310,35 @@ export const topQueriesColumns = [
sortingFn: numericNullsLast, sortingFn: numericNullsLast,
sortDescFirst: true, sortDescFirst: true,
}), }),
queriesHelper.display({
id: "action",
header: () => <span className="sr-only">Actions</span>,
meta: { cellClassName: "w-px whitespace-nowrap text-right align-top" },
cell: ({ row }) => (
<span
className="tooltip tooltip-left opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
data-tip="Run this prompt in Prompt Explorer"
>
<Link
to="/p/$projectId/prompt-explorer"
params={{ projectId }}
search={{ q: row.original.question, hb: brand || undefined }}
className="btn btn-ghost btn-xs gap-1"
aria-label="Run this prompt in Prompt Explorer"
>
<Sparkles className="size-3.5" />
</Link>
</span>
),
}),
]; ];
}
export function TopPagesTable({ table }: { table: Table<TopPageRow> }) { export function TopPagesTable({ table }: { table: Table<TopPageRow> }) {
if (table.getRowModel().rows.length === 0) { if (table.getRowModel().rows.length === 0) {
return ( return (
<p className="p-6 text-center text-sm text-base-content/60"> <p className="p-6 text-center text-sm text-base-content/60">
No cited pages returned. No cited sources to show.
</p> </p>
); );
} }
@ -142,6 +368,7 @@ function BrandLookupTable<T>({
return ( return (
<AppDataTable <AppDataTable
table={table} table={table}
getRowClassName={() => "group transition-colors hover:bg-base-200/40"}
getCellClassName={(_, columnId) => getCellClassName={(_, columnId) =>
cellClassName( cellClassName(
columnId, columnId,
@ -161,6 +388,9 @@ function cellClassName(
if (columnId === urlLikeColumnId) { if (columnId === urlLikeColumnId) {
return "min-w-80 max-w-2xl align-top"; return "min-w-80 max-w-2xl align-top";
} }
if (columnId === "keywords") {
return "max-w-lg align-top";
}
if (isNumeric) { if (isNumeric) {
return "whitespace-nowrap text-right align-top"; return "whitespace-nowrap text-right align-top";
} }

View File

@ -0,0 +1,269 @@
import { useMemo, useState } from "react";
import { type SortingState } from "@tanstack/react-table";
import { ChevronDown, Download, Sheet, SlidersHorizontal } from "lucide-react";
import { useAppTable } from "@/client/components/table/AppDataTable";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import {
buildBrandLookupExport,
downloadBrandLookupCsv,
} from "@/client/features/ai-search/components/brandLookupExport";
import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel";
import {
TopPagesTable,
TopQueriesTable,
buildTopPagesColumns,
buildTopQueriesColumns,
} from "@/client/features/ai-search/components/BrandLookupCitationTables";
import {
formatPlatformLabel,
PLATFORM_DOT_CLASS,
} from "@/client/features/ai-search/platformLabels";
import {
filterQueries,
filterTopPages,
} from "@/client/features/ai-search/brandLookupFiltering";
import { useBrandLookupFilters } from "@/client/features/ai-search/useBrandLookupFilters";
import type { CitationTab } from "@/client/features/ai-search/brandLookupFilterTypes";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
const DEFAULT_PAGES_SORT: SortingState = [{ id: "capturedVolume", desc: true }];
const DEFAULT_QUERIES_SORT: SortingState = [
{ id: "aiSearchVolume", desc: true },
];
// DaisyUI focus-dropdowns stay open until the active element blurs.
function closeExportMenu(): void {
const active = document.activeElement;
if (active instanceof HTMLElement) active.blur();
}
export function CitationTabsCard({
result,
projectId,
}: {
result: BrandLookupResult;
projectId: string;
}) {
const [activeTab, setActiveTab] = useState<CitationTab>("queries");
const [pagesSort, setPagesSort] = useState<SortingState>(DEFAULT_PAGES_SORT);
const [queriesSort, setQueriesSort] =
useState<SortingState>(DEFAULT_QUERIES_SORT);
const filters = useBrandLookupFilters();
// The platform column only earns its place when a tab actually spans >1
// platform; otherwise it repeats one value on every row.
const queryPlatforms = [
...new Set(result.topQueries.map((query) => query.platform)),
];
const pagePlatforms = [
...new Set(result.topPages.map((page) => page.platform)),
];
const showQueryPlatform = queryPlatforms.length > 1;
const showPagePlatform = pagePlatforms.length > 1;
const targetDomain =
result.detectedTargetType === "domain" ? result.resolvedTarget : null;
const filteredPages = useMemo(
() => filterTopPages(result.topPages, filters.pages.values),
[result.topPages, filters.pages.values],
);
const filteredQueries = useMemo(
() => filterQueries(result.topQueries, filters.queries.values),
[result.topQueries, filters.queries.values],
);
const pagesColumns = useMemo(
() =>
buildTopPagesColumns({
showPlatform: showPagePlatform,
targetDomain,
projectId,
brand: result.resolvedTarget,
}),
[showPagePlatform, targetDomain, projectId, result.resolvedTarget],
);
const queriesColumns = useMemo(
() =>
buildTopQueriesColumns({
showPlatform: showQueryPlatform,
projectId,
brand: result.resolvedTarget,
}),
[showQueryPlatform, projectId, result.resolvedTarget],
);
const pagesTable = useAppTable({
data: filteredPages,
columns: pagesColumns,
state: { sorting: pagesSort },
onSortingChange: setPagesSort,
withSorting: true,
// Stable identity (default is the array index): KeywordsCell holds
// expanded state, which must follow the page when filtering/sorting
// reorders rows, not stick to whatever row lands in the same slot.
getRowId: (row) => `${row.platform}:${row.url}`,
});
const queriesTable = useAppTable({
data: filteredQueries,
columns: queriesColumns,
state: { sorting: queriesSort },
onSortingChange: setQueriesSort,
withSorting: true,
});
// Not memoized: TanStack's `getSortedRowModel()` is internally cached, and
// memoing on the table refs alone (which are stable across renders) would
// serve stale data when sort or filters change.
const exportTable = buildBrandLookupExport(
activeTab,
pagesTable.getSortedRowModel().rows.map((row) => row.original),
queriesTable.getSortedRowModel().rows.map((row) => row.original),
);
const handleExportCsv = () => {
downloadBrandLookupCsv(activeTab, result.resolvedTarget, exportTable);
closeExportMenu();
};
const handleExportSheets = () => {
void exportTableToSheets({
headers: exportTable.headers,
rows: exportTable.rows,
feature: `brand_lookup_${activeTab}`,
});
closeExportMenu();
};
const canExport = exportTable.rows.length > 0;
const currentFilterCount = filters[activeTab].activeFilterCount;
const queriesActive = activeTab === "queries";
const pagesActive = activeTab === "pages";
// When the active tab's platform column is hidden, surface the lone platform
// once here instead of repeating it on every row.
const activePlatforms = pagesActive ? pagePlatforms : queryPlatforms;
const captionPlatform =
activePlatforms.length === 1 ? activePlatforms[0] : null;
return (
<section className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3">
<div role="tablist" className="tabs tabs-box w-fit">
<button
type="button"
role="tab"
aria-selected={queriesActive}
className={`tab ${queriesActive ? "tab-active" : ""}`}
onClick={() => setActiveTab("queries")}
>
Queries
</button>
<button
type="button"
role="tab"
aria-selected={pagesActive}
className={`tab ${pagesActive ? "tab-active" : ""}`}
onClick={() => setActiveTab("pages")}
>
Cited sources
</button>
</div>
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
className={`btn btn-ghost btn-sm gap-1.5 ${canExport ? "" : "btn-disabled"}`}
>
<Download className="size-3.5" />
Export
<ChevronDown className="size-3.5" />
</div>
<ul
tabIndex={0}
className="menu dropdown-content z-10 mt-1 w-48 rounded-box border border-base-300 bg-base-100 p-1 shadow"
>
<li>
<button
type="button"
onClick={handleExportSheets}
disabled={!canExport}
>
<Sheet className="size-4" />
Google Sheets
</button>
</li>
<li>
<button
type="button"
onClick={handleExportCsv}
disabled={!canExport}
>
<Download className="size-4" />
CSV
</button>
</li>
</ul>
</div>
</div>
<div className="flex items-center gap-2 border-b border-base-300 px-4 py-2">
<button
type="button"
className={`btn btn-ghost btn-sm gap-1.5 ${filters.showFilters ? "btn-active" : ""}`}
onClick={() => filters.setShowFilters((current) => !current)}
title="Toggle table filters"
>
<SlidersHorizontal className="size-3.5" />
Filters
{currentFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{currentFilterCount}
</span>
) : null}
</button>
</div>
<div className="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-2 text-xs text-base-content/60">
<span>
{activeTab === "pages" ? (
<>
Pages cited alongside{" "}
<strong className="text-base-content/80">
{result.resolvedTarget}
</strong>{" "}
in AI answers. Prompt examples come from the fetched sample.
</>
) : (
<>
Fetched sample of prompts whose AI answer cited{" "}
<strong className="text-base-content/80">
{result.resolvedTarget}
</strong>{" "}
in its text or sources.
</>
)}
</span>
{captionPlatform ? (
<span className="inline-flex shrink-0 items-center gap-1.5 text-base-content/70">
<span
className={`size-1.5 rounded-full ${PLATFORM_DOT_CLASS[captionPlatform]}`}
/>
{formatPlatformLabel(captionPlatform)}
</span>
) : null}
</div>
{filters.showFilters ? (
<BrandLookupFilterPanel activeTab={activeTab} filters={filters} />
) : null}
{activeTab === "pages" ? (
<TopPagesTable table={pagesTable} />
) : (
<TopQueriesTable table={queriesTable} />
)}
</section>
);
}

View File

@ -146,7 +146,7 @@ function TopPagesFilters({
<div className="min-w-[220px]"> <div className="min-w-[220px]">
<FilterRangeInputs <FilterRangeInputs
form={form} form={form}
title="Mentions" title="Source mentions"
minName="minMentions" minName="minMentions"
maxName="maxMentions" maxName="maxMentions"
/> />

View File

@ -25,7 +25,13 @@ export function BrandLookupHistorySection({ projectId, ...props }: Props) {
from="/p/$projectId/brand-lookup" from="/p/$projectId/brand-lookup"
to="/p/$projectId/brand-lookup" to="/p/$projectId/brand-lookup"
params={{ projectId }} params={{ projectId }}
search={{ q: item.query }} search={{
q: item.query,
c:
item.competitors.length > 0
? item.competitors.join(",")
: undefined,
}}
replace replace
className={HISTORY_ITEM_LINK_CLASS} className={HISTORY_ITEM_LINK_CLASS}
> >
@ -33,7 +39,14 @@ export function BrandLookupHistorySection({ projectId, ...props }: Props) {
</Link> </Link>
)} )}
renderItem={(item) => ( renderItem={(item) => (
<p className="font-medium text-base-content truncate">{item.query}</p> <div className="min-w-0">
<p className="truncate font-medium text-base-content">{item.query}</p>
{item.competitors.length > 0 ? (
<p className="truncate text-xs text-base-content/50">
vs {item.competitors.join(", ")}
</p>
) : null}
</div>
)} )}
/> />
); );

View File

@ -1,45 +1,23 @@
import { useMemo, useState } from "react"; import { Info } from "lucide-react";
import { type SortingState } from "@tanstack/react-table";
import { Download, Info, SlidersHorizontal } from "lucide-react";
import { useAppTable } from "@/client/components/table/AppDataTable";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import {
buildBrandLookupExport,
downloadBrandLookupCsv,
} from "@/client/features/ai-search/components/brandLookupExport";
import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard"; import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard";
import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel"; import { BrandLookupShareOfVoice } from "@/client/features/ai-search/components/BrandLookupShareOfVoice";
import { import { CitationTabsCard } from "@/client/features/ai-search/components/BrandLookupCitationsCard";
TopPagesTable,
TopQueriesTable,
topPagesColumns,
topQueriesColumns,
} from "@/client/features/ai-search/components/BrandLookupCitationTables";
import { import {
formatCount, formatCount,
formatPlatformLabel, formatPlatformLabel,
PLATFORM_DOT_CLASS,
} from "@/client/features/ai-search/platformLabels"; } from "@/client/features/ai-search/platformLabels";
import {
filterQueries,
filterTopPages,
} from "@/client/features/ai-search/brandLookupFiltering";
import { useBrandLookupFilters } from "@/client/features/ai-search/useBrandLookupFilters";
import type { CitationTab } from "@/client/features/ai-search/brandLookupFilterTypes";
import type { BrandLookupResult } from "@/types/schemas/ai-search"; import type { BrandLookupResult } from "@/types/schemas/ai-search";
type Props = { type Props = {
result: BrandLookupResult; result: BrandLookupResult;
projectId: string;
}; };
type PlatformRow = BrandLookupResult["perPlatform"][number]; type PlatformRow = BrandLookupResult["perPlatform"][number];
type MetricKey = "mentions" | "aiSearchVolume" | "impressions"; type MetricKey = "mentions" | "aiSearchVolume";
const PLATFORM_DOT_CLASS: Record<PlatformRow["platform"], string> = { export function BrandLookupResults({ result, projectId }: Props) {
chat_gpt: "bg-emerald-500",
google: "bg-sky-500",
};
export function BrandLookupResults({ result }: Props) {
if (!result.hasData) { if (!result.hasData) {
const erroredPlatforms = result.perPlatform.filter( const erroredPlatforms = result.perPlatform.filter(
(p) => p.status === "error", (p) => p.status === "error",
@ -76,17 +54,27 @@ export function BrandLookupResults({ result }: Props) {
} }
const hasTrendData = result.monthlyVolume.length > 0; const hasTrendData = result.monthlyVolume.length > 0;
const sov = result.shareOfVoice;
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<BrandHeader result={result} /> <BrandHeader result={result} />
{/* One shared grid so the cards align by construction: stats left, trend
right, Share of Voice flowing into the next free half-width cell
whichever of trend/SoV is absent, the rest stay column-aligned. A
lone stats card keeps full width instead of half a grid. */}
<div <div
className={`grid gap-4 ${hasTrendData ? "lg:grid-cols-2" : "grid-cols-1"}`} className={
hasTrendData || sov ? "grid gap-4 lg:grid-cols-2" : undefined
}
> >
<KpiTiles result={result} /> <StatsCard result={result} />
{hasTrendData ? <MentionTrendCard result={result} /> : null} {hasTrendData ? <MentionTrendCard result={result} /> : null}
{sov ? <BrandLookupShareOfVoice shareOfVoice={sov} /> : null}
</div> </div>
<CitationTabsCard result={result} />
<CitationTabsCard result={result} projectId={projectId} />
</div> </div>
); );
} }
@ -109,64 +97,54 @@ function BrandHeader({ result }: { result: BrandLookupResult }) {
); );
} }
function KpiTiles({ result }: { result: BrandLookupResult }) { function StatsCard({ result }: { result: BrandLookupResult }) {
return ( return (
<section className="flex flex-col divide-y divide-base-200 rounded-xl border border-base-300 bg-base-100"> <section className="rounded-xl border border-base-300 bg-base-100">
<KpiTile <div className="flex h-full flex-col divide-y divide-base-200">
label="Total mentions" <StatBlock
tooltip="Number of LLM answers where your domain appeared in the text or citations." label="Mentions"
total={result.totalMentions} tooltip="Estimated count of AI answers where the searched brand or domain appeared in the answer text or cited sources."
value={result.totalMentions}
perPlatform={result.perPlatform} perPlatform={result.perPlatform}
metric="mentions" metric="mentions"
/> />
<KpiTile <StatBlock
label="AI search volume" label="AI search volume"
tooltip="Monthly volume of user prompts on topics where your domain shows up in LLM answers." tooltip="Estimated monthly search demand for prompts where the searched brand or domain appears in AI answers. This is prompt demand, not mention count."
total={result.totalAiSearchVolume} value={result.totalAiSearchVolume}
perPlatform={result.perPlatform} perPlatform={result.perPlatform}
metric="aiSearchVolume" metric="aiSearchVolume"
/> />
<KpiTile </div>
label="Estimated impressions"
tooltip="How often your domain is shown to users across LLM answers, based on mention frequency and topic search volume."
total={result.totalImpressions}
perPlatform={result.perPlatform}
metric="impressions"
/>
</section> </section>
); );
} }
function KpiTile({ function StatBlock({
label, label,
tooltip, tooltip,
total, value,
perPlatform, perPlatform,
metric, metric,
}: { }: {
label: string; label: string;
tooltip: string; tooltip: string;
total: number | null; value: number | null;
perPlatform: PlatformRow[]; perPlatform: PlatformRow[];
metric: MetricKey; metric: MetricKey;
}) { }) {
return ( return (
<div className="flex flex-1 items-center justify-between gap-6 px-5 py-3"> <div className="flex flex-1 flex-col justify-center p-4">
<div className="min-w-0">
<p className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-base-content/50"> <p className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-base-content/50">
{label} {label}
<span <span className="tooltip inline-flex normal-case" data-tip={tooltip}>
className="tooltip tooltip-right inline-flex normal-case"
data-tip={tooltip}
>
<Info className="size-3 text-base-content/40" /> <Info className="size-3 text-base-content/40" />
</span> </span>
</p> </p>
<p className="mt-1 text-2xl font-semibold tabular-nums"> <p className="mt-1 text-3xl font-semibold tabular-nums">
{formatCount(total)} {formatCount(value)}
</p> </p>
</div> <div className="mt-3 space-y-1 border-t border-base-200 pt-2.5">
<div className="flex shrink-0 flex-col gap-1.5 min-w-[12rem]">
{perPlatform.map((row) => ( {perPlatform.map((row) => (
<PlatformStatRow key={row.platform} row={row} metric={metric} /> <PlatformStatRow key={row.platform} row={row} metric={metric} />
))} ))}
@ -193,7 +171,7 @@ function PlatformStatRow({
{formatPlatformLabel(row.platform)} {formatPlatformLabel(row.platform)}
{row.platform === "chat_gpt" ? ( {row.platform === "chat_gpt" ? (
<span <span
className="tooltip tooltip-right z-20 inline-flex" className="tooltip z-20 inline-flex"
data-tip="DataForSEO indexes ChatGPT mentions for US English only — country selection is not available for this platform." data-tip="DataForSEO indexes ChatGPT mentions for US English only — country selection is not available for this platform."
> >
<Info className="size-3 text-base-content/40" /> <Info className="size-3 text-base-content/40" />
@ -225,156 +203,6 @@ function MentionTrendCard({ result }: { result: BrandLookupResult }) {
); );
} }
const DEFAULT_PAGES_SORT: SortingState = [{ id: "mentions", desc: true }];
const DEFAULT_QUERIES_SORT: SortingState = [
{ id: "aiSearchVolume", desc: true },
];
function CitationTabsCard({ result }: { result: BrandLookupResult }) {
const [activeTab, setActiveTab] = useState<CitationTab>("queries");
const [pagesSort, setPagesSort] = useState<SortingState>(DEFAULT_PAGES_SORT);
const [queriesSort, setQueriesSort] =
useState<SortingState>(DEFAULT_QUERIES_SORT);
const filters = useBrandLookupFilters();
const filteredPages = useMemo(
() => filterTopPages(result.topPages, filters.pages.values),
[result.topPages, filters.pages.values],
);
const filteredQueries = useMemo(
() => filterQueries(result.topQueries, filters.queries.values),
[result.topQueries, filters.queries.values],
);
const pagesTable = useAppTable({
data: filteredPages,
columns: topPagesColumns,
state: { sorting: pagesSort },
onSortingChange: setPagesSort,
withSorting: true,
});
const queriesTable = useAppTable({
data: filteredQueries,
columns: topQueriesColumns,
state: { sorting: queriesSort },
onSortingChange: setQueriesSort,
withSorting: true,
});
// Not memoized: TanStack's `getSortedRowModel()` is internally cached, and
// memoing on the table refs alone (which are stable across renders) would
// serve stale data when sort or filters change.
const exportTable = buildBrandLookupExport(
activeTab,
pagesTable.getSortedRowModel().rows.map((row) => row.original),
queriesTable.getSortedRowModel().rows.map((row) => row.original),
);
const handleExport = () =>
downloadBrandLookupCsv(activeTab, result.resolvedTarget, exportTable);
const canExport = exportTable.rows.length > 0;
const currentFilterCount = filters[activeTab].activeFilterCount;
const queriesActive = activeTab === "queries";
const pagesActive = activeTab === "pages";
return (
<section className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3">
<div role="tablist" className="tabs tabs-box w-fit">
<button
type="button"
role="tab"
aria-selected={queriesActive}
className={`tab ${queriesActive ? "tab-active" : ""}`}
onClick={() => setActiveTab("queries")}
>
Queries
</button>
<button
type="button"
role="tab"
aria-selected={pagesActive}
className={`tab ${pagesActive ? "tab-active" : ""}`}
onClick={() => setActiveTab("pages")}
>
Related pages
</button>
</div>
<div className="flex items-center gap-2">
<ExportToSheetsButton
headers={exportTable.headers}
rows={exportTable.rows}
feature={`brand_lookup_${activeTab}`}
className="btn-sm"
/>
<button
type="button"
className="btn btn-ghost btn-sm gap-1.5"
onClick={handleExport}
disabled={!canExport}
aria-label="Export current tab as CSV"
>
<Download className="size-3.5" />
Export CSV
</button>
</div>
</div>
<div className="flex items-center gap-2 border-b border-base-300 px-4 py-2">
<button
type="button"
className={`btn btn-ghost btn-sm gap-1.5 ${filters.showFilters ? "btn-active" : ""}`}
onClick={() => filters.setShowFilters((current) => !current)}
title="Toggle table filters"
>
<SlidersHorizontal className="size-3.5" />
Filters
{currentFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{currentFilterCount}
</span>
) : null}
</button>
</div>
<div className="border-b border-base-300 px-4 py-2 text-xs text-base-content/60">
{activeTab === "pages" ? (
<>
Other pages LLMs cited in the same answers that referenced{" "}
<strong className="text-base-content/80">
{result.resolvedTarget}
</strong>
. Useful for spotting the sources competing for attention alongside
your domain.
</>
) : (
<>
User prompts where the LLM's answer referenced{" "}
<strong className="text-base-content/80">
{result.resolvedTarget}
</strong>{" "}
in its text or citations. The prompt itself does not have to mention
your domain.
</>
)}
</div>
{filters.showFilters ? (
<BrandLookupFilterPanel activeTab={activeTab} filters={filters} />
) : null}
{activeTab === "pages" ? (
<TopPagesTable table={pagesTable} />
) : (
<TopQueriesTable table={queriesTable} />
)}
</section>
);
}
function formatRelative(iso: string): string { function formatRelative(iso: string): string {
const date = new Date(iso); const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "just now"; if (Number.isNaN(date.getTime())) return "just now";

View File

@ -7,41 +7,59 @@ import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
type Props = { type Props = {
query: string; query: string;
onQueryChange: (next: string) => void; onQueryChange: (next: string) => void;
competitors: string;
onCompetitorsChange: (next: string) => void;
onSubmit: (event: FormEvent) => void; onSubmit: (event: FormEvent) => void;
isLoading: boolean; isLoading: boolean;
validationError: string | null; validationError: { field: "query" | "competitors"; message: string } | null;
}; };
/** /**
* One brand lookup = 6 DataForSEO calls (3 endpoints × 2 platforms). Measured * One brand lookup = 6 DataForSEO calls (aggregated_metrics + top_pages +
* live at ~$0.634 raw via `pnpm billing:brand-lookup`; rounded up to leave * mentions_search × 2 platforms). Rounded up with headroom because
* headroom for per-query variance. * mentions_search is row-priced at the full 100-row sample per platform.
*/ */
const BRAND_LOOKUP_RAW_COST_USD = 0.65; const BRAND_LOOKUP_RAW_COST_USD = 0.85;
/**
* Adding competitors triggers 2 extra cross_aggregated_metrics calls (one per
* platform). Measured live (Jun 2026) at $0.101 each $0.202 total for a
* 4-group comparison via `pnpm billing:brand-lookup --competitors=...`. A
* fixed estimate, marked up once at module load exactly like the base.
*/
const BRAND_LOOKUP_COMPETITOR_RAW_COST_USD = 0.2;
// Hosted customers are billed the marked-up USD; self-hosted users pay // Hosted customers are billed the marked-up USD; self-hosted users pay
// DataForSEO directly at the raw rate. // DataForSEO directly at the raw rate.
const BRAND_LOOKUP_DISPLAYED_COST_USD = isHostedClientAuthMode() const markup = (rawUsd: number) =>
? applyBillingMarkupUsd(BRAND_LOOKUP_RAW_COST_USD) isHostedClientAuthMode() ? applyBillingMarkupUsd(rawUsd) : rawUsd;
: BRAND_LOOKUP_RAW_COST_USD;
const BRAND_LOOKUP_DISPLAYED_COST_USD = markup(BRAND_LOOKUP_RAW_COST_USD);
const BRAND_LOOKUP_COMPETITOR_DISPLAYED_COST_USD = markup(
BRAND_LOOKUP_COMPETITOR_RAW_COST_USD,
);
export function BrandLookupSearchCard({ export function BrandLookupSearchCard({
query, query,
onQueryChange, onQueryChange,
competitors,
onCompetitorsChange,
onSubmit, onSubmit,
isLoading, isLoading,
validationError, validationError,
}: Props) { }: Props) {
const hasCompetitors = competitors.trim().length > 0;
const queryError = validationError?.field === "query";
const competitorsError = validationError?.field === "competitors";
return ( return (
<div className="card border border-base-300 bg-base-100"> <div className="card border border-base-300 bg-base-100">
<div className="card-body gap-4"> <div className="card-body gap-4">
<form <form onSubmit={onSubmit} className="flex flex-col gap-3">
onSubmit={onSubmit} <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
className="flex flex-col gap-3 lg:flex-row lg:items-center"
>
<label <label
className={`input input-bordered flex flex-1 items-center gap-2 ${ className={`input input-bordered flex flex-1 items-center gap-2 ${
validationError ? "input-error" : "" queryError ? "input-error" : ""
}`} }`}
> >
<Search className="size-4 text-base-content/60" /> <Search className="size-4 text-base-content/60" />
@ -51,9 +69,9 @@ export function BrandLookupSearchCard({
value={query} value={query}
maxLength={BRAND_LOOKUP_MAX_INPUT_LENGTH} maxLength={BRAND_LOOKUP_MAX_INPUT_LENGTH}
onChange={(event) => onQueryChange(event.target.value)} onChange={(event) => onQueryChange(event.target.value)}
aria-invalid={validationError ? true : undefined} aria-invalid={queryError || undefined}
aria-describedby={ aria-describedby={
validationError ? "brand-lookup-input-error" : undefined queryError ? "brand-lookup-input-error" : undefined
} }
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
@ -68,11 +86,35 @@ export function BrandLookupSearchCard({
> >
{isLoading ? "Looking up..." : "Look up"} {isLoading ? "Looking up..." : "Look up"}
</button> </button>
</div>
<div className="flex flex-col gap-1">
<input
type="text"
placeholder="Add competitors (comma-separated)"
value={competitors}
onChange={(event) => onCompetitorsChange(event.target.value)}
autoComplete="off"
spellCheck={false}
className={`input input-bordered w-full ${
competitorsError ? "input-error" : ""
}`}
aria-label="Competitors"
aria-invalid={competitorsError || undefined}
aria-describedby={
competitorsError ? "brand-lookup-input-error" : undefined
}
/>
<p className="text-xs text-base-content/60">
Add up to 5 competitor brands or domains to see your Share of
Voice.
</p>
</div>
</form> </form>
{validationError ? ( {validationError ? (
<p id="brand-lookup-input-error" className="text-sm text-error"> <p id="brand-lookup-input-error" className="text-sm text-error">
{validationError} {validationError.message}
</p> </p>
) : null} ) : null}
@ -82,6 +124,14 @@ export function BrandLookupSearchCard({
<span className="font-medium text-base-content/80"> <span className="font-medium text-base-content/80">
${BRAND_LOOKUP_DISPLAYED_COST_USD.toFixed(2)} ${BRAND_LOOKUP_DISPLAYED_COST_USD.toFixed(2)}
</span> </span>
{hasCompetitors ? (
<span>
{" "}
plus ~$
{BRAND_LOOKUP_COMPETITOR_DISPLAYED_COST_USD.toFixed(2)} to
compare competitors
</span>
) : null}
</p> </p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,110 @@
import {
formatCount,
formatPlatformLabel,
} from "@/client/features/ai-search/platformLabels";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
type ShareOfVoice = NonNullable<BrandLookupResult["shareOfVoice"]>;
type ShareEntry = ShareOfVoice["entries"][number];
/**
* Competitor Share of Voice leaderboard. The server sorts entries descending by
* mentions and flags `isTarget`; this component only renders. Bars are scaled to
* the leader (the exact % is shown on every row, so nothing is hidden) so a
* dominant competitor reads as a full bar and small shares stay visible.
*/
export function BrandLookupShareOfVoice({
shareOfVoice,
}: {
shareOfVoice: ShareOfVoice;
}) {
const target = shareOfVoice.entries.find((entry) => entry.isTarget) ?? null;
const maxPct = Math.max(
0,
...shareOfVoice.entries.map((entry) => entry.sharePct ?? 0),
);
return (
<section className="flex h-full flex-col overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="flex items-baseline justify-between gap-2 border-b border-base-300 px-4 py-3">
<h3 className="text-sm font-semibold">Share of Voice</h3>
{target ? (
<span className="text-xs text-base-content/50">
<span className="font-medium text-base-content/80">
{target.label}
</span>{" "}
{target.sharePct == null
? "· no comparable data"
: `· ${Math.round(target.sharePct)}%`}
</span>
) : null}
</div>
<ul className="flex-1 divide-y divide-base-200">
{shareOfVoice.entries.map((entry, index) => (
<LeaderboardRow
key={entry.label}
entry={entry}
rank={index + 1}
maxPct={maxPct}
/>
))}
</ul>
{/* Captions only the platforms actually summed when one platform's
cross_aggregated call failed, the leaderboard must not claim both. */}
<p className="border-t border-base-200 px-4 py-2 text-[11px] text-base-content/50">
Mentions share across{" "}
{shareOfVoice.platforms.map(formatPlatformLabel).join(" and ")} · bars
relative to the leader.
</p>
</section>
);
}
function LeaderboardRow({
entry,
rank,
maxPct,
}: {
entry: ShareEntry;
rank: number;
maxPct: number;
}) {
const hasData = entry.mentions != null && entry.sharePct != null;
const barWidth =
hasData && maxPct > 0 ? ((entry.sharePct ?? 0) / maxPct) * 100 : 0;
return (
<li
className={`grid grid-cols-[1.25rem_minmax(0,1fr)_2.75rem] items-center gap-3 px-4 py-2.5 ${
entry.isTarget ? "bg-primary/5" : ""
}`}
>
<span className="text-xs tabular-nums text-base-content/40">{rank}</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm">{entry.label}</span>
{entry.isTarget ? (
<span className="badge badge-primary badge-xs border-0">You</span>
) : null}
<span className="ml-auto shrink-0 text-xs tabular-nums text-base-content/50">
{/* Null mentions = "no data"; render a dash, not zero. */}
{entry.mentions == null ? "—" : formatCount(entry.mentions)}
</span>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-base-200">
<div
className={`h-full rounded-full ${
entry.isTarget ? "bg-primary" : "bg-base-content/25"
}`}
style={{ width: `${barWidth}%` }}
/>
</div>
</div>
<span className="text-right text-sm font-medium tabular-nums">
{hasData ? `${Math.round(entry.sharePct ?? 0)}%` : "—"}
</span>
</li>
);
}

View File

@ -14,12 +14,21 @@ export function buildBrandLookupExport(
): { headers: string[]; rows: CsvValue[][] } { ): { headers: string[]; rows: CsvValue[][] } {
if (tab === "pages") { if (tab === "pages") {
return { return {
headers: ["URL", "Domain", "Platform", "Mentions"], headers: [
"URL",
"Domain",
"Platform",
"Source mentions",
"Source AI search volume",
"Fetched-sample prompt examples",
],
rows: sortedPages.map((row) => [ rows: sortedPages.map((row) => [
row.url, row.url,
row.domain ?? "", row.domain ?? "",
formatPlatformLabel(row.platform), formatPlatformLabel(row.platform),
row.mentions ?? "", row.mentions ?? "",
row.capturedVolume ?? "",
row.keywords.map((keyword) => keyword.question).join("; "),
]), ]),
}; };
} }

View File

@ -48,6 +48,17 @@ export function formatPlatformLabel(platform: "chat_gpt" | "google"): string {
return MENTION_PLATFORM_LABELS[platform]; return MENTION_PLATFORM_LABELS[platform];
} }
/** Shared per-platform accent dot + short label for compact table/KPI rows. */
export const PLATFORM_DOT_CLASS: Record<"chat_gpt" | "google", string> = {
chat_gpt: "bg-emerald-500",
google: "bg-sky-500",
};
export const PLATFORM_SHORT_LABEL: Record<"chat_gpt" | "google", string> = {
chat_gpt: "ChatGPT",
google: "Google",
};
export function formatModelLabel(model: PromptExplorerModel): string { export function formatModelLabel(model: PromptExplorerModel): string {
return MODEL_LABELS[model]; return MODEL_LABELS[model];
} }

View File

@ -8,7 +8,10 @@ import {
} from "./brandLookupFilterTypes"; } from "./brandLookupFilterTypes";
import { countActiveFilters } from "./brandLookupFiltering"; import { countActiveFilters } from "./brandLookupFiltering";
const STORAGE_KEY_PREFIX = "brand-lookup-filters:"; // v3: the pages tab returned to provider page-level metrics after a brief
// sampled-prompt scale. Bump the prefix so local min/max filters do not carry
// between incompatible metric scales.
const STORAGE_KEY_PREFIX = "brand-lookup-filters-v3:";
type FilterValues = Record<string, string>; type FilterValues = Record<string, string>;

View File

@ -3,6 +3,8 @@ import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearch
const brandLookupSearchBodySchema = z.object({ const brandLookupSearchBodySchema = z.object({
query: z.string(), query: z.string(),
// Optional/defaulted so pre-existing history entries (query only) still parse.
competitors: z.array(z.string()).optional().default([]),
}); });
type BrandLookupSearchBody = z.infer<typeof brandLookupSearchBodySchema>; type BrandLookupSearchBody = z.infer<typeof brandLookupSearchBodySchema>;
@ -15,6 +17,10 @@ export function useBrandLookupSearchHistory(projectId: string) {
return useTimestampedSearchHistory({ return useTimestampedSearchHistory({
storageKey: `brand-lookup-search-history:${projectId}`, storageKey: `brand-lookup-search-history:${projectId}`,
bodySchema: brandLookupSearchBodySchema, bodySchema: brandLookupSearchBodySchema,
isSame: (a, b) => a.query === b.query, // Competitor set is part of the identity: a plain lookup must not replace
// the saved (already paid for) Share-of-Voice comparison of the same brand.
isSame: (a, b) =>
a.query === b.query &&
a.competitors.join(",") === b.competitors.join(","),
}); });
} }

View File

@ -10,17 +10,24 @@ export const Route = createFileRoute("/_project/p/$projectId/brand-lookup")({
function BrandLookupRoute() { function BrandLookupRoute() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
const { q = "" } = Route.useSearch(); // `c` is already an opaque competitor string array via the schema transform.
const { q = "", c = [] } = Route.useSearch();
return ( return (
<BrandLookupPage <BrandLookupPage
projectId={projectId} projectId={projectId}
initialQuery={q} initialQuery={q}
onQueryChange={(nextQuery) => { initialCompetitors={c}
onSearchChange={(nextQuery, nextCompetitors) => {
void navigate({ void navigate({
search: (prev) => ({ search: (prev) => ({
...prev, ...prev,
q: nextQuery.trim() || undefined, q: nextQuery.trim() || undefined,
// One serialization site: comma-join the competitor list.
c:
nextCompetitors.length > 0
? nextCompetitors.join(",")
: undefined,
}), }),
replace: true, replace: true,
}); });

View File

@ -0,0 +1,283 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("cloudflare:workers", () => ({ waitUntil: vi.fn() }));
const { dataforseoClientMock, cacheMock } = vi.hoisted(() => ({
dataforseoClientMock: {
aiSearch: {
aggregatedMetrics: vi.fn(),
topPages: vi.fn(),
mentionsSearch: vi.fn(),
crossAggregatedMetrics: vi.fn(),
},
},
cacheMock: {
buildCacheKey: vi.fn(async (_prefix: string, params: unknown) =>
JSON.stringify(params),
),
getCached: vi.fn(),
setCached: vi.fn(async () => undefined),
},
}));
vi.mock("@/server/lib/dataforseo", () => {
return {
CHATGPT_LANGUAGE_CODE: "en",
CHATGPT_LOCATION_CODE: 2840,
buildLlmTarget: vi.fn(
({ type, value }: { type: "domain" | "keyword"; value: string }) =>
type === "domain" ? { domain: value } : { keyword: value },
),
createDataforseoClient: vi.fn(() => dataforseoClientMock),
};
});
vi.mock("@/server/lib/r2-cache", () => cacheMock);
import { getBrandLookup } from "./brandLookup";
import { shapeResult, type ShapeArgs } from "./brandLookupShaping";
import { resolveCompetitorGroups } from "./shareOfVoice";
import { brandLookupSearchSchema } from "@/types/schemas/ai-search";
import type {
LlmMentionItem,
LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas";
import type { BillingCustomerContext } from "@/server/billing/subscription";
const billingCustomer: BillingCustomerContext = {
organizationId: "org_123",
userId: "user_123",
userEmail: "alice@example.com",
};
type PlatformBundle = {
aggregated: { platform?: Array<Record<string, unknown>> | null };
topPages: LlmTopPagesItem[];
mentions: LlmMentionItem[];
complete: boolean;
};
function platformBundle(
platform: "chat_gpt" | "google",
mentions: number | null,
aiSearchVolume: number | null,
): ShapeArgs["platformBundles"][number] {
return {
platform,
status: "success",
bundle: {
aggregated: {
platform: [
{
key: platform,
mentions,
ai_search_volume: aiSearchVolume,
// Deprecated field still present in upstream payloads; must be
// ignored end-to-end.
impressions: 999,
},
],
},
topPages: [],
mentions: [],
complete: true,
} as PlatformBundle,
};
}
function baseArgs(overrides: Partial<ShapeArgs>): ShapeArgs {
return {
query: "acme",
detected: { type: "keyword", value: "acme" },
platformBundles: [
platformBundle("chat_gpt", 10, 100),
platformBundle("google", 5, 50),
],
crossOutcomes: [],
competitorKeys: [],
userLocationCode: 2840,
userLanguageCode: "en",
...overrides,
};
}
function resetBrandLookupMocks(): void {
vi.clearAllMocks();
cacheMock.getCached.mockResolvedValue(null);
cacheMock.setCached.mockResolvedValue(undefined);
dataforseoClientMock.aiSearch.aggregatedMetrics.mockResolvedValue({
platform: [{ key: "google", mentions: 5, ai_search_volume: 50 }],
});
dataforseoClientMock.aiSearch.topPages.mockImplementation(
async ({ platform }: { platform: "chat_gpt" | "google" }) => [
topPage(`https://${platform}.example/source`, platform, 3, 300),
],
);
dataforseoClientMock.aiSearch.mentionsSearch.mockImplementation(
async ({ platform }: { platform: "chat_gpt" | "google" }) => [
citedMention("best source", 100, [`https://${platform}.example/source`]),
],
);
dataforseoClientMock.aiSearch.crossAggregatedMetrics.mockResolvedValue([]);
}
describe("getBrandLookup", () => {
it("fetches top_pages as part of the base lookup", async () => {
resetBrandLookupMocks();
await getBrandLookup(
{
projectId: "project_123",
query: "acme.com",
competitors: [],
locationCode: 2840,
languageCode: "en",
},
billingCustomer,
);
expect(dataforseoClientMock.aiSearch.topPages).toHaveBeenCalledTimes(2);
expect(dataforseoClientMock.aiSearch.topPages).toHaveBeenCalledWith(
expect.objectContaining({
platform: "chat_gpt",
itemsListLimit: 10,
}),
);
expect(dataforseoClientMock.aiSearch.topPages).toHaveBeenCalledWith(
expect.objectContaining({
platform: "google",
itemsListLimit: 10,
}),
);
expect(cacheMock.setCached).toHaveBeenCalledTimes(1);
});
it("does not cache a renderable partial result when top_pages fails", async () => {
resetBrandLookupMocks();
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
dataforseoClientMock.aiSearch.topPages.mockRejectedValueOnce(
new Error("top pages failed"),
);
const result = await getBrandLookup(
{
projectId: "project_123",
query: "acme.com",
competitors: [],
locationCode: 2840,
languageCode: "en",
},
billingCustomer,
);
expect(result.hasData).toBe(true);
expect(cacheMock.setCached).not.toHaveBeenCalled();
consoleError.mockRestore();
});
it("uses semantic cache keys and reapplies the current display query", async () => {
resetBrandLookupMocks();
cacheMock.getCached.mockResolvedValueOnce({
...shapeResult(baseArgs({})),
query: "Nike",
resolvedTarget: "Nike",
});
const result = await getBrandLookup(
{
projectId: "project_123",
query: "nike",
competitors: ["ADIDAS"],
locationCode: 2840,
languageCode: "en",
},
billingCustomer,
);
expect(result.query).toBe("nike");
expect(cacheMock.buildCacheKey).toHaveBeenCalledWith(
"ai-search:brand-lookup",
expect.objectContaining({
targetValue: "nike",
competitors: "adidas",
}),
);
expect(
dataforseoClientMock.aiSearch.aggregatedMetrics,
).not.toHaveBeenCalled();
});
});
describe("resolveCompetitorGroups", () => {
it("dedupes case-insensitively and drops target collisions", () => {
// DataForSEO matches keyword targets case-insensitively, so "Nike" and
// "nike" would be two paid groups returning identical counts.
const groups = resolveCompetitorGroups("Nike", [
"nike",
"Adidas",
"ADIDAS",
"puma.com",
"www.PUMA.com",
]);
expect(groups.map((g) => g.label)).toEqual(["Adidas", "puma.com"]);
});
});
describe("brandLookupSearchSchema — `c` competitor param", () => {
it("parses a raw comma-separated string from the URL", () => {
expect(brandLookupSearchSchema.parse({ c: "nike, adidas" }).c).toEqual([
"nike",
"adidas",
]);
});
it("accepts an already-parsed array (TanStack re-validates its own output)", () => {
// navigate() feeds the previous transformed output (a string[]) back through
// validateSearch — this must not throw "expected string, received array".
expect(brandLookupSearchSchema.parse({ c: ["nike", "adidas"] }).c).toEqual([
"nike",
"adidas",
]);
});
it("dedupes and caps at 5 regardless of input form", () => {
const many = ["a", "a", "b", "c", "d", "e", "f"];
expect(brandLookupSearchSchema.parse({ c: many }).c).toEqual([
"a",
"b",
"c",
"d",
"e",
]);
});
it("leaves `c` undefined when absent", () => {
expect(brandLookupSearchSchema.parse({}).c).toBeUndefined();
});
});
function citedMention(
question: string,
aiSearchVolume: number | null,
urls: string[],
): LlmMentionItem {
return {
question,
ai_search_volume: aiSearchVolume,
sources: urls.map((url) => ({ url })),
};
}
function topPage(
url: string,
platform: "chat_gpt" | "google",
mentions: number | null,
aiSearchVolume: number | null,
): LlmTopPagesItem {
return {
key: url,
platform: [{ key: platform, mentions, ai_search_volume: aiSearchVolume }],
};
}

View File

@ -1,5 +1,4 @@
import { waitUntil } from "cloudflare:workers"; import { waitUntil } from "cloudflare:workers";
import { sortBy } from "remeda";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import { createDataforseoClient } from "@/server/lib/dataforseo";
import { import {
@ -8,20 +7,25 @@ import {
CHATGPT_LOCATION_CODE, CHATGPT_LOCATION_CODE,
type LlmPlatform, type LlmPlatform,
} from "@/server/lib/dataforseo"; } from "@/server/lib/dataforseo";
import type { import type { LlmCrossAggregatedItem } from "@/server/lib/dataforseoLlmSchemas";
LlmAggregatedTotal,
LlmMentionItem,
LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl"; import {
resolveCompetitorGroups,
type CompetitorGroup,
type CrossOutcome,
} from "@/server/features/ai-search/services/shareOfVoice";
import {
shapeResult,
type PlatformBundle,
type PlatformOutcome,
} from "@/server/features/ai-search/services/brandLookupShaping";
import { import {
brandLookupResultSchema, brandLookupResultSchema,
type BrandLookupInput, type BrandLookupInput,
type BrandLookupResult, type BrandLookupResult,
} from "@/types/schemas/ai-search"; } from "@/types/schemas/ai-search";
import { detectTarget } from "@/server/features/ai-search/targetDetection"; import { detectTarget } from "@/shared/targetDetection";
/** /**
* Brand Lookup is the AI-search analog of Domain Overview. The user types a * Brand Lookup is the AI-search analog of Domain Overview. The user types a
@ -35,39 +39,77 @@ const BRAND_LOOKUP_TTL_SECONDS = 24 * 60 * 60;
const PLATFORMS: LlmPlatform[] = ["chat_gpt", "google"]; const PLATFORMS: LlmPlatform[] = ["chat_gpt", "google"];
const TOP_PAGES_PER_PLATFORM = 10; // Prompt rows supply explainable examples for cited pages. Ranked source rows
const TOP_QUERIES_PER_PLATFORM = 25; // come from top_pages so the table is not limited to this sample.
const MENTIONS_PER_PLATFORM = 100;
const TOP_SOURCES_PER_PLATFORM = 10;
export async function getBrandLookup( export async function getBrandLookup(
input: BrandLookupInput, input: BrandLookupInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
): Promise<BrandLookupResult> { ): Promise<BrandLookupResult> {
const detected = detectTarget(input.query); const detected = detectTarget(input.query);
const competitorGroups = resolveCompetitorGroups(
detected.value,
input.competitors,
);
// Changing this key's param set orphans every pre-deploy cache entry; with a
// 24h TTL that's at most one re-charged lookup per cached target — accepted
// rather than maintaining parallel legacy-shape parsing.
const cacheKey = await buildCacheKey("ai-search:brand-lookup", { const cacheKey = await buildCacheKey("ai-search:brand-lookup", {
organizationId: billingCustomer.organizationId, organizationId: billingCustomer.organizationId,
projectId: input.projectId, projectId: input.projectId,
targetType: detected.type, targetType: detected.type,
targetValue: detected.value, // Values are lowercased for DataForSEO's matching semantics. Competitors
// are canonical detected values too, so equivalent casing/order shares one
// paid cache entry.
targetValue: detected.value.toLowerCase(),
competitors: competitorGroups
.map((g) => g.detected.value.toLowerCase())
.toSorted()
.join("|"),
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
}); });
const cached = brandLookupResultSchema.safeParse(await getCached(cacheKey)); const cached = brandLookupResultSchema.safeParse(await getCached(cacheKey));
if (cached.success) return cached.data; if (cached.success) {
return {
...cached.data,
query: input.query,
resolvedTarget: detected.value,
};
}
const dataforseo = createDataforseoClient(billingCustomer); const dataforseo = createDataforseoClient(billingCustomer);
// Settle each platform independently so a failure in one doesn't discard // Settle each platform independently so a failure in one doesn't discard the
// the other (which the caller already paid for via meterDataforseoCall). // other. Keep the metered DataForSEO calls sequenced: in hosted mode each
const settled = await Promise.allSettled( // call checks balance before execution and records spend after, so parallel
PLATFORMS.map((platform) => // fan-out can overrun a low remaining balance.
const settled: Array<PromiseSettledResult<PlatformBundle>> = [];
for (const platform of PLATFORMS) {
settled.push(
await settle(() =>
fetchPlatformData(platform, detected, input, dataforseo), fetchPlatformData(platform, detected, input, dataforseo),
), ),
); );
}
rethrowIfBlockingAiSearchError(settled); rethrowIfBlockingAiSearchError(settled);
const crossSettled =
competitorGroups.length > 0
? await settle(() =>
fetchCrossAggregated(detected, competitorGroups, input, dataforseo),
)
: ({ status: "fulfilled", value: [] } as PromiseFulfilledResult<
CrossOutcome[]
>);
if (crossSettled.status === "rejected") throw crossSettled.reason;
const crossOutcomes = crossSettled.value;
const platformBundles: PlatformOutcome[] = settled.map((settledResult, i) => { const platformBundles: PlatformOutcome[] = settled.map((settledResult, i) => {
const platform = PLATFORMS[i]; const platform = PLATFORMS[i];
if (settledResult.status === "fulfilled") { if (settledResult.status === "fulfilled") {
@ -84,13 +126,20 @@ export async function getBrandLookup(
query: input.query, query: input.query,
detected, detected,
platformBundles, platformBundles,
crossOutcomes,
competitorKeys: competitorGroups.map((g) => g.label),
userLocationCode: input.locationCode, userLocationCode: input.locationCode,
userLanguageCode: input.languageCode, userLanguageCode: input.languageCode,
}); });
// Only cache when every platform succeeded — otherwise users would see a // Only cache when every call succeeded — a platform bundle that swallowed a
// stale partial result for 24h and have no way to retry without busting it. // failed sub-call into empty fallback data is renderable but must not be
const allSucceeded = platformBundles.every((b) => b.status === "success"); // frozen for 24h with no way to retry; same for a partial SoV miss when
// competitors were requested.
const allSucceeded =
platformBundles.every(
(b) => b.status === "success" && b.bundle?.complete,
) && crossOutcomes.every((c) => c.status === "success");
if (allSucceeded && result.hasData) { if (allSucceeded && result.hasData) {
waitUntil( waitUntil(
setCached(cacheKey, result, BRAND_LOOKUP_TTL_SECONDS).catch((err) => { setCached(cacheKey, result, BRAND_LOOKUP_TTL_SECONDS).catch((err) => {
@ -102,23 +151,21 @@ export async function getBrandLookup(
return result; return result;
} }
async function settle<T>(
execute: () => Promise<T>,
): Promise<PromiseSettledResult<T>> {
try {
return { status: "fulfilled", value: await execute() };
} catch (reason) {
return { status: "rejected", reason };
}
}
type PlatformFetchInput = Pick< type PlatformFetchInput = Pick<
BrandLookupInput, BrandLookupInput,
"locationCode" | "languageCode" "locationCode" | "languageCode"
>; >;
type PlatformBundle = {
aggregated: LlmAggregatedTotal;
topPages: LlmTopPagesItem[];
mentions: LlmMentionItem[];
};
type PlatformOutcome = {
platform: LlmPlatform;
status: "success" | "error";
bundle: PlatformBundle | null;
};
async function fetchPlatformData( async function fetchPlatformData(
platform: LlmPlatform, platform: LlmPlatform,
detected: ReturnType<typeof detectTarget>, detected: ReturnType<typeof detectTarget>,
@ -136,9 +183,9 @@ async function fetchPlatformData(
const languageCode = const languageCode =
platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : input.languageCode; platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : input.languageCode;
// `allSettled` so one sub-call failing doesn't discard the other two we // Settle sub-calls independently so one failure doesn't discard the others we
// already paid for. Each sub-call is metered independently upstream. // already paid for, but keep them sequenced for hosted billing checks.
const [aggregated, topPages, mentions] = await Promise.allSettled([ const aggregated = await settle(() =>
dataforseo.aiSearch.aggregatedMetrics({ dataforseo.aiSearch.aggregatedMetrics({
target, target,
platform, platform,
@ -146,21 +193,25 @@ async function fetchPlatformData(
languageCode, languageCode,
internalListLimit: 20, internalListLimit: 20,
}), }),
);
const topPages = await settle(() =>
dataforseo.aiSearch.topPages({ dataforseo.aiSearch.topPages({
target, target,
platform, platform,
locationCode, locationCode,
languageCode, languageCode,
itemsListLimit: TOP_PAGES_PER_PLATFORM, itemsListLimit: TOP_SOURCES_PER_PLATFORM,
}), }),
);
const mentions = await settle(() =>
dataforseo.aiSearch.mentionsSearch({ dataforseo.aiSearch.mentionsSearch({
target, target,
platform, platform,
locationCode, locationCode,
languageCode, languageCode,
limit: TOP_QUERIES_PER_PLATFORM, limit: MENTIONS_PER_PLATFORM,
}), }),
]); );
rethrowIfBlockingAiSearchError([aggregated, topPages, mentions]); rethrowIfBlockingAiSearchError([aggregated, topPages, mentions]);
@ -176,9 +227,76 @@ async function fetchPlatformData(
aggregated: fulfilledOr(aggregated, () => ({}), platform, "aggregated"), aggregated: fulfilledOr(aggregated, () => ({}), platform, "aggregated"),
topPages: fulfilledOr(topPages, () => [], platform, "topPages"), topPages: fulfilledOr(topPages, () => [], platform, "topPages"),
mentions: fulfilledOr(mentions, () => [], platform, "mentions"), mentions: fulfilledOr(mentions, () => [], platform, "mentions"),
complete:
aggregated.status === "fulfilled" &&
topPages.status === "fulfilled" &&
mentions.status === "fulfilled",
}; };
} }
/**
* One cross_aggregated_metrics call per platform (ChatGPT forced to US/en),
* each comparing the target against the competitors. Settled per-platform so a
* single failure doesn't discard the other matching the per-platform
* fan-out in {@link getBrandLookup}. The target's aggregation_key is the
* resolved target value so SoV can flag the target row.
*/
async function fetchCrossAggregated(
detected: ReturnType<typeof detectTarget>,
competitors: CompetitorGroup[],
input: PlatformFetchInput,
dataforseo: ReturnType<typeof createDataforseoClient>,
): Promise<CrossOutcome[]> {
const groups = [
{
key: detected.value,
target: buildLlmTarget({ type: detected.type, value: detected.value }),
},
...competitors.map((competitor) => ({
key: competitor.label,
target: buildLlmTarget({
type: competitor.detected.type,
value: competitor.detected.value,
}),
})),
];
const settled: Array<PromiseSettledResult<LlmCrossAggregatedItem[]>> = [];
for (const platform of PLATFORMS) {
settled.push(
await settle(() =>
dataforseo.aiSearch.crossAggregatedMetrics({
groups,
platform,
// ChatGPT mentions DB only contains US/en data per DataForSEO docs.
locationCode:
platform === "chat_gpt"
? CHATGPT_LOCATION_CODE
: input.locationCode,
languageCode:
platform === "chat_gpt"
? CHATGPT_LANGUAGE_CODE
: input.languageCode,
}),
),
);
}
rethrowIfBlockingAiSearchError(settled);
return settled.map((result, i) => {
const platform = PLATFORMS[i];
if (result.status === "fulfilled") {
return { platform, status: "success" as const, items: result.value };
}
console.error(
`ai-search.brand-lookup.${platform}.cross-aggregated.error:`,
result.reason,
);
return { platform, status: "error" as const, items: [] };
});
}
function rethrowIfBlockingAiSearchError( function rethrowIfBlockingAiSearchError(
results: Array<PromiseSettledResult<unknown>>, results: Array<PromiseSettledResult<unknown>>,
): void { ): void {
@ -208,197 +326,3 @@ function fulfilledOr<T>(
); );
return fallback(); return fallback();
} }
type ShapeArgs = {
query: string;
detected: ReturnType<typeof detectTarget>;
platformBundles: PlatformOutcome[];
userLocationCode: number;
userLanguageCode: string;
};
function shapeResult(args: ShapeArgs): BrandLookupResult {
const successfulBundles = args.platformBundles.filter(
(b): b is PlatformOutcome & { bundle: PlatformBundle } =>
b.status === "success" && b.bundle !== null,
);
// ChatGPT data is always fetched US/en (DataForSEO only indexes that
// locale), so when the user picks a non-US/en locale we must not fold its
// numbers into cross-platform totals or the monthly trend — doing so would
// mix two different datasets under one locale label. Per-platform rows
// still render ChatGPT separately with the "US-only" tooltip.
// Match the primary subtag so "en-US"/"en_US" still count as English.
const primaryLanguage = args.userLanguageCode.toLowerCase().split(/[-_]/)[0];
const chatGptLocaleMatches =
args.userLocationCode === CHATGPT_LOCATION_CODE &&
primaryLanguage === CHATGPT_LANGUAGE_CODE;
const perPlatform = args.platformBundles.map((outcome) => {
if (outcome.status === "error" || !outcome.bundle) {
return {
platform: outcome.platform,
status: "error" as const,
mentions: null,
aiSearchVolume: null,
impressions: null,
};
}
const platformGroup = outcome.bundle.aggregated.platform?.find(
(entry) => entry.key === outcome.platform,
);
return {
platform: outcome.platform,
status: "success" as const,
mentions: roundOrNull(platformGroup?.mentions),
aiSearchVolume: roundOrNull(platformGroup?.ai_search_volume),
impressions: roundOrNull(platformGroup?.impressions),
};
});
const aggregatablePlatforms = perPlatform.filter(
(p) => chatGptLocaleMatches || p.platform !== "chat_gpt",
);
const totalMentions = sumNullable(
aggregatablePlatforms.map((p) => p.mentions),
);
const totalAiSearchVolume = sumNullable(
aggregatablePlatforms.map((p) => p.aiSearchVolume),
);
const totalImpressions = sumNullable(
aggregatablePlatforms.map((p) => p.impressions),
);
const topPages = sortBy(
successfulBundles.flatMap((bundle) =>
bundle.bundle.topPages
.map((page) => {
const safeUrl = safeHttpUrl(page.key);
if (!safeUrl) return null;
return {
url: safeUrl,
domain: safeHostname(safeUrl),
mentions: roundOrNull(
page.platform?.find((entry) => entry.key === bundle.platform)
?.mentions,
),
platform: bundle.platform,
};
})
.filter((page): page is NonNullable<typeof page> => page !== null),
),
[(page) => page.mentions ?? 0, "desc"],
).slice(0, 20);
const topQueries = sortBy(
successfulBundles.flatMap((bundle) =>
bundle.bundle.mentions
.filter(
(item): item is LlmMentionItem & { question: string } =>
typeof item.question === "string" && item.question.length > 0,
)
.map((item) => ({
question: item.question,
platform: bundle.platform,
aiSearchVolume: roundOrNull(item.ai_search_volume),
firstSeenAt: item.first_response_at ?? null,
lastSeenAt: item.last_response_at ?? null,
citedSources: (item.sources ?? [])
.map((src) => {
const safeUrl = safeHttpUrl(src.url);
if (!safeUrl) return null;
return {
url: safeUrl,
domain: src.domain ?? safeHostname(safeUrl),
title: src.title ?? null,
};
})
.filter((src): src is NonNullable<typeof src> => src !== null)
.slice(0, 10),
brandsMentioned: (item.brand_entities ?? [])
.map((entity) => entity.title ?? "")
.filter((title) => title.length > 0)
.slice(0, 20),
})),
),
[(query) => query.aiSearchVolume ?? 0, "desc"],
).slice(0, 50);
const trendBundles = chatGptLocaleMatches
? successfulBundles
: successfulBundles.filter((b) => b.platform !== "chat_gpt");
const monthlyVolume = aggregateMonthlyVolume(trendBundles);
const hasData =
(totalMentions ?? 0) > 0 ||
topPages.length > 0 ||
topQueries.length > 0 ||
monthlyVolume.length > 0;
return {
query: args.query,
detectedTargetType: args.detected.type,
resolvedTarget: args.detected.value,
fetchedAt: new Date().toISOString(),
hasData,
totalMentions,
totalAiSearchVolume,
totalImpressions,
perPlatform,
topPages,
topQueries,
monthlyVolume,
};
}
function sumNullable(values: Array<number | null>): number | null {
let total = 0;
let hasValue = false;
for (const value of values) {
if (value != null) {
total += value;
hasValue = true;
}
}
return hasValue ? total : null;
}
function roundOrNull(value: number | null | undefined): number | null {
if (value == null) return null;
return Math.round(value);
}
/**
* Sum monthly mention volume across all returned mention items, regardless of
* platform. Returns the most recent 12 months in chronological order.
*/
function aggregateMonthlyVolume(
bundles: Array<PlatformOutcome & { bundle: PlatformBundle }>,
): BrandLookupResult["monthlyVolume"] {
const totals = new Map<string, number>();
for (const outcome of bundles) {
for (const mention of outcome.bundle.mentions) {
for (const monthly of mention.monthly_searches ?? []) {
if (monthly.search_volume == null) continue;
const key = `${monthly.year}-${monthly.month}`;
totals.set(key, (totals.get(key) ?? 0) + monthly.search_volume);
}
}
}
const entries = Array.from(totals.entries()).map(([key, volume]) => {
const [yearStr, monthStr] = key.split("-");
return {
year: Number(yearStr),
month: Number(monthStr),
volume: Math.round(volume),
};
});
return sortBy(
entries,
[(entry) => entry.year, "asc"],
[(entry) => entry.month, "asc"],
).slice(-12);
}

View File

@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { shapeResult, type ShapeArgs } from "./brandLookupShaping";
import type {
LlmCrossAggregatedItem,
LlmMentionItem,
LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas";
import { brandLookupResultSchema } from "@/types/schemas/ai-search";
function platformBundle(
platform: "chat_gpt" | "google",
mentions: number | null,
aiSearchVolume: number | null,
): ShapeArgs["platformBundles"][number] {
return {
platform,
status: "success",
bundle: {
aggregated: {
platform: [
{ key: platform, mentions, ai_search_volume: aiSearchVolume },
],
},
topPages: [],
mentions: [],
complete: true,
},
};
}
function crossItem(
key: string,
platformMentions: Array<{ key: string; mentions: number | null }>,
): LlmCrossAggregatedItem {
return {
key,
platform: platformMentions.map((p) => ({
key: p.key,
mentions: p.mentions,
ai_search_volume: null,
})),
};
}
function baseArgs(overrides: Partial<ShapeArgs> = {}): ShapeArgs {
return {
query: "acme",
detected: { type: "keyword", value: "acme" },
platformBundles: [
platformBundle("chat_gpt", 10, 100),
platformBundle("google", 5, 50),
],
crossOutcomes: [],
competitorKeys: [],
userLocationCode: 2840,
userLanguageCode: "en",
...overrides,
};
}
describe("shapeResult", () => {
it("excludes ChatGPT from totals and SoV outside US/en", () => {
const result = shapeResult(
baseArgs({
userLocationCode: 2826,
competitorKeys: ["rival"],
crossOutcomes: [
{
platform: "chat_gpt",
status: "success",
items: [
crossItem("acme", [{ key: "chat_gpt", mentions: 90 }]),
crossItem("rival", [{ key: "chat_gpt", mentions: 10 }]),
],
},
{
platform: "google",
status: "success",
items: [
crossItem("acme", [{ key: "google", mentions: 10 }]),
crossItem("rival", [{ key: "google", mentions: 30 }]),
],
},
],
}),
);
expect(result.totalMentions).toBe(5);
expect(result.shareOfVoice?.platforms).toEqual(["google"]);
expect(result.shareOfVoice?.entries[0]).toMatchObject({
label: "rival",
sharePct: 75,
});
});
it("derives top-query cited source domains from urls and caps long output", () => {
const longTitle = "x".repeat(400);
const result = shapeResult(
baseArgs({
platformBundles: [
{
platform: "google",
status: "success",
bundle: {
aggregated: { platform: [] },
topPages: [],
mentions: [
{
question: "q".repeat(600),
ai_search_volume: 100,
sources: [
{
url: "https://evil.example/path",
domain: "customer.example",
title: longTitle,
},
],
brand_entities: [{ title: "b".repeat(300) }],
} satisfies LlmMentionItem,
],
complete: true,
},
},
],
}),
);
expect(result.topQueries[0].question).toHaveLength(500);
expect(result.topQueries[0].citedSources[0]).toMatchObject({
url: "https://evil.example/path",
domain: "evil.example",
title: "x".repeat(300),
});
expect(result.topQueries[0].brandsMentioned[0]).toHaveLength(200);
});
it("round-trips through the cache schema", () => {
const topPage: LlmTopPagesItem = {
key: "https://a.com",
platform: [{ key: "google", mentions: 3, ai_search_volume: 300 }],
};
const result = shapeResult(
baseArgs({
platformBundles: [
{
platform: "google",
status: "success",
bundle: {
aggregated: { platform: [] },
topPages: [topPage],
mentions: [],
complete: true,
},
},
],
}),
);
expect(result.topPages[0]).toMatchObject({
domain: "a.com",
mentions: 3,
capturedVolume: 300,
});
expect(brandLookupResultSchema.safeParse(result).success).toBe(true);
});
});

View File

@ -0,0 +1,228 @@
import { sortBy } from "remeda";
import {
CHATGPT_LANGUAGE_CODE,
CHATGPT_LOCATION_CODE,
type LlmPlatform,
} from "@/server/lib/dataforseo/ai";
import type {
LlmAggregatedTotal,
LlmMentionItem,
LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas";
import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl";
import { deriveCitedSources } from "@/server/features/ai-search/services/citedSources";
import {
computeShareOfVoice,
roundOrNull,
sumNullable,
type CrossOutcome,
} from "@/server/features/ai-search/services/shareOfVoice";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
import type { detectTarget } from "@/shared/targetDetection";
const TOP_QUERIES_PER_PLATFORM = 25;
const TOP_SOURCES_PER_PLATFORM = 10;
const KEYWORDS_PER_SOURCE = 50;
const MAX_URL_LENGTH = 2048;
const MAX_TITLE_LENGTH = 300;
const MAX_QUESTION_LENGTH = 500;
const MAX_BRAND_ENTITY_LENGTH = 200;
export type PlatformBundle = {
aggregated: LlmAggregatedTotal;
topPages: LlmTopPagesItem[];
mentions: LlmMentionItem[];
/** False when one of the sub-calls failed and fell back to empty data. */
complete: boolean;
};
export type PlatformOutcome = {
platform: LlmPlatform;
status: "success" | "error";
bundle: PlatformBundle | null;
};
export type ShapeArgs = {
query: string;
detected: ReturnType<typeof detectTarget>;
platformBundles: PlatformOutcome[];
crossOutcomes: CrossOutcome[];
/** Labels of the resolved competitor groups, as sent to cross_aggregated. */
competitorKeys: string[];
userLocationCode: number;
userLanguageCode: string;
};
export function shapeResult(args: ShapeArgs): BrandLookupResult {
const successfulBundles = args.platformBundles.filter(
(b): b is PlatformOutcome & { bundle: PlatformBundle } =>
b.status === "success" && b.bundle !== null,
);
const primaryLanguage = args.userLanguageCode.toLowerCase().split(/[-_]/)[0];
const chatGptLocaleMatches =
args.userLocationCode === CHATGPT_LOCATION_CODE &&
primaryLanguage === CHATGPT_LANGUAGE_CODE;
const perPlatform = args.platformBundles.map((outcome) => {
if (outcome.status === "error" || !outcome.bundle) {
return {
platform: outcome.platform,
status: "error" as const,
mentions: null,
aiSearchVolume: null,
};
}
const platformGroup = outcome.bundle.aggregated.platform?.find(
(entry) => entry.key === outcome.platform,
);
return {
platform: outcome.platform,
status: "success" as const,
mentions: roundOrNull(platformGroup?.mentions),
aiSearchVolume: roundOrNull(platformGroup?.ai_search_volume),
};
});
const aggregatablePlatforms = perPlatform.filter(
(p) => chatGptLocaleMatches || p.platform !== "chat_gpt",
);
const totalMentions = sumNullable(
aggregatablePlatforms.map((p) => p.mentions),
);
const totalAiSearchVolume = sumNullable(
aggregatablePlatforms.map((p) => p.aiSearchVolume),
);
const topPages = deriveCitedSources(
successfulBundles.map((bundle) => ({
platform: bundle.platform,
topPages: bundle.bundle.topPages,
mentions: bundle.bundle.mentions,
})),
{
sourcesPerPlatform: TOP_SOURCES_PER_PLATFORM,
keywordsPerSource: KEYWORDS_PER_SOURCE,
},
);
const topQueries = shapeTopQueries(successfulBundles);
const trendBundles = chatGptLocaleMatches
? successfulBundles
: successfulBundles.filter((b) => b.platform !== "chat_gpt");
const monthlyVolume = aggregateMonthlyVolume(trendBundles);
const shareOfVoice = computeShareOfVoice(
chatGptLocaleMatches
? args.crossOutcomes
: args.crossOutcomes.filter((outcome) => outcome.platform !== "chat_gpt"),
args.detected.value,
args.competitorKeys,
);
const hasData =
(totalMentions ?? 0) > 0 ||
topPages.length > 0 ||
topQueries.length > 0 ||
monthlyVolume.length > 0 ||
(shareOfVoice?.entries.some((e) => e.mentions != null) ?? false);
return {
query: args.query,
detectedTargetType: args.detected.type,
resolvedTarget: args.detected.value,
fetchedAt: new Date().toISOString(),
hasData,
totalMentions,
totalAiSearchVolume,
perPlatform,
shareOfVoice,
topPages,
topQueries,
monthlyVolume,
};
}
function shapeTopQueries(
bundles: Array<PlatformOutcome & { bundle: PlatformBundle }>,
): BrandLookupResult["topQueries"] {
return sortBy(
bundles.flatMap((bundle) =>
sortBy(
bundle.bundle.mentions
.filter(
(item): item is LlmMentionItem & { question: string } =>
typeof item.question === "string" && item.question.length > 0,
)
.map((item) => ({
question: truncate(item.question, MAX_QUESTION_LENGTH),
platform: bundle.platform,
aiSearchVolume: roundOrNull(item.ai_search_volume),
firstSeenAt: item.first_response_at ?? null,
lastSeenAt: item.last_response_at ?? null,
citedSources: shapeQuerySources(item),
brandsMentioned: (item.brand_entities ?? [])
.map((entity) => entity.title ?? "")
.filter((title) => title.length > 0)
.map((title) => truncate(title, MAX_BRAND_ENTITY_LENGTH))
.slice(0, 20),
})),
[(query) => query.aiSearchVolume ?? 0, "desc"],
).slice(0, TOP_QUERIES_PER_PLATFORM),
),
[(query) => query.aiSearchVolume ?? 0, "desc"],
);
}
function shapeQuerySources(
item: LlmMentionItem,
): BrandLookupResult["topQueries"][number]["citedSources"] {
return (item.sources ?? [])
.map((src) => {
const safeUrl = safeHttpUrl(src.url);
if (!safeUrl || safeUrl.length > MAX_URL_LENGTH) return null;
return {
url: safeUrl,
domain: safeHostname(safeUrl),
title:
typeof src.title === "string"
? truncate(src.title, MAX_TITLE_LENGTH)
: null,
};
})
.filter((src): src is NonNullable<typeof src> => src !== null)
.slice(0, 10);
}
function aggregateMonthlyVolume(
bundles: Array<PlatformOutcome & { bundle: PlatformBundle }>,
): BrandLookupResult["monthlyVolume"] {
const totals = new Map<string, number>();
for (const outcome of bundles) {
for (const mention of outcome.bundle.mentions) {
for (const monthly of mention.monthly_searches ?? []) {
if (monthly.search_volume == null) continue;
const key = `${monthly.year}-${monthly.month}`;
totals.set(key, (totals.get(key) ?? 0) + monthly.search_volume);
}
}
}
const entries = Array.from(totals.entries()).map(([key, volume]) => {
const [yearStr, monthStr] = key.split("-");
return {
year: Number(yearStr),
month: Number(monthStr),
volume: Math.round(volume),
};
});
return sortBy(
entries,
[(entry) => entry.year, "asc"],
[(entry) => entry.month, "asc"],
).slice(-12);
}
function truncate(value: string, maxLength: number): string {
return value.length <= maxLength ? value : value.slice(0, maxLength);
}

View File

@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { deriveCitedSources } from "./citedSources";
import type {
LlmMentionItem,
LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas";
function citedMention(
question: string,
aiSearchVolume: number | null,
urls: string[],
): LlmMentionItem {
return {
question,
ai_search_volume: aiSearchVolume,
sources: urls.map((url) => ({ url })),
};
}
function topPage(
url: string,
platform: "chat_gpt" | "google",
mentions: number | null,
aiSearchVolume: number | null,
): LlmTopPagesItem {
return {
key: url,
platform: [{ key: platform, mentions, ai_search_volume: aiSearchVolume }],
};
}
describe("deriveCitedSources", () => {
it("uses top_pages metrics and attaches matching prompt examples", () => {
const sources = deriveCitedSources(
[
{
platform: "google",
topPages: [
topPage("https://a.com/x", "google", 9, 9000),
topPage("https://b.com/y", "google", 2, 1000),
],
mentions: [
citedMention("best seo tools", 1000, [
"https://a.com/x",
"https://b.com/y",
]),
citedMention("cheap seo", 500, ["https://a.com/x"]),
],
},
],
{ sourcesPerPlatform: 20, keywordsPerSource: 50 },
);
expect(sources[0]).toMatchObject({
domain: "a.com",
mentions: 9,
capturedVolume: 9000,
});
expect(sources[0].keywords.map((k) => k.question).toSorted()).toEqual([
"best seo tools",
"cheap seo",
]);
});
it("dedupes sampled prompt examples and derives domains from urls", () => {
const sources = deriveCitedSources(
[
{
platform: "google",
topPages: [topPage("https://evil.example/path", "google", 3, 300)],
mentions: [
{
question: "q",
ai_search_volume: 200,
sources: [
{
url: "https://evil.example/path",
domain: "customer.example",
},
{ url: "https://evil.example/path" },
],
},
],
},
],
{ sourcesPerPlatform: 20, keywordsPerSource: 50 },
);
expect(sources[0]).toMatchObject({
url: "https://evil.example/path",
domain: "evil.example",
});
expect(sources[0].keywords).toEqual([
{ question: "q", aiSearchVolume: 200 },
]);
});
});

View File

@ -0,0 +1,118 @@
import { sortBy } from "remeda";
import type { LlmPlatform } from "@/server/lib/dataforseo";
import type {
LlmMentionItem,
LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas";
import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl";
import { roundOrNull } from "@/server/features/ai-search/services/shareOfVoice";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
type Bundle = {
platform: LlmPlatform;
topPages: LlmTopPagesItem[];
mentions: LlmMentionItem[];
};
type PromptExamples = Map<string, Map<string, number | null>>;
const MAX_URL_LENGTH = 2048;
const MAX_QUESTION_LENGTH = 500;
/**
* Use DataForSEO top_pages for the ranked cited-source rows, then attach prompt
* examples from the mentions sample when the exact cited URL appears there.
* The page metrics stay authoritative while the prompt examples remain plainly
* sample-based.
*/
export function deriveCitedSources(
bundles: Bundle[],
limits: { sourcesPerPlatform: number; keywordsPerSource: number },
): BrandLookupResult["topPages"] {
const promptExamples = buildPromptExamples(bundles);
const rows = bundles.flatMap((bundle) =>
bundle.topPages
.map((page) => {
const url = safeHttpUrl(page.key);
if (!url || url.length > MAX_URL_LENGTH) return null;
const platformGroup = page.platform?.find(
(entry) => entry.key === bundle.platform,
);
const key = sourceKey(bundle.platform, url);
const examples =
promptExamples.get(key) ?? new Map<string, number | null>();
return {
url,
domain: safeHostname(url),
platform: bundle.platform,
mentions: roundOrNull(platformGroup?.mentions),
capturedVolume: roundOrNull(platformGroup?.ai_search_volume),
keywords: sortBy(
Array.from(examples.entries()).map(
([question, aiSearchVolume]) => ({
question,
aiSearchVolume,
}),
),
[(keyword) => keyword.aiSearchVolume ?? 0, "desc"],
).slice(0, limits.keywordsPerSource),
};
})
.filter((row): row is NonNullable<typeof row> => row !== null),
);
// Keep the top sources PER PLATFORM so a high-volume platform (Google) can't
// crowd out a sparse one (ChatGPT, US/en only) entirely. Then order the
// combined set by captured volume for a sensible default.
const byPlatform = new Map<LlmPlatform, typeof rows>();
for (const row of rows) {
const list = byPlatform.get(row.platform) ?? [];
list.push(row);
byPlatform.set(row.platform, list);
}
const capped = Array.from(byPlatform.values()).flatMap((list) =>
sortBy(list, [(row) => row.capturedVolume ?? 0, "desc"]).slice(
0,
limits.sourcesPerPlatform,
),
);
return sortBy(
capped,
[(row) => row.capturedVolume ?? 0, "desc"],
[(row) => row.mentions ?? 0, "desc"],
);
}
function buildPromptExamples(bundles: Bundle[]): PromptExamples {
const examples: PromptExamples = new Map();
for (const bundle of bundles) {
for (const mention of bundle.mentions) {
const question =
typeof mention.question === "string"
? truncate(mention.question, MAX_QUESTION_LENGTH)
: "";
if (question.length === 0) continue;
const volume = roundOrNull(mention.ai_search_volume);
for (const source of mention.sources ?? []) {
const url = safeHttpUrl(source.url);
if (!url) continue;
const key = sourceKey(bundle.platform, url);
const existing = examples.get(key) ?? new Map<string, number | null>();
if (!existing.has(question)) existing.set(question, volume);
examples.set(key, existing);
}
}
}
return examples;
}
function sourceKey(platform: LlmPlatform, url: string): string {
return `${platform}::${url}`;
}
function truncate(value: string, maxLength: number): string {
return value.length <= maxLength ? value : value.slice(0, maxLength);
}

View File

@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
computeShareOfVoice,
resolveCompetitorGroups,
type CrossOutcome,
} from "./shareOfVoice";
import type { LlmCrossAggregatedItem } from "@/server/lib/dataforseoLlmSchemas";
function crossItem(
key: string,
platformMentions: Array<{ key: string; mentions: number | null }>,
): LlmCrossAggregatedItem {
return {
key,
platform: platformMentions.map((p) => ({
key: p.key,
mentions: p.mentions,
ai_search_volume: null,
})),
};
}
describe("computeShareOfVoice", () => {
it("sums requested rows, excludes nulls, and ignores unrequested provider rows", () => {
const outcomes: CrossOutcome[] = [
{
platform: "google",
status: "success",
items: [
crossItem("acme", [{ key: "google", mentions: 30 }]),
crossItem("rival", [{ key: "google", mentions: 10 }]),
crossItem("ghost", [{ key: "google", mentions: null }]),
crossItem("unexpected", [{ key: "google", mentions: 60 }]),
],
},
];
const entries = computeShareOfVoice(outcomes, "acme", [
"rival",
"ghost",
])!.entries;
expect(entries.map((entry) => entry.label)).toEqual([
"acme",
"rival",
"ghost",
]);
expect(entries[0]).toMatchObject({ label: "acme", sharePct: 75 });
expect(entries[1]).toMatchObject({ label: "rival", sharePct: 25 });
expect(entries[2]).toMatchObject({ mentions: null, sharePct: null });
});
it("returns null with no competitors or no successful calls", () => {
expect(computeShareOfVoice([], "acme", [])).toBe(null);
expect(
computeShareOfVoice(
[
{ platform: "chat_gpt", status: "error", items: [] },
{ platform: "google", status: "error", items: [] },
],
"acme",
["rival"],
),
).toBe(null);
});
});
describe("resolveCompetitorGroups", () => {
it("dedupes case-insensitively and drops target collisions", () => {
const groups = resolveCompetitorGroups("Nike", [
"nike",
"Adidas",
"ADIDAS",
"puma.com",
"www.PUMA.com",
]);
expect(groups.map((g) => g.label)).toEqual(["Adidas", "puma.com"]);
});
});

View File

@ -0,0 +1,132 @@
import { sortBy } from "remeda";
import type { LlmCrossAggregatedItem } from "@/server/lib/dataforseoLlmSchemas";
import type { LlmPlatform } from "@/server/lib/dataforseo";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
import { detectTarget } from "@/shared/targetDetection";
export type CrossOutcome = {
platform: LlmPlatform;
status: "success" | "error";
items: LlmCrossAggregatedItem[];
};
export type CompetitorGroup = {
label: string;
detected: ReturnType<typeof detectTarget>;
};
/**
* Resolve raw competitor inputs into comparison groups: detect each one's
* target type, dedupe by resolved value, and drop any that collide with the
* target a duplicate aggregation group adds a redundant leaderboard row and
* wastes a paid comparison slot. Dedupe is case-insensitive: domains are
* already lowercased by detectTarget, but keyword targets preserve case while
* DataForSEO matches them case-insensitively, so "Nike" and "nike" would be
* two paid groups returning the same counts.
*/
export function resolveCompetitorGroups(
targetValue: string,
competitors: string[],
): CompetitorGroup[] {
const seen = new Set<string>([targetValue.toLowerCase()]);
const groups: CompetitorGroup[] = [];
for (const competitor of competitors) {
const detected = detectTarget(competitor);
const dedupeKey = detected.value.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
groups.push({ label: detected.value, detected });
}
return groups;
}
/**
* Build Share of Voice from the per-platform cross_aggregated calls. For each
* brand (item.key), sum mentions across the platforms the calls returned.
*
* Every requested group (target + competitors) is seeded as a row up front, so
* a brand the API returned no item for renders as "no data" instead of
* silently vanishing from a leaderboard the user paid to compare it on. Echoed
* aggregation_keys are matched back to requested keys case-insensitively so
* vendor normalization can't orphan a row or the target's isTarget flag.
*
* US/en assumption: today the only locale that exists for both platforms is
* US/en (the UI hardcodes locationCode 2840 / languageCode en and has no locale
* selector), so we sum every returned platform. If a locale selector ships,
* gate chat_gpt here the same way the single-brand totals do in shapeResult
* do not duplicate the chatGptLocaleMatches branch into this path.
*
* Null vs zero: a brand whose summed mentions is null is "no data" (excluded
* from the denominator, sharePct null); a brand with mentions 0 is known-zero
* and counts. sharePct = mentions / denominator * 100, guarded against
* divide-by-zero. Returns null when there are no competitors or both calls
* failed (so the UI omits the section rather than blanking the page).
*/
export function computeShareOfVoice(
crossOutcomes: CrossOutcome[],
targetKey: string,
competitorKeys: string[],
): BrandLookupResult["shareOfVoice"] {
if (competitorKeys.length === 0) return null;
const successful = crossOutcomes.filter((c) => c.status === "success");
if (successful.length === 0) return null;
const requestedKeys = [targetKey, ...competitorKeys];
const labelByKey = new Map(
requestedKeys.map((key) => [key.toLowerCase(), key]),
);
const mentionsByKey = new Map<string, number | null>(
requestedKeys.map((key) => [key.toLowerCase(), null]),
);
for (const outcome of successful) {
for (const item of outcome.items) {
if (item.key == null) continue;
const key = item.key.toLowerCase();
// The provider should echo only requested aggregation keys. If it ever
// returns extra rows, do not let them alter requested share percentages.
if (!labelByKey.has(key)) continue;
const platformMentions = sumNullable(
(item.platform ?? []).map((entry) => roundOrNull(entry.mentions)),
);
const prior = mentionsByKey.get(key) ?? null;
// null + null stays null ("no data"); null + n = n; m + n = m + n.
mentionsByKey.set(key, sumNullable([prior, platformMentions]));
}
}
const denominator = sumNullable(Array.from(mentionsByKey.values())) ?? 0;
const targetLower = targetKey.toLowerCase();
const entries = sortBy(
Array.from(mentionsByKey.entries()).map(([key, mentions]) => ({
label: labelByKey.get(key) ?? key,
isTarget: key === targetLower,
mentions,
sharePct:
mentions == null || denominator <= 0
? null
: (mentions / denominator) * 100,
})),
[(entry) => entry.mentions ?? -1, "desc"],
);
return { platforms: successful.map((outcome) => outcome.platform), entries };
}
export function sumNullable(values: Array<number | null>): number | null {
let total = 0;
let hasValue = false;
for (const value of values) {
if (value != null) {
total += value;
hasValue = true;
}
}
return hasValue ? total : null;
}
export function roundOrNull(value: number | null | undefined): number | null {
if (value == null) return null;
return Math.round(value);
}

View File

@ -3,9 +3,11 @@ import {
AiOptimizationChatGptLlmResponsesLiveRequestInfo, AiOptimizationChatGptLlmResponsesLiveRequestInfo,
AiOptimizationClaudeLlmResponsesLiveRequestInfo, AiOptimizationClaudeLlmResponsesLiveRequestInfo,
AiOptimizationGeminiLlmResponsesLiveRequestInfo, AiOptimizationGeminiLlmResponsesLiveRequestInfo,
AiOptimizationLLmMentionsCrossAggregateMetricsTargetInfo,
AiOptimizationLLmMentionsDomainElement, AiOptimizationLLmMentionsDomainElement,
AiOptimizationLLmMentionsKeywordElement, AiOptimizationLLmMentionsKeywordElement,
AiOptimizationLlmMentionsAggregatedMetricsLiveRequestInfo, AiOptimizationLlmMentionsAggregatedMetricsLiveRequestInfo,
AiOptimizationLlmMentionsCrossAggregatedMetricsLiveRequestInfo,
AiOptimizationLlmMentionsSearchLiveRequestInfo, AiOptimizationLlmMentionsSearchLiveRequestInfo,
AiOptimizationLlmMentionsTopPagesLiveRequestInfo, AiOptimizationLlmMentionsTopPagesLiveRequestInfo,
type BaseAiOptimizationLLmMentionsTargetElement, type BaseAiOptimizationLLmMentionsTargetElement,
@ -13,10 +15,12 @@ import {
} from "dataforseo-client"; } from "dataforseo-client";
import { import {
llmAggregatedTotalSchema, llmAggregatedTotalSchema,
llmCrossAggregatedItemSchema,
llmMentionItemSchema, llmMentionItemSchema,
llmResponseResultSchema, llmResponseResultSchema,
llmTopPagesItemSchema, llmTopPagesItemSchema,
type LlmAggregatedTotal, type LlmAggregatedTotal,
type LlmCrossAggregatedItem,
type LlmMentionItem, type LlmMentionItem,
type LlmResponseResult, type LlmResponseResult,
type LlmTopPagesItem, type LlmTopPagesItem,
@ -240,6 +244,66 @@ export async function fetchLlmTopPages(
return { data: items.data, billing: buildTaskBilling(task) }; return { data: items.data, billing: buildTaskBilling(task) };
} }
// ---------------------------------------------------------------------------
// LLM Mentions Cross-Aggregated Metrics
// Compares 2..10 aggregation groups (target + competitors) in one call and
// returns one item per group, keyed by its aggregation_key (brand label).
// ---------------------------------------------------------------------------
type LlmCrossAggregatedMetricsInput = {
groups: Array<{ key: string; target: LlmTarget }>;
platform: LlmPlatform;
locationCode: number;
languageCode: string;
internalListLimit?: number;
};
export async function fetchLlmCrossAggregatedMetrics(
input: LlmCrossAggregatedMetricsInput,
): Promise<DataforseoApiResponse<LlmCrossAggregatedItem[]>> {
if (input.groups.length < 2 || input.groups.length > 10) {
throw new AppError(
"VALIDATION_ERROR",
"DataForSEO llm_mentions/cross_aggregated_metrics requires 2 to 10 target groups",
);
}
const response = await aiOptimizationApi(
classifyAiSearchError,
).llmMentionsCrossAggregatedMetricsLive([
new AiOptimizationLlmMentionsCrossAggregatedMetricsLiveRequestInfo({
targets: input.groups.map(
(group) =>
new AiOptimizationLLmMentionsCrossAggregateMetricsTargetInfo({
aggregation_key: group.key,
target: targetList(group.target),
}),
),
platform: input.platform,
location_code: input.locationCode,
language_code: input.languageCode,
internal_list_limit: clampLimit(input.internalListLimit ?? 5, 1, 10),
}),
]);
const task = assertOk(
response,
assertOptions(
"/v3/ai_optimization/llm_mentions/cross_aggregated_metrics/live",
),
);
const items = z
.array(llmCrossAggregatedItemSchema)
.safeParse(firstResult(task)?.items ?? []);
if (!items.success) {
throw new AppError(
"INTERNAL_ERROR",
"DataForSEO llm_mentions/cross_aggregated_metrics returned an invalid shape",
);
}
return { data: items.data, billing: buildTaskBilling(task) };
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// LLM Responses (per-model) // LLM Responses (per-model)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -80,6 +80,7 @@ vi.mock("@/server/lib/dataforseo/ai", () => ({
fetchLlmMentionsSearch: vi.fn(), fetchLlmMentionsSearch: vi.fn(),
fetchLlmAggregatedMetrics: vi.fn(), fetchLlmAggregatedMetrics: vi.fn(),
fetchLlmTopPages: vi.fn(), fetchLlmTopPages: vi.fn(),
fetchLlmCrossAggregatedMetrics: vi.fn(),
fetchLlmResponse: vi.fn(), fetchLlmResponse: vi.fn(),
})); }));

View File

@ -41,6 +41,7 @@ import {
import { fetchLighthouseResult } from "@/server/lib/dataforseo/lighthouse"; import { fetchLighthouseResult } from "@/server/lib/dataforseo/lighthouse";
import { import {
fetchLlmAggregatedMetrics, fetchLlmAggregatedMetrics,
fetchLlmCrossAggregatedMetrics,
fetchLlmMentionsSearch, fetchLlmMentionsSearch,
fetchLlmResponse, fetchLlmResponse,
fetchLlmTopPages, fetchLlmTopPages,
@ -125,6 +126,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
mentionsSearch: meter(customer, fetchLlmMentionsSearch), mentionsSearch: meter(customer, fetchLlmMentionsSearch),
aggregatedMetrics: meter(customer, fetchLlmAggregatedMetrics), aggregatedMetrics: meter(customer, fetchLlmAggregatedMetrics),
topPages: meter(customer, fetchLlmTopPages), topPages: meter(customer, fetchLlmTopPages),
crossAggregatedMetrics: meter(customer, fetchLlmCrossAggregatedMetrics),
llmResponse: meter(customer, fetchLlmResponse), llmResponse: meter(customer, fetchLlmResponse),
}, },
} as const; } as const;

View File

@ -8,6 +8,7 @@ import { fetchQuestionsAnswers } from "@/server/lib/dataforseo/business";
import { import {
buildLlmTarget, buildLlmTarget,
fetchLlmAggregatedMetrics, fetchLlmAggregatedMetrics,
fetchLlmCrossAggregatedMetrics,
fetchLlmMentionsSearch, fetchLlmMentionsSearch,
fetchLlmResponse, fetchLlmResponse,
fetchLlmTopPages, fetchLlmTopPages,
@ -83,7 +84,7 @@ describe("DataForSEO SDK-backed endpoints", () => {
}); });
}); });
it("serializes LLM mentions domain targets for all live endpoints", async () => { it("serializes LLM mentions domain targets for search, top pages, and aggregated endpoints", async () => {
const fetchMock = vi.fn<typeof fetch>().mockImplementation((url) => { const fetchMock = vi.fn<typeof fetch>().mockImplementation((url) => {
const path = const path =
typeof url === "string" || url instanceof URL typeof url === "string" || url instanceof URL
@ -132,8 +133,8 @@ describe("DataForSEO SDK-backed endpoints", () => {
platform: "google", platform: "google",
locationCode: 2840, locationCode: 2840,
languageCode: "en", languageCode: "en",
itemsListLimit: 10,
}); });
const expectedTarget = [ const expectedTarget = [
{ {
search_scope: ["any"], search_scope: ["any"],
@ -179,6 +180,79 @@ describe("DataForSEO SDK-backed endpoints", () => {
]); ]);
}); });
it("serializes cross-aggregated target groups", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
Response.json({
status_code: 20000,
tasks: [
{
status_code: 20000,
path: [
"v3",
"ai_optimization",
"llm_mentions",
"cross_aggregated_metrics",
"live",
],
cost: 0.0001,
result_count: 1,
result: [{ items: [] }],
},
],
}),
);
vi.stubGlobal("fetch", fetchMock);
await fetchLlmCrossAggregatedMetrics({
groups: [
{
key: "example.com",
target: buildLlmTarget({ type: "domain", value: "example.com" }),
},
{
key: "Acme Storage",
target: buildLlmTarget({ type: "keyword", value: "Acme Storage" }),
},
],
platform: "google",
locationCode: 2840,
languageCode: "en",
});
expect(parseDataforseoRequestBody(fetchMock.mock.calls[0]?.[1])).toEqual([
{
targets: [
{
aggregation_key: "example.com",
target: [
{
search_scope: ["any"],
search_filter: "include",
domain: "example.com",
include_subdomains: true,
},
],
},
{
aggregation_key: "Acme Storage",
target: [
{
search_scope: ["any", "brand_entities"],
search_filter: "include",
keyword: "Acme Storage",
match_type: "word_match",
},
],
},
],
location_code: 2840,
language_code: "en",
platform: "google",
internal_list_limit: 5,
},
]);
});
it("serializes LLM mentions keyword targets", async () => { it("serializes LLM mentions keyword targets", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue( const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
Response.json({ Response.json({

View File

@ -90,6 +90,27 @@ export const llmTopPagesItemSchema = z
export type LlmTopPagesItem = z.infer<typeof llmTopPagesItemSchema>; export type LlmTopPagesItem = z.infer<typeof llmTopPagesItemSchema>;
// ---------------------------------------------------------------------------
// LLM Mentions Cross-Aggregated Metrics — `/v3/ai_optimization/llm_mentions/cross_aggregated_metrics/live`
// One item per requested aggregation group (target + competitors).
// `.passthrough()` because the real item also carries location, language,
// sources_domain, and brand_entities arrays we intentionally ignore.
// ---------------------------------------------------------------------------
export const llmCrossAggregatedItemSchema = z
.object({
// The shared SDK type AiOptimizationLlmMentionssLiveItem documents `key` as
// the URL of a found page, but for cross_aggregated `key` is the request
// aggregation_key (the brand label).
key: z.string().nullable().optional(),
platform: z.array(groupElementSchema).nullable().optional(),
})
.passthrough();
export type LlmCrossAggregatedItem = z.infer<
typeof llmCrossAggregatedItemSchema
>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// LLM Responses — shared between ChatGPT/Claude/Gemini/Perplexity // LLM Responses — shared between ChatGPT/Claude/Gemini/Perplexity
// All four model endpoints return the same envelope shape. // All four model endpoints return the same envelope shape.

View File

@ -22,9 +22,34 @@ export const aiSearchProjectSchema = z.object({
/** Maximum allowed length for a free-text brand or domain search input. */ /** Maximum allowed length for a free-text brand or domain search input. */
export const BRAND_LOOKUP_MAX_INPUT_LENGTH = 250; export const BRAND_LOOKUP_MAX_INPUT_LENGTH = 250;
/** Maximum number of competitors compared in one Share-of-Voice lookup. */
const BRAND_LOOKUP_MAX_COMPETITORS = 5;
/**
* Canonicalize raw comma-separated competitor text: split, trim, drop empties,
* dedupe, cap. Shared by the `c` URL-param transform and the page form so the
* two never diverge.
*/
export function parseCompetitorList(raw: string): string[] {
return Array.from(
new Set(
raw
.split(",")
.map((part) => part.trim())
.filter((part) => part.length > 0),
),
).slice(0, BRAND_LOOKUP_MAX_COMPETITORS);
}
export const brandLookupInputSchema = z.object({ export const brandLookupInputSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
query: z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH), query: z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH),
// Optional competitor brands/domains to compare Share of Voice against.
// cross_aggregated_metrics caps groups at 10 (target + 9); we cap at 5.
competitors: z
.array(z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH))
.max(BRAND_LOOKUP_MAX_COMPETITORS)
.default([]),
locationCode: z.number().int().positive().default(2840), locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"), languageCode: z.string().min(2).max(8).default("en"),
}); });
@ -36,18 +61,42 @@ const brandPlatformBreakdownSchema = z.object({
status: z.enum(["success", "error"]), status: z.enum(["success", "error"]),
mentions: z.number().int().nonnegative().nullable(), mentions: z.number().int().nonnegative().nullable(),
aiSearchVolume: z.number().int().nonnegative().nullable(), aiSearchVolume: z.number().int().nonnegative().nullable(),
impressions: z.number().int().nonnegative().nullable(), });
const brandShareOfVoiceSchema = z.object({
// The platforms whose cross_aggregated call succeeded and are summed into
// the entries — so the UI can caption a single-platform leaderboard honestly
// when the other platform's call failed.
platforms: z.array(z.enum(["chat_gpt", "google"])),
entries: z.array(
z.object({
label: z.string().max(BRAND_LOOKUP_MAX_INPUT_LENGTH),
isTarget: z.boolean(),
mentions: z.number().int().nonnegative().nullable(),
sharePct: z.number().nullable(),
}),
),
});
const brandTopPageKeywordSchema = z.object({
question: z.string().max(500),
aiSearchVolume: z.number().int().nonnegative().nullable(),
}); });
const brandTopPageSchema = z.object({ const brandTopPageSchema = z.object({
url: z.string(), url: z.string().max(2048),
domain: z.string().nullable(), domain: z.string().max(253).nullable(),
mentions: z.number().int().nonnegative().nullable(),
platform: z.enum(["chat_gpt", "google"]), platform: z.enum(["chat_gpt", "google"]),
// Page-level citation mentions from DataForSEO top_pages.
mentions: z.number().int().nonnegative().nullable(),
// Page-level AI search volume from DataForSEO top_pages.
capturedVolume: z.number().int().nonnegative().nullable(),
// Example prompts from the fetched mentions sample that cited this page.
keywords: z.array(brandTopPageKeywordSchema).max(50),
}); });
const brandTopQuerySchema = z.object({ const brandTopQuerySchema = z.object({
question: z.string(), question: z.string().max(500),
platform: z.enum(["chat_gpt", "google"]), platform: z.enum(["chat_gpt", "google"]),
aiSearchVolume: z.number().int().nonnegative().nullable(), aiSearchVolume: z.number().int().nonnegative().nullable(),
firstSeenAt: z.string().nullable(), firstSeenAt: z.string().nullable(),
@ -55,13 +104,13 @@ const brandTopQuerySchema = z.object({
citedSources: z citedSources: z
.array( .array(
z.object({ z.object({
url: z.string(), url: z.string().max(2048),
domain: z.string().nullable(), domain: z.string().max(253).nullable(),
title: z.string().nullable(), title: z.string().max(300).nullable(),
}), }),
) )
.max(10), .max(10),
brandsMentioned: z.array(z.string()).max(20), brandsMentioned: z.array(z.string().max(200)).max(20),
}); });
const brandMonthlyVolumeSchema = z.object({ const brandMonthlyVolumeSchema = z.object({
@ -78,9 +127,13 @@ export const brandLookupResultSchema = z.object({
hasData: z.boolean(), hasData: z.boolean(),
totalMentions: z.number().int().nonnegative().nullable(), totalMentions: z.number().int().nonnegative().nullable(),
totalAiSearchVolume: z.number().int().nonnegative().nullable(), totalAiSearchVolume: z.number().int().nonnegative().nullable(),
totalImpressions: z.number().int().nonnegative().nullable(),
perPlatform: z.array(brandPlatformBreakdownSchema), perPlatform: z.array(brandPlatformBreakdownSchema),
topPages: z.array(brandTopPageSchema).max(20), // Competitor Share of Voice — null when no competitors were supplied or both
// cross_aggregated calls failed. No legacy-cache shim: pre-SoV cache entries
// are unreachable anyway (the cache key's param set changed), see the
// buildCacheKey comment in brandLookup.ts.
shareOfVoice: brandShareOfVoiceSchema.nullable(),
topPages: z.array(brandTopPageSchema).max(40),
topQueries: z.array(brandTopQuerySchema).max(50), topQueries: z.array(brandTopQuerySchema).max(50),
monthlyVolume: z.array(brandMonthlyVolumeSchema), monthlyVolume: z.array(brandMonthlyVolumeSchema),
}); });
@ -205,9 +258,23 @@ export type PromptExplorerResult = z.infer<typeof promptExplorerResultSchema>;
// URL search params // URL search params
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /p/$projectId/brand-lookup query params — `q` keeps the lookup shareable. */ /**
* /p/$projectId/brand-lookup query params. `q` keeps the lookup shareable; `c`
* is a comma-joined competitor list (route + page treat the parsed result as an
* opaque string array). `c` accepts a raw string (from the URL) OR an array
* (TanStack Router re-validates its own transformed output on navigate) same
* union pattern as `models` below.
*/
export const brandLookupSearchSchema = z.object({ export const brandLookupSearchSchema = z.object({
q: z.string().optional(), q: z.string().optional(),
c: z
.union([z.string(), z.array(z.string())])
.optional()
.transform((value) =>
value === undefined
? undefined
: parseCompetitorList(Array.isArray(value) ? value.join(",") : value),
),
}); });
/** /**