AI Visibility: competitor Share of Voice + clarity fixes for AI citations (#248)
This commit is contained in:
parent
2db6fdec71
commit
8a85b7b794
10
README.md
10
README.md
@ -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.
|
||||
- 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
|
||||
|
||||
- 100 keyword research requests at the default 150 results: `$3.50`
|
||||
|
||||
@ -4,11 +4,14 @@ import {
|
||||
CHATGPT_LANGUAGE_CODE,
|
||||
CHATGPT_LOCATION_CODE,
|
||||
fetchLlmAggregatedMetrics,
|
||||
fetchLlmCrossAggregatedMetrics,
|
||||
fetchLlmMentionsSearch,
|
||||
fetchLlmTopPages,
|
||||
type LlmPlatform,
|
||||
} from "@/server/lib/dataforseo/ai";
|
||||
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";
|
||||
|
||||
loadLocalEnv();
|
||||
@ -48,8 +51,22 @@ async function main() {
|
||||
const userLocationCode = parsePositiveInteger(args.locationCode, 2840);
|
||||
const userLanguageCode = args.languageCode ?? "en";
|
||||
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 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 allRuns: RunSummary[] = [];
|
||||
@ -83,20 +100,42 @@ async function main() {
|
||||
});
|
||||
calls.push(toRecord(platform, "top_pages", topPages.billing));
|
||||
|
||||
// Prompt rows provide examples for the cited-source table.
|
||||
const mentions = await fetchLlmMentionsSearch({
|
||||
target: llmTarget,
|
||||
platform,
|
||||
locationCode,
|
||||
languageCode,
|
||||
limit: 25,
|
||||
limit: 100,
|
||||
});
|
||||
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 crossRawUsd = sum(
|
||||
calls
|
||||
.filter((c) => c.endpoint === "cross_aggregated_metrics")
|
||||
.map((c) => c.rawUsd),
|
||||
);
|
||||
allRuns.push({
|
||||
run: runIndex + 1,
|
||||
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),
|
||||
totalBilledUsd: applyBillingMarkupUsd(totalRawUsd),
|
||||
});
|
||||
@ -113,6 +152,7 @@ async function main() {
|
||||
targetType,
|
||||
userLocationCode,
|
||||
userLanguageCode,
|
||||
competitors: competitorGroups.map((group) => group.label),
|
||||
repeat,
|
||||
},
|
||||
runs: allRuns,
|
||||
@ -140,6 +180,8 @@ type CallRecord = {
|
||||
type RunSummary = {
|
||||
run: number;
|
||||
calls: CallRecord[];
|
||||
baseRawUsd: number;
|
||||
crossRawUsd: number;
|
||||
totalRawUsd: number;
|
||||
totalBilledUsd: number;
|
||||
};
|
||||
@ -183,7 +225,7 @@ function round(value: number): number {
|
||||
function printUsageAndExit(message: string): never {
|
||||
console.error(message);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -23,24 +23,29 @@ import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSear
|
||||
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
||||
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
||||
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 = {
|
||||
projectId: string;
|
||||
initialQuery: string;
|
||||
onQueryChange: (next: string) => void;
|
||||
initialCompetitors: string[];
|
||||
onSearchChange: (nextQuery: string, nextCompetitors: string[]) => void;
|
||||
};
|
||||
|
||||
const BRAND_LOOKUP_BULLETS = [
|
||||
{
|
||||
icon: TrendingUp,
|
||||
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,
|
||||
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,
|
||||
@ -60,24 +65,38 @@ export function BrandLookupPage(props: Props) {
|
||||
function BrandLookupPageInner({
|
||||
projectId,
|
||||
initialQuery,
|
||||
onQueryChange,
|
||||
initialCompetitors,
|
||||
onSearchChange,
|
||||
planGate,
|
||||
}: Props & { planGate: HostedPlanGateState }) {
|
||||
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 trimmedInitialQuery = initialQuery.trim();
|
||||
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({
|
||||
queryKey: ["brand-lookup", projectId, trimmedInitialQuery],
|
||||
queryKey: ["brand-lookup", projectId, trimmedInitialQuery, competitorKey],
|
||||
queryFn: () =>
|
||||
lookupBrand({
|
||||
data: {
|
||||
projectId,
|
||||
query: trimmedInitialQuery,
|
||||
competitors: initialCompetitors,
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
},
|
||||
@ -96,38 +115,83 @@ function BrandLookupPageInner({
|
||||
|
||||
// Dedup ref prevents repeat adds — `addSearch` identity is not stable
|
||||
// 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(() => {
|
||||
if (!hasActiveQuery || !lookupQuery.isSuccess) return;
|
||||
if (lastAddedQueryRef.current === trimmedInitialQuery) return;
|
||||
lastAddedQueryRef.current = trimmedInitialQuery;
|
||||
addSearch({ query: trimmedInitialQuery });
|
||||
}, [hasActiveQuery, lookupQuery.isSuccess, trimmedInitialQuery, addSearch]);
|
||||
const addedKey = `${trimmedInitialQuery}::${competitorKey}`;
|
||||
if (lastAddedKeyRef.current === addedKey) return;
|
||||
lastAddedKeyRef.current = addedKey;
|
||||
addSearch({
|
||||
query: trimmedInitialQuery,
|
||||
competitors: competitorKey ? competitorKey.split(",") : [],
|
||||
});
|
||||
}, [
|
||||
hasActiveQuery,
|
||||
lookupQuery.isSuccess,
|
||||
trimmedInitialQuery,
|
||||
competitorKey,
|
||||
addSearch,
|
||||
]);
|
||||
|
||||
const handleSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setValidationError("Enter a brand name or domain");
|
||||
setValidationError({
|
||||
field: "query",
|
||||
message: "Enter a brand name or domain",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (trimmed.length > BRAND_LOOKUP_MAX_INPUT_LENGTH) {
|
||||
setValidationError(
|
||||
`Keep it under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`,
|
||||
);
|
||||
setValidationError({
|
||||
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;
|
||||
}
|
||||
setValidationError(null);
|
||||
onQueryChange(trimmed);
|
||||
onSearchChange(trimmed, competitors);
|
||||
};
|
||||
|
||||
// The query input is reset whenever the URL `q` changes — including the
|
||||
// browser-back path and Cmd+click navigation. This keeps local form state
|
||||
// in sync with the URL source-of-truth.
|
||||
// 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 in
|
||||
// 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(() => {
|
||||
setQuery(initialQuery);
|
||||
setCompetitorsInput(competitorKey.split(",").join(", "));
|
||||
setValidationError(null);
|
||||
}, [initialQuery]);
|
||||
}, [initialQuery, competitorKey]);
|
||||
|
||||
const isLoading = hasActiveQuery && lookupQuery.isPending;
|
||||
const errorMessage =
|
||||
@ -159,7 +223,7 @@ function BrandLookupPageInner({
|
||||
) : planGate.isFreePlan ? (
|
||||
<AiSearchPaidPlanGate
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
@ -170,6 +234,11 @@ function BrandLookupPageInner({
|
||||
setQuery(next);
|
||||
if (validationError) setValidationError(null);
|
||||
}}
|
||||
competitors={competitorsInput}
|
||||
onCompetitorsChange={(next) => {
|
||||
setCompetitorsInput(next);
|
||||
if (validationError) setValidationError(null);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
validationError={validationError}
|
||||
@ -194,7 +263,7 @@ function BrandLookupPageInner({
|
||||
from="/p/$projectId/brand-lookup"
|
||||
to="/p/$projectId/brand-lookup"
|
||||
params={{ projectId }}
|
||||
search={{ q: undefined }}
|
||||
search={{ q: undefined, c: undefined }}
|
||||
replace
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
<BrandLookupResults result={resultData} />
|
||||
<BrandLookupResults result={resultData} projectId={projectId} />
|
||||
</>
|
||||
) : !errorMessage ? (
|
||||
<BrandLookupHistorySection
|
||||
|
||||
@ -44,7 +44,11 @@ export function filterTopPages(
|
||||
const excludeTerms = parseTerms(filters.exclude);
|
||||
|
||||
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))
|
||||
.join(" ");
|
||||
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { useState } from "react";
|
||||
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 { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||
import { numericNullsLast } from "@/client/components/table/nullSafeSort";
|
||||
import {
|
||||
formatCount,
|
||||
formatPlatformLabel,
|
||||
PLATFORM_DOT_CLASS,
|
||||
PLATFORM_SHORT_LABEL,
|
||||
} from "@/client/features/ai-search/platformLabels";
|
||||
import { formatUrlForDisplay } from "@/client/components/table/url";
|
||||
import type { BrandLookupResult } from "@/types/schemas/ai-search";
|
||||
@ -14,105 +18,327 @@ type TopPageRow = BrandLookupResult["topPages"][number];
|
||||
type TopQueryRow = BrandLookupResult["topQueries"][number];
|
||||
type PlatformKey = TopPageRow["platform"];
|
||||
|
||||
const PLATFORM_BADGE_CLASS: Record<PlatformKey, string> = {
|
||||
chat_gpt: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500",
|
||||
google: "border-sky-500/40 bg-sky-500/10 text-sky-500",
|
||||
};
|
||||
|
||||
function PlatformBadge({ platform }: { platform: PlatformKey }) {
|
||||
/** Uppercase column header with a hover/focus popover explaining the column. */
|
||||
function HeaderWithHelp({
|
||||
label,
|
||||
helpText,
|
||||
}: {
|
||||
label: string;
|
||||
helpText: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={`badge badge-sm border ${PLATFORM_BADGE_CLASS[platform]}`}>
|
||||
{formatPlatformLabel(platform)}
|
||||
<span className="uppercase tracking-wider">
|
||||
<HeaderHelpLabel label={label} helpText={helpText} />
|
||||
</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 queriesHelper = createColumnHelper<TopQueryRow>();
|
||||
|
||||
export const topPagesColumns = [
|
||||
pagesHelper.accessor("url", {
|
||||
id: "url",
|
||||
header: () => <span className="uppercase tracking-wider">URL</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<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}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
pagesHelper.accessor("platform", {
|
||||
id: "platform",
|
||||
header: () => <span className="uppercase tracking-wider">Platform</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => <PlatformBadge platform={getValue()} />,
|
||||
}),
|
||||
pagesHelper.accessor("mentions", {
|
||||
id: "mentions",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label="Mentions" align="right" />
|
||||
),
|
||||
cell: ({ getValue }) => (
|
||||
<span className="tabular-nums">{formatCount(getValue())}</span>
|
||||
),
|
||||
sortingFn: numericNullsLast,
|
||||
sortDescFirst: true,
|
||||
}),
|
||||
];
|
||||
export function buildTopPagesColumns({
|
||||
showPlatform,
|
||||
targetDomain,
|
||||
projectId,
|
||||
brand,
|
||||
}: {
|
||||
showPlatform: boolean;
|
||||
targetDomain: string | null;
|
||||
projectId: string;
|
||||
brand: string;
|
||||
}) {
|
||||
return [
|
||||
pagesHelper.accessor("url", {
|
||||
id: "url",
|
||||
header: () => (
|
||||
<HeaderWithHelp
|
||||
label="Source"
|
||||
helpText="A page cited as a source in AI answers where the searched brand or domain appears."
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<PageUrlCell row={row.original} targetDomain={targetDomain} />
|
||||
),
|
||||
}),
|
||||
...(showPlatform
|
||||
? [
|
||||
pagesHelper.accessor("platform", {
|
||||
id: "platform",
|
||||
header: () => (
|
||||
<HeaderWithHelp label="Platform" helpText={PLATFORM_HELP} />
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => <PlatformCell platform={getValue()} />,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
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 }) => (
|
||||
<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 }) => (
|
||||
<span className="tabular-nums">{formatCount(getValue())}</span>
|
||||
),
|
||||
sortingFn: numericNullsLast,
|
||||
sortDescFirst: true,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export const topQueriesColumns = [
|
||||
queriesHelper.accessor("question", {
|
||||
id: "question",
|
||||
header: () => <span className="uppercase tracking-wider">Query</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<p className="break-words font-medium">{row.original.question}</p>
|
||||
{row.original.brandsMentioned.length > 0 ? (
|
||||
<p className="mt-0.5 text-xs text-base-content/50">
|
||||
Brands: {row.original.brandsMentioned.slice(0, 5).join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
queriesHelper.accessor("platform", {
|
||||
id: "platform",
|
||||
header: () => <span className="uppercase tracking-wider">Platform</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => <PlatformBadge platform={getValue()} />,
|
||||
}),
|
||||
queriesHelper.accessor("aiSearchVolume", {
|
||||
id: "aiSearchVolume",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label="AI search vol." align="right" />
|
||||
),
|
||||
cell: ({ getValue }) => (
|
||||
<span className="tabular-nums">{formatCount(getValue())}</span>
|
||||
),
|
||||
sortingFn: numericNullsLast,
|
||||
sortDescFirst: true,
|
||||
}),
|
||||
];
|
||||
export function buildTopQueriesColumns({
|
||||
showPlatform,
|
||||
projectId,
|
||||
brand,
|
||||
}: {
|
||||
showPlatform: boolean;
|
||||
projectId: string;
|
||||
brand: string;
|
||||
}) {
|
||||
return [
|
||||
queriesHelper.accessor("question", {
|
||||
id: "question",
|
||||
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,
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<p className="break-words font-medium">{row.original.question}</p>
|
||||
{row.original.brandsMentioned.length > 0 ? (
|
||||
<p className="mt-0.5 text-xs text-base-content/50">
|
||||
Brands: {row.original.brandsMentioned.slice(0, 5).join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
...(showPlatform
|
||||
? [
|
||||
queriesHelper.accessor("platform", {
|
||||
id: "platform",
|
||||
header: () => (
|
||||
<HeaderWithHelp label="Platform" helpText={PLATFORM_HELP} />
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => <PlatformCell platform={getValue()} />,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
queriesHelper.accessor("aiSearchVolume", {
|
||||
id: "aiSearchVolume",
|
||||
header: ({ column }) => (
|
||||
<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 }) => (
|
||||
<span className="tabular-nums">{formatCount(getValue())}</span>
|
||||
),
|
||||
sortingFn: numericNullsLast,
|
||||
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> }) {
|
||||
if (table.getRowModel().rows.length === 0) {
|
||||
return (
|
||||
<p className="p-6 text-center text-sm text-base-content/60">
|
||||
No cited pages returned.
|
||||
No cited sources to show.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@ -142,6 +368,7 @@ function BrandLookupTable<T>({
|
||||
return (
|
||||
<AppDataTable
|
||||
table={table}
|
||||
getRowClassName={() => "group transition-colors hover:bg-base-200/40"}
|
||||
getCellClassName={(_, columnId) =>
|
||||
cellClassName(
|
||||
columnId,
|
||||
@ -161,6 +388,9 @@ function cellClassName(
|
||||
if (columnId === urlLikeColumnId) {
|
||||
return "min-w-80 max-w-2xl align-top";
|
||||
}
|
||||
if (columnId === "keywords") {
|
||||
return "max-w-lg align-top";
|
||||
}
|
||||
if (isNumeric) {
|
||||
return "whitespace-nowrap text-right align-top";
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -146,7 +146,7 @@ function TopPagesFilters({
|
||||
<div className="min-w-[220px]">
|
||||
<FilterRangeInputs
|
||||
form={form}
|
||||
title="Mentions"
|
||||
title="Source mentions"
|
||||
minName="minMentions"
|
||||
maxName="maxMentions"
|
||||
/>
|
||||
|
||||
@ -25,7 +25,13 @@ export function BrandLookupHistorySection({ projectId, ...props }: Props) {
|
||||
from="/p/$projectId/brand-lookup"
|
||||
to="/p/$projectId/brand-lookup"
|
||||
params={{ projectId }}
|
||||
search={{ q: item.query }}
|
||||
search={{
|
||||
q: item.query,
|
||||
c:
|
||||
item.competitors.length > 0
|
||||
? item.competitors.join(",")
|
||||
: undefined,
|
||||
}}
|
||||
replace
|
||||
className={HISTORY_ITEM_LINK_CLASS}
|
||||
>
|
||||
@ -33,7 +39,14 @@ export function BrandLookupHistorySection({ projectId, ...props }: Props) {
|
||||
</Link>
|
||||
)}
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,45 +1,23 @@
|
||||
import { useMemo, useState } from "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 { Info } from "lucide-react";
|
||||
import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard";
|
||||
import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel";
|
||||
import {
|
||||
TopPagesTable,
|
||||
TopQueriesTable,
|
||||
topPagesColumns,
|
||||
topQueriesColumns,
|
||||
} from "@/client/features/ai-search/components/BrandLookupCitationTables";
|
||||
import { BrandLookupShareOfVoice } from "@/client/features/ai-search/components/BrandLookupShareOfVoice";
|
||||
import { CitationTabsCard } from "@/client/features/ai-search/components/BrandLookupCitationsCard";
|
||||
import {
|
||||
formatCount,
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
result: BrandLookupResult;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
type PlatformRow = BrandLookupResult["perPlatform"][number];
|
||||
type MetricKey = "mentions" | "aiSearchVolume" | "impressions";
|
||||
type MetricKey = "mentions" | "aiSearchVolume";
|
||||
|
||||
const PLATFORM_DOT_CLASS: Record<PlatformRow["platform"], string> = {
|
||||
chat_gpt: "bg-emerald-500",
|
||||
google: "bg-sky-500",
|
||||
};
|
||||
|
||||
export function BrandLookupResults({ result }: Props) {
|
||||
export function BrandLookupResults({ result, projectId }: Props) {
|
||||
if (!result.hasData) {
|
||||
const erroredPlatforms = result.perPlatform.filter(
|
||||
(p) => p.status === "error",
|
||||
@ -76,17 +54,27 @@ export function BrandLookupResults({ result }: Props) {
|
||||
}
|
||||
|
||||
const hasTrendData = result.monthlyVolume.length > 0;
|
||||
const sov = result.shareOfVoice;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
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}
|
||||
{sov ? <BrandLookupShareOfVoice shareOfVoice={sov} /> : null}
|
||||
</div>
|
||||
<CitationTabsCard result={result} />
|
||||
|
||||
<CitationTabsCard result={result} projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -109,64 +97,54 @@ function BrandHeader({ result }: { result: BrandLookupResult }) {
|
||||
);
|
||||
}
|
||||
|
||||
function KpiTiles({ result }: { result: BrandLookupResult }) {
|
||||
function StatsCard({ result }: { result: BrandLookupResult }) {
|
||||
return (
|
||||
<section className="flex flex-col divide-y divide-base-200 rounded-xl border border-base-300 bg-base-100">
|
||||
<KpiTile
|
||||
label="Total mentions"
|
||||
tooltip="Number of LLM answers where your domain appeared in the text or citations."
|
||||
total={result.totalMentions}
|
||||
perPlatform={result.perPlatform}
|
||||
metric="mentions"
|
||||
/>
|
||||
<KpiTile
|
||||
label="AI search volume"
|
||||
tooltip="Monthly volume of user prompts on topics where your domain shows up in LLM answers."
|
||||
total={result.totalAiSearchVolume}
|
||||
perPlatform={result.perPlatform}
|
||||
metric="aiSearchVolume"
|
||||
/>
|
||||
<KpiTile
|
||||
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 className="rounded-xl border border-base-300 bg-base-100">
|
||||
<div className="flex h-full flex-col divide-y divide-base-200">
|
||||
<StatBlock
|
||||
label="Mentions"
|
||||
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}
|
||||
metric="mentions"
|
||||
/>
|
||||
<StatBlock
|
||||
label="AI search volume"
|
||||
tooltip="Estimated monthly search demand for prompts where the searched brand or domain appears in AI answers. This is prompt demand, not mention count."
|
||||
value={result.totalAiSearchVolume}
|
||||
perPlatform={result.perPlatform}
|
||||
metric="aiSearchVolume"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiTile({
|
||||
function StatBlock({
|
||||
label,
|
||||
tooltip,
|
||||
total,
|
||||
value,
|
||||
perPlatform,
|
||||
metric,
|
||||
}: {
|
||||
label: string;
|
||||
tooltip: string;
|
||||
total: number | null;
|
||||
value: number | null;
|
||||
perPlatform: PlatformRow[];
|
||||
metric: MetricKey;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-between gap-6 px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-base-content/50">
|
||||
{label}
|
||||
<span
|
||||
className="tooltip tooltip-right inline-flex normal-case"
|
||||
data-tip={tooltip}
|
||||
>
|
||||
<Info className="size-3 text-base-content/40" />
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">
|
||||
{formatCount(total)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1.5 min-w-[12rem]">
|
||||
<div className="flex flex-1 flex-col justify-center p-4">
|
||||
<p className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-base-content/50">
|
||||
{label}
|
||||
<span className="tooltip inline-flex normal-case" data-tip={tooltip}>
|
||||
<Info className="size-3 text-base-content/40" />
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-3xl font-semibold tabular-nums">
|
||||
{formatCount(value)}
|
||||
</p>
|
||||
<div className="mt-3 space-y-1 border-t border-base-200 pt-2.5">
|
||||
{perPlatform.map((row) => (
|
||||
<PlatformStatRow key={row.platform} row={row} metric={metric} />
|
||||
))}
|
||||
@ -193,7 +171,7 @@ function PlatformStatRow({
|
||||
{formatPlatformLabel(row.platform)}
|
||||
{row.platform === "chat_gpt" ? (
|
||||
<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."
|
||||
>
|
||||
<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 {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "just now";
|
||||
|
||||
@ -7,72 +7,114 @@ import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
|
||||
type Props = {
|
||||
query: string;
|
||||
onQueryChange: (next: string) => void;
|
||||
competitors: string;
|
||||
onCompetitorsChange: (next: string) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
isLoading: boolean;
|
||||
validationError: string | null;
|
||||
validationError: { field: "query" | "competitors"; message: string } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One brand lookup = 6 DataForSEO calls (3 endpoints × 2 platforms). Measured
|
||||
* live at ~$0.634 raw via `pnpm billing:brand-lookup`; rounded up to leave
|
||||
* headroom for per-query variance.
|
||||
* One brand lookup = 6 DataForSEO calls (aggregated_metrics + top_pages +
|
||||
* mentions_search × 2 platforms). Rounded up with headroom because
|
||||
* 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
|
||||
// DataForSEO directly at the raw rate.
|
||||
const BRAND_LOOKUP_DISPLAYED_COST_USD = isHostedClientAuthMode()
|
||||
? applyBillingMarkupUsd(BRAND_LOOKUP_RAW_COST_USD)
|
||||
: BRAND_LOOKUP_RAW_COST_USD;
|
||||
const markup = (rawUsd: number) =>
|
||||
isHostedClientAuthMode() ? applyBillingMarkupUsd(rawUsd) : rawUsd;
|
||||
|
||||
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({
|
||||
query,
|
||||
onQueryChange,
|
||||
competitors,
|
||||
onCompetitorsChange,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
validationError,
|
||||
}: Props) {
|
||||
const hasCompetitors = competitors.trim().length > 0;
|
||||
const queryError = validationError?.field === "query";
|
||||
const competitorsError = validationError?.field === "competitors";
|
||||
|
||||
return (
|
||||
<div className="card border border-base-300 bg-base-100">
|
||||
<div className="card-body gap-4">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-3 lg:flex-row lg:items-center"
|
||||
>
|
||||
<label
|
||||
className={`input input-bordered flex flex-1 items-center gap-2 ${
|
||||
validationError ? "input-error" : ""
|
||||
}`}
|
||||
>
|
||||
<Search className="size-4 text-base-content/60" />
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<label
|
||||
className={`input input-bordered flex flex-1 items-center gap-2 ${
|
||||
queryError ? "input-error" : ""
|
||||
}`}
|
||||
>
|
||||
<Search className="size-4 text-base-content/60" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter a brand name or domain"
|
||||
value={query}
|
||||
maxLength={BRAND_LOOKUP_MAX_INPUT_LENGTH}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
aria-invalid={queryError || undefined}
|
||||
aria-describedby={
|
||||
queryError ? "brand-lookup-input-error" : undefined
|
||||
}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="grow"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary px-6"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Looking up..." : "Look up"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter a brand name or domain"
|
||||
value={query}
|
||||
maxLength={BRAND_LOOKUP_MAX_INPUT_LENGTH}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
aria-invalid={validationError ? true : undefined}
|
||||
aria-describedby={
|
||||
validationError ? "brand-lookup-input-error" : undefined
|
||||
}
|
||||
placeholder="Add competitors (comma-separated)"
|
||||
value={competitors}
|
||||
onChange={(event) => onCompetitorsChange(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="grow"
|
||||
className={`input input-bordered w-full ${
|
||||
competitorsError ? "input-error" : ""
|
||||
}`}
|
||||
aria-label="Competitors"
|
||||
aria-invalid={competitorsError || undefined}
|
||||
aria-describedby={
|
||||
competitorsError ? "brand-lookup-input-error" : undefined
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary px-6"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Looking up..." : "Look up"}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{validationError ? (
|
||||
<p id="brand-lookup-input-error" className="text-sm text-error">
|
||||
{validationError}
|
||||
{validationError.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@ -82,6 +124,14 @@ export function BrandLookupSearchCard({
|
||||
<span className="font-medium text-base-content/80">
|
||||
${BRAND_LOOKUP_DISPLAYED_COST_USD.toFixed(2)}
|
||||
</span>
|
||||
{hasCompetitors ? (
|
||||
<span>
|
||||
{" "}
|
||||
plus ~$
|
||||
{BRAND_LOOKUP_COMPETITOR_DISPLAYED_COST_USD.toFixed(2)} to
|
||||
compare competitors
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -14,12 +14,21 @@ export function buildBrandLookupExport(
|
||||
): { headers: string[]; rows: CsvValue[][] } {
|
||||
if (tab === "pages") {
|
||||
return {
|
||||
headers: ["URL", "Domain", "Platform", "Mentions"],
|
||||
headers: [
|
||||
"URL",
|
||||
"Domain",
|
||||
"Platform",
|
||||
"Source mentions",
|
||||
"Source AI search volume",
|
||||
"Fetched-sample prompt examples",
|
||||
],
|
||||
rows: sortedPages.map((row) => [
|
||||
row.url,
|
||||
row.domain ?? "",
|
||||
formatPlatformLabel(row.platform),
|
||||
row.mentions ?? "",
|
||||
row.capturedVolume ?? "",
|
||||
row.keywords.map((keyword) => keyword.question).join("; "),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
@ -48,6 +48,17 @@ export function formatPlatformLabel(platform: "chat_gpt" | "google"): string {
|
||||
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 {
|
||||
return MODEL_LABELS[model];
|
||||
}
|
||||
|
||||
@ -8,7 +8,10 @@ import {
|
||||
} from "./brandLookupFilterTypes";
|
||||
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>;
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@ import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearch
|
||||
|
||||
const brandLookupSearchBodySchema = z.object({
|
||||
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>;
|
||||
@ -15,6 +17,10 @@ export function useBrandLookupSearchHistory(projectId: string) {
|
||||
return useTimestampedSearchHistory({
|
||||
storageKey: `brand-lookup-search-history:${projectId}`,
|
||||
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(","),
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,17 +10,24 @@ export const Route = createFileRoute("/_project/p/$projectId/brand-lookup")({
|
||||
function BrandLookupRoute() {
|
||||
const { projectId } = Route.useParams();
|
||||
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 (
|
||||
<BrandLookupPage
|
||||
projectId={projectId}
|
||||
initialQuery={q}
|
||||
onQueryChange={(nextQuery) => {
|
||||
initialCompetitors={c}
|
||||
onSearchChange={(nextQuery, nextCompetitors) => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
q: nextQuery.trim() || undefined,
|
||||
// One serialization site: comma-join the competitor list.
|
||||
c:
|
||||
nextCompetitors.length > 0
|
||||
? nextCompetitors.join(",")
|
||||
: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
|
||||
283
src/server/features/ai-search/services/brandLookup.test.ts
Normal file
283
src/server/features/ai-search/services/brandLookup.test.ts
Normal 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 }],
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import { waitUntil } from "cloudflare:workers";
|
||||
import { sortBy } from "remeda";
|
||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
import { createDataforseoClient } from "@/server/lib/dataforseo";
|
||||
import {
|
||||
@ -8,20 +7,25 @@ import {
|
||||
CHATGPT_LOCATION_CODE,
|
||||
type LlmPlatform,
|
||||
} from "@/server/lib/dataforseo";
|
||||
import type {
|
||||
LlmAggregatedTotal,
|
||||
LlmMentionItem,
|
||||
LlmTopPagesItem,
|
||||
} from "@/server/lib/dataforseoLlmSchemas";
|
||||
import type { LlmCrossAggregatedItem } from "@/server/lib/dataforseoLlmSchemas";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
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 {
|
||||
brandLookupResultSchema,
|
||||
type BrandLookupInput,
|
||||
type BrandLookupResult,
|
||||
} 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
|
||||
@ -35,39 +39,77 @@ const BRAND_LOOKUP_TTL_SECONDS = 24 * 60 * 60;
|
||||
|
||||
const PLATFORMS: LlmPlatform[] = ["chat_gpt", "google"];
|
||||
|
||||
const TOP_PAGES_PER_PLATFORM = 10;
|
||||
const TOP_QUERIES_PER_PLATFORM = 25;
|
||||
// Prompt rows supply explainable examples for cited pages. Ranked source rows
|
||||
// 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(
|
||||
input: BrandLookupInput,
|
||||
billingCustomer: BillingCustomerContext,
|
||||
): Promise<BrandLookupResult> {
|
||||
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", {
|
||||
organizationId: billingCustomer.organizationId,
|
||||
projectId: input.projectId,
|
||||
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,
|
||||
languageCode: input.languageCode,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Settle each platform independently so a failure in one doesn't discard
|
||||
// the other (which the caller already paid for via meterDataforseoCall).
|
||||
const settled = await Promise.allSettled(
|
||||
PLATFORMS.map((platform) =>
|
||||
fetchPlatformData(platform, detected, input, dataforseo),
|
||||
),
|
||||
);
|
||||
// Settle each platform independently so a failure in one doesn't discard the
|
||||
// other. Keep the metered DataForSEO calls sequenced: in hosted mode each
|
||||
// call checks balance before execution and records spend after, so parallel
|
||||
// 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 platform = PLATFORMS[i];
|
||||
if (settledResult.status === "fulfilled") {
|
||||
@ -84,13 +126,20 @@ export async function getBrandLookup(
|
||||
query: input.query,
|
||||
detected,
|
||||
platformBundles,
|
||||
crossOutcomes,
|
||||
competitorKeys: competitorGroups.map((g) => g.label),
|
||||
userLocationCode: input.locationCode,
|
||||
userLanguageCode: input.languageCode,
|
||||
});
|
||||
|
||||
// Only cache when every platform succeeded — otherwise users would see a
|
||||
// stale partial result for 24h and have no way to retry without busting it.
|
||||
const allSucceeded = platformBundles.every((b) => b.status === "success");
|
||||
// Only cache when every call succeeded — a platform bundle that swallowed a
|
||||
// failed sub-call into empty fallback data is renderable but must not be
|
||||
// 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) {
|
||||
waitUntil(
|
||||
setCached(cacheKey, result, BRAND_LOOKUP_TTL_SECONDS).catch((err) => {
|
||||
@ -102,23 +151,21 @@ export async function getBrandLookup(
|
||||
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<
|
||||
BrandLookupInput,
|
||||
"locationCode" | "languageCode"
|
||||
>;
|
||||
|
||||
type PlatformBundle = {
|
||||
aggregated: LlmAggregatedTotal;
|
||||
topPages: LlmTopPagesItem[];
|
||||
mentions: LlmMentionItem[];
|
||||
};
|
||||
|
||||
type PlatformOutcome = {
|
||||
platform: LlmPlatform;
|
||||
status: "success" | "error";
|
||||
bundle: PlatformBundle | null;
|
||||
};
|
||||
|
||||
async function fetchPlatformData(
|
||||
platform: LlmPlatform,
|
||||
detected: ReturnType<typeof detectTarget>,
|
||||
@ -136,9 +183,9 @@ async function fetchPlatformData(
|
||||
const languageCode =
|
||||
platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : input.languageCode;
|
||||
|
||||
// `allSettled` so one sub-call failing doesn't discard the other two we
|
||||
// already paid for. Each sub-call is metered independently upstream.
|
||||
const [aggregated, topPages, mentions] = await Promise.allSettled([
|
||||
// Settle sub-calls independently so one failure doesn't discard the others we
|
||||
// already paid for, but keep them sequenced for hosted billing checks.
|
||||
const aggregated = await settle(() =>
|
||||
dataforseo.aiSearch.aggregatedMetrics({
|
||||
target,
|
||||
platform,
|
||||
@ -146,21 +193,25 @@ async function fetchPlatformData(
|
||||
languageCode,
|
||||
internalListLimit: 20,
|
||||
}),
|
||||
);
|
||||
const topPages = await settle(() =>
|
||||
dataforseo.aiSearch.topPages({
|
||||
target,
|
||||
platform,
|
||||
locationCode,
|
||||
languageCode,
|
||||
itemsListLimit: TOP_PAGES_PER_PLATFORM,
|
||||
itemsListLimit: TOP_SOURCES_PER_PLATFORM,
|
||||
}),
|
||||
);
|
||||
const mentions = await settle(() =>
|
||||
dataforseo.aiSearch.mentionsSearch({
|
||||
target,
|
||||
platform,
|
||||
locationCode,
|
||||
languageCode,
|
||||
limit: TOP_QUERIES_PER_PLATFORM,
|
||||
limit: MENTIONS_PER_PLATFORM,
|
||||
}),
|
||||
]);
|
||||
);
|
||||
|
||||
rethrowIfBlockingAiSearchError([aggregated, topPages, mentions]);
|
||||
|
||||
@ -176,9 +227,76 @@ async function fetchPlatformData(
|
||||
aggregated: fulfilledOr(aggregated, () => ({}), platform, "aggregated"),
|
||||
topPages: fulfilledOr(topPages, () => [], platform, "topPages"),
|
||||
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(
|
||||
results: Array<PromiseSettledResult<unknown>>,
|
||||
): void {
|
||||
@ -208,197 +326,3 @@ function fulfilledOr<T>(
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
228
src/server/features/ai-search/services/brandLookupShaping.ts
Normal file
228
src/server/features/ai-search/services/brandLookupShaping.ts
Normal 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);
|
||||
}
|
||||
97
src/server/features/ai-search/services/citedSources.test.ts
Normal file
97
src/server/features/ai-search/services/citedSources.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
118
src/server/features/ai-search/services/citedSources.ts
Normal file
118
src/server/features/ai-search/services/citedSources.ts
Normal 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);
|
||||
}
|
||||
80
src/server/features/ai-search/services/shareOfVoice.test.ts
Normal file
80
src/server/features/ai-search/services/shareOfVoice.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
132
src/server/features/ai-search/services/shareOfVoice.ts
Normal file
132
src/server/features/ai-search/services/shareOfVoice.ts
Normal 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);
|
||||
}
|
||||
@ -3,9 +3,11 @@ import {
|
||||
AiOptimizationChatGptLlmResponsesLiveRequestInfo,
|
||||
AiOptimizationClaudeLlmResponsesLiveRequestInfo,
|
||||
AiOptimizationGeminiLlmResponsesLiveRequestInfo,
|
||||
AiOptimizationLLmMentionsCrossAggregateMetricsTargetInfo,
|
||||
AiOptimizationLLmMentionsDomainElement,
|
||||
AiOptimizationLLmMentionsKeywordElement,
|
||||
AiOptimizationLlmMentionsAggregatedMetricsLiveRequestInfo,
|
||||
AiOptimizationLlmMentionsCrossAggregatedMetricsLiveRequestInfo,
|
||||
AiOptimizationLlmMentionsSearchLiveRequestInfo,
|
||||
AiOptimizationLlmMentionsTopPagesLiveRequestInfo,
|
||||
type BaseAiOptimizationLLmMentionsTargetElement,
|
||||
@ -13,10 +15,12 @@ import {
|
||||
} from "dataforseo-client";
|
||||
import {
|
||||
llmAggregatedTotalSchema,
|
||||
llmCrossAggregatedItemSchema,
|
||||
llmMentionItemSchema,
|
||||
llmResponseResultSchema,
|
||||
llmTopPagesItemSchema,
|
||||
type LlmAggregatedTotal,
|
||||
type LlmCrossAggregatedItem,
|
||||
type LlmMentionItem,
|
||||
type LlmResponseResult,
|
||||
type LlmTopPagesItem,
|
||||
@ -240,6 +244,66 @@ export async function fetchLlmTopPages(
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -80,6 +80,7 @@ vi.mock("@/server/lib/dataforseo/ai", () => ({
|
||||
fetchLlmMentionsSearch: vi.fn(),
|
||||
fetchLlmAggregatedMetrics: vi.fn(),
|
||||
fetchLlmTopPages: vi.fn(),
|
||||
fetchLlmCrossAggregatedMetrics: vi.fn(),
|
||||
fetchLlmResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@ import {
|
||||
import { fetchLighthouseResult } from "@/server/lib/dataforseo/lighthouse";
|
||||
import {
|
||||
fetchLlmAggregatedMetrics,
|
||||
fetchLlmCrossAggregatedMetrics,
|
||||
fetchLlmMentionsSearch,
|
||||
fetchLlmResponse,
|
||||
fetchLlmTopPages,
|
||||
@ -125,6 +126,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
||||
mentionsSearch: meter(customer, fetchLlmMentionsSearch),
|
||||
aggregatedMetrics: meter(customer, fetchLlmAggregatedMetrics),
|
||||
topPages: meter(customer, fetchLlmTopPages),
|
||||
crossAggregatedMetrics: meter(customer, fetchLlmCrossAggregatedMetrics),
|
||||
llmResponse: meter(customer, fetchLlmResponse),
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -8,6 +8,7 @@ import { fetchQuestionsAnswers } from "@/server/lib/dataforseo/business";
|
||||
import {
|
||||
buildLlmTarget,
|
||||
fetchLlmAggregatedMetrics,
|
||||
fetchLlmCrossAggregatedMetrics,
|
||||
fetchLlmMentionsSearch,
|
||||
fetchLlmResponse,
|
||||
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 path =
|
||||
typeof url === "string" || url instanceof URL
|
||||
@ -132,8 +133,8 @@ describe("DataForSEO SDK-backed endpoints", () => {
|
||||
platform: "google",
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
itemsListLimit: 10,
|
||||
});
|
||||
|
||||
const expectedTarget = [
|
||||
{
|
||||
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 () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
Response.json({
|
||||
|
||||
@ -90,6 +90,27 @@ export const llmTopPagesItemSchema = z
|
||||
|
||||
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
|
||||
// All four model endpoints return the same envelope shape.
|
||||
|
||||
@ -22,9 +22,34 @@ export const aiSearchProjectSchema = z.object({
|
||||
/** Maximum allowed length for a free-text brand or domain search input. */
|
||||
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({
|
||||
projectId: z.string().min(1),
|
||||
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),
|
||||
languageCode: z.string().min(2).max(8).default("en"),
|
||||
});
|
||||
@ -36,18 +61,42 @@ const brandPlatformBreakdownSchema = z.object({
|
||||
status: z.enum(["success", "error"]),
|
||||
mentions: 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({
|
||||
url: z.string(),
|
||||
domain: z.string().nullable(),
|
||||
mentions: z.number().int().nonnegative().nullable(),
|
||||
url: z.string().max(2048),
|
||||
domain: z.string().max(253).nullable(),
|
||||
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({
|
||||
question: z.string(),
|
||||
question: z.string().max(500),
|
||||
platform: z.enum(["chat_gpt", "google"]),
|
||||
aiSearchVolume: z.number().int().nonnegative().nullable(),
|
||||
firstSeenAt: z.string().nullable(),
|
||||
@ -55,13 +104,13 @@ const brandTopQuerySchema = z.object({
|
||||
citedSources: z
|
||||
.array(
|
||||
z.object({
|
||||
url: z.string(),
|
||||
domain: z.string().nullable(),
|
||||
title: z.string().nullable(),
|
||||
url: z.string().max(2048),
|
||||
domain: z.string().max(253).nullable(),
|
||||
title: z.string().max(300).nullable(),
|
||||
}),
|
||||
)
|
||||
.max(10),
|
||||
brandsMentioned: z.array(z.string()).max(20),
|
||||
brandsMentioned: z.array(z.string().max(200)).max(20),
|
||||
});
|
||||
|
||||
const brandMonthlyVolumeSchema = z.object({
|
||||
@ -78,9 +127,13 @@ export const brandLookupResultSchema = z.object({
|
||||
hasData: z.boolean(),
|
||||
totalMentions: z.number().int().nonnegative().nullable(),
|
||||
totalAiSearchVolume: z.number().int().nonnegative().nullable(),
|
||||
totalImpressions: z.number().int().nonnegative().nullable(),
|
||||
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),
|
||||
monthlyVolume: z.array(brandMonthlyVolumeSchema),
|
||||
});
|
||||
@ -205,9 +258,23 @@ export type PromptExplorerResult = z.infer<typeof promptExplorerResultSchema>;
|
||||
// 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({
|
||||
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),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user