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.
|
- Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each.
|
||||||
- Exact cost can vary slightly based on returned rows and DataForSEO pricing.
|
- Exact cost can vary slightly based on returned rows and DataForSEO pricing.
|
||||||
|
|
||||||
|
### 6) AI Search — Brand Lookup
|
||||||
|
|
||||||
|
- One lookup = 6 DataForSEO AI Optimization calls (`aggregated_metrics` + `top_pages` + `mentions_search` across ChatGPT and Google AI Overview): up to about `$0.85` per lookup.
|
||||||
|
- `aggregated_metrics`: `$0.101` per platform.
|
||||||
|
- `top_pages`: page-ranked cited sources per platform.
|
||||||
|
- `mentions_search`: row-priced; `$0.20` per platform at the app's full 100-row sample (lower-volume brands return fewer rows and cost less).
|
||||||
|
- Adding competitors (Share of Voice) adds 2 `cross_aggregated_metrics` calls: about `$0.10` each, `$0.20` total.
|
||||||
|
- Results are cached for 24 hours, so repeating the same lookup (same target + competitor set) is free within a day.
|
||||||
|
- Re-measure anytime with `pnpm billing:brand-lookup --target=example.com --competitors=a.com,b.com --confirmLive=true`.
|
||||||
|
|
||||||
### Planning examples
|
### Planning examples
|
||||||
|
|
||||||
- 100 keyword research requests at the default 150 results: `$3.50`
|
- 100 keyword research requests at the default 150 results: `$3.50`
|
||||||
|
|||||||
@ -4,11 +4,14 @@ import {
|
|||||||
CHATGPT_LANGUAGE_CODE,
|
CHATGPT_LANGUAGE_CODE,
|
||||||
CHATGPT_LOCATION_CODE,
|
CHATGPT_LOCATION_CODE,
|
||||||
fetchLlmAggregatedMetrics,
|
fetchLlmAggregatedMetrics,
|
||||||
|
fetchLlmCrossAggregatedMetrics,
|
||||||
fetchLlmMentionsSearch,
|
fetchLlmMentionsSearch,
|
||||||
fetchLlmTopPages,
|
fetchLlmTopPages,
|
||||||
type LlmPlatform,
|
type LlmPlatform,
|
||||||
} from "@/server/lib/dataforseo/ai";
|
} from "@/server/lib/dataforseo/ai";
|
||||||
import { applyBillingMarkupUsd } from "@/shared/billing";
|
import { applyBillingMarkupUsd } from "@/shared/billing";
|
||||||
|
import { resolveCompetitorGroups } from "@/server/features/ai-search/services/shareOfVoice";
|
||||||
|
import { parseCompetitorList } from "@/types/schemas/ai-search";
|
||||||
import { loadLocalEnv, parseArgs } from "./cli-utils";
|
import { loadLocalEnv, parseArgs } from "./cli-utils";
|
||||||
|
|
||||||
loadLocalEnv();
|
loadLocalEnv();
|
||||||
@ -48,8 +51,22 @@ async function main() {
|
|||||||
const userLocationCode = parsePositiveInteger(args.locationCode, 2840);
|
const userLocationCode = parsePositiveInteger(args.locationCode, 2840);
|
||||||
const userLanguageCode = args.languageCode ?? "en";
|
const userLanguageCode = args.languageCode ?? "en";
|
||||||
const repeat = parsePositiveInteger(args.repeat, 1);
|
const repeat = parsePositiveInteger(args.repeat, 1);
|
||||||
|
// Optional comma-separated competitors — adds the Share of Voice
|
||||||
|
// cross_aggregated_metrics call per platform, mirroring the service.
|
||||||
|
const competitors = parseCompetitorList(args.competitors ?? "");
|
||||||
|
const competitorGroups = resolveCompetitorGroups(target, competitors);
|
||||||
|
|
||||||
const llmTarget = buildLlmTarget({ type: targetType, value: target });
|
const llmTarget = buildLlmTarget({ type: targetType, value: target });
|
||||||
|
const crossGroups = [
|
||||||
|
{ key: target, target: llmTarget },
|
||||||
|
...competitorGroups.map((competitor) => {
|
||||||
|
const detected = competitor.detected;
|
||||||
|
return {
|
||||||
|
key: competitor.label,
|
||||||
|
target: buildLlmTarget({ type: detected.type, value: detected.value }),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
];
|
||||||
const platforms: LlmPlatform[] = ["chat_gpt", "google"];
|
const platforms: LlmPlatform[] = ["chat_gpt", "google"];
|
||||||
|
|
||||||
const allRuns: RunSummary[] = [];
|
const allRuns: RunSummary[] = [];
|
||||||
@ -83,20 +100,42 @@ async function main() {
|
|||||||
});
|
});
|
||||||
calls.push(toRecord(platform, "top_pages", topPages.billing));
|
calls.push(toRecord(platform, "top_pages", topPages.billing));
|
||||||
|
|
||||||
|
// Prompt rows provide examples for the cited-source table.
|
||||||
const mentions = await fetchLlmMentionsSearch({
|
const mentions = await fetchLlmMentionsSearch({
|
||||||
target: llmTarget,
|
target: llmTarget,
|
||||||
platform,
|
platform,
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode,
|
languageCode,
|
||||||
limit: 25,
|
limit: 100,
|
||||||
});
|
});
|
||||||
calls.push(toRecord(platform, "mentions_search", mentions.billing));
|
calls.push(toRecord(platform, "mentions_search", mentions.billing));
|
||||||
|
|
||||||
|
if (competitorGroups.length > 0) {
|
||||||
|
const cross = await fetchLlmCrossAggregatedMetrics({
|
||||||
|
groups: crossGroups,
|
||||||
|
platform,
|
||||||
|
locationCode,
|
||||||
|
languageCode,
|
||||||
|
});
|
||||||
|
calls.push(
|
||||||
|
toRecord(platform, "cross_aggregated_metrics", cross.billing),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalRawUsd = sum(calls.map((c) => c.rawUsd));
|
const totalRawUsd = sum(calls.map((c) => c.rawUsd));
|
||||||
|
const crossRawUsd = sum(
|
||||||
|
calls
|
||||||
|
.filter((c) => c.endpoint === "cross_aggregated_metrics")
|
||||||
|
.map((c) => c.rawUsd),
|
||||||
|
);
|
||||||
allRuns.push({
|
allRuns.push({
|
||||||
run: runIndex + 1,
|
run: runIndex + 1,
|
||||||
calls,
|
calls,
|
||||||
|
// Split out so the base "Est. $X" and the "+$Y to compare competitors"
|
||||||
|
// UI constants can each be checked against reality.
|
||||||
|
baseRawUsd: round(totalRawUsd - crossRawUsd),
|
||||||
|
crossRawUsd: round(crossRawUsd),
|
||||||
totalRawUsd: round(totalRawUsd),
|
totalRawUsd: round(totalRawUsd),
|
||||||
totalBilledUsd: applyBillingMarkupUsd(totalRawUsd),
|
totalBilledUsd: applyBillingMarkupUsd(totalRawUsd),
|
||||||
});
|
});
|
||||||
@ -113,6 +152,7 @@ async function main() {
|
|||||||
targetType,
|
targetType,
|
||||||
userLocationCode,
|
userLocationCode,
|
||||||
userLanguageCode,
|
userLanguageCode,
|
||||||
|
competitors: competitorGroups.map((group) => group.label),
|
||||||
repeat,
|
repeat,
|
||||||
},
|
},
|
||||||
runs: allRuns,
|
runs: allRuns,
|
||||||
@ -140,6 +180,8 @@ type CallRecord = {
|
|||||||
type RunSummary = {
|
type RunSummary = {
|
||||||
run: number;
|
run: number;
|
||||||
calls: CallRecord[];
|
calls: CallRecord[];
|
||||||
|
baseRawUsd: number;
|
||||||
|
crossRawUsd: number;
|
||||||
totalRawUsd: number;
|
totalRawUsd: number;
|
||||||
totalBilledUsd: number;
|
totalBilledUsd: number;
|
||||||
};
|
};
|
||||||
@ -183,7 +225,7 @@ function round(value: number): number {
|
|||||||
function printUsageAndExit(message: string): never {
|
function printUsageAndExit(message: string): never {
|
||||||
console.error(message);
|
console.error(message);
|
||||||
console.error(
|
console.error(
|
||||||
"Usage: pnpm billing:brand-lookup --target=example.com --confirmLive=true [--targetType=domain|keyword] [--locationCode=2840] [--languageCode=en] [--repeat=1] [--allowCi=true]",
|
"Usage: pnpm billing:brand-lookup --target=example.com --confirmLive=true [--targetType=domain|keyword] [--competitors=a.com,b.com] [--locationCode=2840] [--languageCode=en] [--repeat=1] [--allowCi=true]",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,24 +23,29 @@ import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSear
|
|||||||
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
||||||
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
||||||
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
|
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
|
||||||
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
|
import {
|
||||||
|
BRAND_LOOKUP_MAX_INPUT_LENGTH,
|
||||||
|
parseCompetitorList,
|
||||||
|
} from "@/types/schemas/ai-search";
|
||||||
|
import { detectTarget } from "@/shared/targetDetection";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
initialQuery: string;
|
initialQuery: string;
|
||||||
onQueryChange: (next: string) => void;
|
initialCompetitors: string[];
|
||||||
|
onSearchChange: (nextQuery: string, nextCompetitors: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BRAND_LOOKUP_BULLETS = [
|
const BRAND_LOOKUP_BULLETS = [
|
||||||
{
|
{
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
title: "Track AI visibility",
|
title: "Track AI visibility",
|
||||||
body: "Count how often ChatGPT and Google AI Overview cite your brand, and watch the trend month over month.",
|
body: "See estimated counts for ChatGPT and Google AI Overview answers that cite your brand, and watch the trend month over month.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Quote,
|
icon: Quote,
|
||||||
title: "See the prompts",
|
title: "See the prompts",
|
||||||
body: "View the actual user questions where LLMs reference your domain — the real demand driving AI traffic.",
|
body: "View sample user questions where LLMs reference your brand or domain.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
@ -60,24 +65,38 @@ export function BrandLookupPage(props: Props) {
|
|||||||
function BrandLookupPageInner({
|
function BrandLookupPageInner({
|
||||||
projectId,
|
projectId,
|
||||||
initialQuery,
|
initialQuery,
|
||||||
onQueryChange,
|
initialCompetitors,
|
||||||
|
onSearchChange,
|
||||||
planGate,
|
planGate,
|
||||||
}: Props & { planGate: HostedPlanGateState }) {
|
}: Props & { planGate: HostedPlanGateState }) {
|
||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState(initialQuery);
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
// Raw comma-separated competitor text; parsed into a deduped array on submit.
|
||||||
|
const [competitorsInput, setCompetitorsInput] = useState(
|
||||||
|
initialCompetitors.join(", "),
|
||||||
|
);
|
||||||
|
// Field-tagged so the error styling lands on the input that caused it.
|
||||||
|
const [validationError, setValidationError] = useState<{
|
||||||
|
field: "query" | "competitors";
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const access = useAiSearchAccess(projectId);
|
const access = useAiSearchAccess(projectId);
|
||||||
|
|
||||||
const trimmedInitialQuery = initialQuery.trim();
|
const trimmedInitialQuery = initialQuery.trim();
|
||||||
const hasActiveQuery = trimmedInitialQuery.length > 0;
|
const hasActiveQuery = trimmedInitialQuery.length > 0;
|
||||||
|
// The URL `c` param is the source of truth for the active lookup; the local
|
||||||
|
// `competitorsInput` text only drives the input until the next submit. A
|
||||||
|
// stable string key, since `initialCompetitors` is a fresh array each render.
|
||||||
|
const competitorKey = initialCompetitors.join(",");
|
||||||
|
|
||||||
const lookupQuery = useQuery({
|
const lookupQuery = useQuery({
|
||||||
queryKey: ["brand-lookup", projectId, trimmedInitialQuery],
|
queryKey: ["brand-lookup", projectId, trimmedInitialQuery, competitorKey],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
lookupBrand({
|
lookupBrand({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
query: trimmedInitialQuery,
|
query: trimmedInitialQuery,
|
||||||
|
competitors: initialCompetitors,
|
||||||
locationCode: 2840,
|
locationCode: 2840,
|
||||||
languageCode: "en",
|
languageCode: "en",
|
||||||
},
|
},
|
||||||
@ -96,38 +115,83 @@ function BrandLookupPageInner({
|
|||||||
|
|
||||||
// Dedup ref prevents repeat adds — `addSearch` identity is not stable
|
// Dedup ref prevents repeat adds — `addSearch` identity is not stable
|
||||||
// across renders, so we'd otherwise re-write the same item every render.
|
// across renders, so we'd otherwise re-write the same item every render.
|
||||||
const lastAddedQueryRef = useRef<string | null>(null);
|
// Key on query + competitors so changing competitors records a fresh entry.
|
||||||
|
const lastAddedKeyRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasActiveQuery || !lookupQuery.isSuccess) return;
|
if (!hasActiveQuery || !lookupQuery.isSuccess) return;
|
||||||
if (lastAddedQueryRef.current === trimmedInitialQuery) return;
|
const addedKey = `${trimmedInitialQuery}::${competitorKey}`;
|
||||||
lastAddedQueryRef.current = trimmedInitialQuery;
|
if (lastAddedKeyRef.current === addedKey) return;
|
||||||
addSearch({ query: trimmedInitialQuery });
|
lastAddedKeyRef.current = addedKey;
|
||||||
}, [hasActiveQuery, lookupQuery.isSuccess, trimmedInitialQuery, addSearch]);
|
addSearch({
|
||||||
|
query: trimmedInitialQuery,
|
||||||
|
competitors: competitorKey ? competitorKey.split(",") : [],
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
hasActiveQuery,
|
||||||
|
lookupQuery.isSuccess,
|
||||||
|
trimmedInitialQuery,
|
||||||
|
competitorKey,
|
||||||
|
addSearch,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSubmit = (event: FormEvent) => {
|
const handleSubmit = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
setValidationError("Enter a brand name or domain");
|
setValidationError({
|
||||||
|
field: "query",
|
||||||
|
message: "Enter a brand name or domain",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trimmed.length > BRAND_LOOKUP_MAX_INPUT_LENGTH) {
|
if (trimmed.length > BRAND_LOOKUP_MAX_INPUT_LENGTH) {
|
||||||
setValidationError(
|
setValidationError({
|
||||||
`Keep it under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`,
|
field: "query",
|
||||||
|
message: `Keep it under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const competitors = parseCompetitorList(competitorsInput);
|
||||||
|
// Mirror the server's input schema (per-item max) and its competitor
|
||||||
|
// resolution (a competitor that resolves to the target is dropped) so the
|
||||||
|
// user gets an inline message instead of a generic server error or a
|
||||||
|
// silently missing Share of Voice section.
|
||||||
|
const tooLong = competitors.find(
|
||||||
|
(competitor) => competitor.length > BRAND_LOOKUP_MAX_INPUT_LENGTH,
|
||||||
);
|
);
|
||||||
|
if (tooLong) {
|
||||||
|
setValidationError({
|
||||||
|
field: "competitors",
|
||||||
|
message: `Keep each competitor under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetValue = detectTarget(trimmed).value.toLowerCase();
|
||||||
|
const matchesTarget = competitors.find(
|
||||||
|
(competitor) =>
|
||||||
|
detectTarget(competitor).value.toLowerCase() === targetValue,
|
||||||
|
);
|
||||||
|
if (matchesTarget) {
|
||||||
|
setValidationError({
|
||||||
|
field: "competitors",
|
||||||
|
message: `"${matchesTarget}" matches the brand you're looking up — remove it from competitors`,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
onQueryChange(trimmed);
|
onSearchChange(trimmed, competitors);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The query input is reset whenever the URL `q` changes — including the
|
// The form inputs are reset whenever the URL `q`/`c` changes — including the
|
||||||
// browser-back path and Cmd+click navigation. This keeps local form state
|
// browser-back path and Cmd+click navigation. This keeps local form state in
|
||||||
// in sync with the URL source-of-truth.
|
// sync with the URL source-of-truth. Depend on the stable `competitorKey`
|
||||||
|
// string (not the fresh-each-render `initialCompetitors` array) so typing in
|
||||||
|
// the competitor field isn't clobbered on every render.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuery(initialQuery);
|
setQuery(initialQuery);
|
||||||
|
setCompetitorsInput(competitorKey.split(",").join(", "));
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
}, [initialQuery]);
|
}, [initialQuery, competitorKey]);
|
||||||
|
|
||||||
const isLoading = hasActiveQuery && lookupQuery.isPending;
|
const isLoading = hasActiveQuery && lookupQuery.isPending;
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@ -159,7 +223,7 @@ function BrandLookupPageInner({
|
|||||||
) : planGate.isFreePlan ? (
|
) : planGate.isFreePlan ? (
|
||||||
<AiSearchPaidPlanGate
|
<AiSearchPaidPlanGate
|
||||||
feature="Brand Lookup"
|
feature="Brand Lookup"
|
||||||
description="See how ChatGPT and Google AI Overview cite any brand or domain — total mentions, the prompts driving them, and the pages cited alongside yours."
|
description="See how ChatGPT and Google AI Overview cite any brand or domain — total mentions, sample prompts where it appears, and the pages cited alongside it."
|
||||||
bullets={BRAND_LOOKUP_BULLETS}
|
bullets={BRAND_LOOKUP_BULLETS}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -170,6 +234,11 @@ function BrandLookupPageInner({
|
|||||||
setQuery(next);
|
setQuery(next);
|
||||||
if (validationError) setValidationError(null);
|
if (validationError) setValidationError(null);
|
||||||
}}
|
}}
|
||||||
|
competitors={competitorsInput}
|
||||||
|
onCompetitorsChange={(next) => {
|
||||||
|
setCompetitorsInput(next);
|
||||||
|
if (validationError) setValidationError(null);
|
||||||
|
}}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
validationError={validationError}
|
validationError={validationError}
|
||||||
@ -194,7 +263,7 @@ function BrandLookupPageInner({
|
|||||||
from="/p/$projectId/brand-lookup"
|
from="/p/$projectId/brand-lookup"
|
||||||
to="/p/$projectId/brand-lookup"
|
to="/p/$projectId/brand-lookup"
|
||||||
params={{ projectId }}
|
params={{ projectId }}
|
||||||
search={{ q: undefined }}
|
search={{ q: undefined, c: undefined }}
|
||||||
replace
|
replace
|
||||||
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
||||||
>
|
>
|
||||||
@ -202,7 +271,7 @@ function BrandLookupPageInner({
|
|||||||
Recent searches
|
Recent searches
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<BrandLookupResults result={resultData} />
|
<BrandLookupResults result={resultData} projectId={projectId} />
|
||||||
</>
|
</>
|
||||||
) : !errorMessage ? (
|
) : !errorMessage ? (
|
||||||
<BrandLookupHistorySection
|
<BrandLookupHistorySection
|
||||||
|
|||||||
@ -44,7 +44,11 @@ export function filterTopPages(
|
|||||||
const excludeTerms = parseTerms(filters.exclude);
|
const excludeTerms = parseTerms(filters.exclude);
|
||||||
|
|
||||||
return rows.filter((row) => {
|
return rows.filter((row) => {
|
||||||
const textFields = [row.url, row.domain]
|
const textFields = [
|
||||||
|
row.url,
|
||||||
|
row.domain,
|
||||||
|
...row.keywords.map((keyword) => keyword.question),
|
||||||
|
]
|
||||||
.filter((v): v is string => Boolean(v))
|
.filter((v): v is string => Boolean(v))
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { createColumnHelper, type Table } from "@tanstack/react-table";
|
import { createColumnHelper, type Table } from "@tanstack/react-table";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { ExternalLink, Sparkles } from "lucide-react";
|
||||||
import { AppDataTable } from "@/client/components/table/AppDataTable";
|
import { AppDataTable } from "@/client/components/table/AppDataTable";
|
||||||
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||||
|
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||||
import { numericNullsLast } from "@/client/components/table/nullSafeSort";
|
import { numericNullsLast } from "@/client/components/table/nullSafeSort";
|
||||||
import {
|
import {
|
||||||
formatCount,
|
formatCount,
|
||||||
formatPlatformLabel,
|
PLATFORM_DOT_CLASS,
|
||||||
|
PLATFORM_SHORT_LABEL,
|
||||||
} from "@/client/features/ai-search/platformLabels";
|
} from "@/client/features/ai-search/platformLabels";
|
||||||
import { formatUrlForDisplay } from "@/client/components/table/url";
|
import { formatUrlForDisplay } from "@/client/components/table/url";
|
||||||
import type { BrandLookupResult } from "@/types/schemas/ai-search";
|
import type { BrandLookupResult } from "@/types/schemas/ai-search";
|
||||||
@ -14,56 +18,230 @@ type TopPageRow = BrandLookupResult["topPages"][number];
|
|||||||
type TopQueryRow = BrandLookupResult["topQueries"][number];
|
type TopQueryRow = BrandLookupResult["topQueries"][number];
|
||||||
type PlatformKey = TopPageRow["platform"];
|
type PlatformKey = TopPageRow["platform"];
|
||||||
|
|
||||||
const PLATFORM_BADGE_CLASS: Record<PlatformKey, string> = {
|
/** Uppercase column header with a hover/focus popover explaining the column. */
|
||||||
chat_gpt: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500",
|
function HeaderWithHelp({
|
||||||
google: "border-sky-500/40 bg-sky-500/10 text-sky-500",
|
label,
|
||||||
};
|
helpText,
|
||||||
|
}: {
|
||||||
function PlatformBadge({ platform }: { platform: PlatformKey }) {
|
label: string;
|
||||||
|
helpText: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<span className={`badge badge-sm border ${PLATFORM_BADGE_CLASS[platform]}`}>
|
<span className="uppercase tracking-wider">
|
||||||
{formatPlatformLabel(platform)}
|
<HeaderHelpLabel label={label} helpText={helpText} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLATFORM_HELP =
|
||||||
|
"Which AI surface produced the answer — ChatGPT or Google AI Overview.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform indicator used only when a table actually spans >1 platform. A dot +
|
||||||
|
* short label replaces the old full-width pill that repeated identically on
|
||||||
|
* every row.
|
||||||
|
*/
|
||||||
|
function PlatformCell({ platform }: { platform: PlatformKey }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-base-content/70">
|
||||||
|
<span
|
||||||
|
className={`size-1.5 rounded-full ${PLATFORM_DOT_CLASS[platform]}`}
|
||||||
|
/>
|
||||||
|
{PLATFORM_SHORT_LABEL[platform]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlPath(rawUrl: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
const path = `${url.pathname}${url.search}`;
|
||||||
|
return path === "/" ? "" : path;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomain(value: string): string {
|
||||||
|
return value.replace(/^www\./i, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lookup targets a domain with include_subdomains, so the target's own
|
||||||
|
* pages can surface under any subdomain (docs.acme.com for acme.com) — those
|
||||||
|
* must get the "You" badge too.
|
||||||
|
*/
|
||||||
|
function isTargetDomain(domain: string, targetDomain: string): boolean {
|
||||||
|
const candidate = normalizeDomain(domain);
|
||||||
|
const target = normalizeDomain(targetDomain);
|
||||||
|
return candidate === target || candidate.endsWith(`.${target}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Domain-led cited page: bold domain + truncated path, links out. */
|
||||||
|
function PageUrlCell({
|
||||||
|
row,
|
||||||
|
targetDomain,
|
||||||
|
}: {
|
||||||
|
row: TopPageRow;
|
||||||
|
targetDomain: string | null;
|
||||||
|
}) {
|
||||||
|
const path = urlPath(row.url);
|
||||||
|
const isOwn =
|
||||||
|
targetDomain != null &&
|
||||||
|
row.domain != null &&
|
||||||
|
isTargetDomain(row.domain, targetDomain);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={row.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="group block max-w-xl"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="font-medium text-base-content group-hover:underline">
|
||||||
|
{row.domain ?? formatUrlForDisplay(row.url)}
|
||||||
|
</span>
|
||||||
|
{isOwn ? (
|
||||||
|
<span className="badge badge-primary badge-xs border-0">You</span>
|
||||||
|
) : null}
|
||||||
|
<ExternalLink className="size-3 shrink-0 text-base-content/40" />
|
||||||
|
</span>
|
||||||
|
{path ? (
|
||||||
|
<span className="block truncate text-xs text-base-content/50">
|
||||||
|
{path}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prompts (keywords) whose answers cited this page. Shows the top 3 inline;
|
||||||
|
* if there are more, a "+N more" toggle reveals the rest. Each prompt links into
|
||||||
|
* Prompt Explorer prefilled with it.
|
||||||
|
*/
|
||||||
|
function KeywordsCell({
|
||||||
|
keywords,
|
||||||
|
projectId,
|
||||||
|
brand,
|
||||||
|
}: {
|
||||||
|
keywords: TopPageRow["keywords"];
|
||||||
|
projectId: string;
|
||||||
|
brand: string;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
if (keywords.length === 0) {
|
||||||
|
return <span className="text-base-content/40">—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = expanded ? keywords : keywords.slice(0, 3);
|
||||||
|
const remaining = keywords.length - visible.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{visible.map((keyword) => (
|
||||||
|
<li key={keyword.question}>
|
||||||
|
<Link
|
||||||
|
to="/p/$projectId/prompt-explorer"
|
||||||
|
params={{ projectId }}
|
||||||
|
search={{ q: keyword.question, hb: brand || undefined }}
|
||||||
|
className="group/kw inline-flex items-baseline gap-2 text-xs"
|
||||||
|
title="Run this prompt in Prompt Explorer"
|
||||||
|
>
|
||||||
|
<span className="text-base-content/80 group-hover/kw:underline">
|
||||||
|
{keyword.question}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="shrink-0 tabular-nums text-base-content/40"
|
||||||
|
title="Prompt volume in the fetched sample"
|
||||||
|
>
|
||||||
|
{formatCount(keyword.aiSearchVolume)} vol.
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{keywords.length > 3 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((current) => !current)}
|
||||||
|
className="text-xs text-base-content/50 hover:text-base-content"
|
||||||
|
>
|
||||||
|
{expanded ? "Show less" : `+${remaining} more`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const pagesHelper = createColumnHelper<TopPageRow>();
|
const pagesHelper = createColumnHelper<TopPageRow>();
|
||||||
const queriesHelper = createColumnHelper<TopQueryRow>();
|
const queriesHelper = createColumnHelper<TopQueryRow>();
|
||||||
|
|
||||||
export const topPagesColumns = [
|
export function buildTopPagesColumns({
|
||||||
|
showPlatform,
|
||||||
|
targetDomain,
|
||||||
|
projectId,
|
||||||
|
brand,
|
||||||
|
}: {
|
||||||
|
showPlatform: boolean;
|
||||||
|
targetDomain: string | null;
|
||||||
|
projectId: string;
|
||||||
|
brand: string;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
pagesHelper.accessor("url", {
|
pagesHelper.accessor("url", {
|
||||||
id: "url",
|
id: "url",
|
||||||
header: () => <span className="uppercase tracking-wider">URL</span>,
|
header: () => (
|
||||||
|
<HeaderWithHelp
|
||||||
|
label="Source"
|
||||||
|
helpText="A page cited as a source in AI answers where the searched brand or domain appears."
|
||||||
|
/>
|
||||||
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<>
|
<PageUrlCell row={row.original} targetDomain={targetDomain} />
|
||||||
<a
|
|
||||||
href={row.original.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="link link-primary inline-flex items-start gap-1 break-all"
|
|
||||||
>
|
|
||||||
<span className="break-all">
|
|
||||||
{formatUrlForDisplay(row.original.url)}
|
|
||||||
</span>
|
|
||||||
<ExternalLink className="mt-1 size-3 shrink-0" />
|
|
||||||
</a>
|
|
||||||
{row.original.domain ? (
|
|
||||||
<p className="text-xs text-base-content/50">{row.original.domain}</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
...(showPlatform
|
||||||
|
? [
|
||||||
pagesHelper.accessor("platform", {
|
pagesHelper.accessor("platform", {
|
||||||
id: "platform",
|
id: "platform",
|
||||||
header: () => <span className="uppercase tracking-wider">Platform</span>,
|
header: () => (
|
||||||
|
<HeaderWithHelp label="Platform" helpText={PLATFORM_HELP} />
|
||||||
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ getValue }) => <PlatformBadge platform={getValue()} />,
|
cell: ({ getValue }) => <PlatformCell platform={getValue()} />,
|
||||||
}),
|
}),
|
||||||
pagesHelper.accessor("mentions", {
|
]
|
||||||
id: "mentions",
|
: []),
|
||||||
|
pagesHelper.display({
|
||||||
|
id: "keywords",
|
||||||
|
header: () => (
|
||||||
|
<HeaderWithHelp
|
||||||
|
label="Cited for"
|
||||||
|
helpText="Example prompts from the fetched sample where this page was cited."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<KeywordsCell
|
||||||
|
keywords={row.original.keywords}
|
||||||
|
projectId={projectId}
|
||||||
|
brand={brand}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
pagesHelper.accessor("capturedVolume", {
|
||||||
|
id: "capturedVolume",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader column={column} label="Mentions" align="right" />
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Source vol."
|
||||||
|
helpText="Estimated monthly prompt demand DataForSEO reports for this cited source, across prompts where the searched brand or domain appears."
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<span className="tabular-nums">{formatCount(getValue())}</span>
|
<span className="tabular-nums">{formatCount(getValue())}</span>
|
||||||
@ -71,12 +249,27 @@ export const topPagesColumns = [
|
|||||||
sortingFn: numericNullsLast,
|
sortingFn: numericNullsLast,
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const topQueriesColumns = [
|
export function buildTopQueriesColumns({
|
||||||
|
showPlatform,
|
||||||
|
projectId,
|
||||||
|
brand,
|
||||||
|
}: {
|
||||||
|
showPlatform: boolean;
|
||||||
|
projectId: string;
|
||||||
|
brand: string;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
queriesHelper.accessor("question", {
|
queriesHelper.accessor("question", {
|
||||||
id: "question",
|
id: "question",
|
||||||
header: () => <span className="uppercase tracking-wider">Query</span>,
|
header: () => (
|
||||||
|
<HeaderWithHelp
|
||||||
|
label="Query"
|
||||||
|
helpText="A sampled user prompt whose AI answer cited the searched brand or domain in its text or sources. The prompt itself may not name the brand."
|
||||||
|
/>
|
||||||
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<>
|
<>
|
||||||
@ -89,16 +282,27 @@ export const topQueriesColumns = [
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
...(showPlatform
|
||||||
|
? [
|
||||||
queriesHelper.accessor("platform", {
|
queriesHelper.accessor("platform", {
|
||||||
id: "platform",
|
id: "platform",
|
||||||
header: () => <span className="uppercase tracking-wider">Platform</span>,
|
header: () => (
|
||||||
|
<HeaderWithHelp label="Platform" helpText={PLATFORM_HELP} />
|
||||||
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ getValue }) => <PlatformBadge platform={getValue()} />,
|
cell: ({ getValue }) => <PlatformCell platform={getValue()} />,
|
||||||
}),
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
queriesHelper.accessor("aiSearchVolume", {
|
queriesHelper.accessor("aiSearchVolume", {
|
||||||
id: "aiSearchVolume",
|
id: "aiSearchVolume",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader column={column} label="AI search vol." align="right" />
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="AI search vol."
|
||||||
|
helpText="Estimated monthly search demand for this prompt's topic. This is prompt demand, not the number of brand mentions."
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<span className="tabular-nums">{formatCount(getValue())}</span>
|
<span className="tabular-nums">{formatCount(getValue())}</span>
|
||||||
@ -106,13 +310,35 @@ export const topQueriesColumns = [
|
|||||||
sortingFn: numericNullsLast,
|
sortingFn: numericNullsLast,
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
];
|
queriesHelper.display({
|
||||||
|
id: "action",
|
||||||
|
header: () => <span className="sr-only">Actions</span>,
|
||||||
|
meta: { cellClassName: "w-px whitespace-nowrap text-right align-top" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className="tooltip tooltip-left opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
||||||
|
data-tip="Run this prompt in Prompt Explorer"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/p/$projectId/prompt-explorer"
|
||||||
|
params={{ projectId }}
|
||||||
|
search={{ q: row.original.question, hb: brand || undefined }}
|
||||||
|
className="btn btn-ghost btn-xs gap-1"
|
||||||
|
aria-label="Run this prompt in Prompt Explorer"
|
||||||
|
>
|
||||||
|
<Sparkles className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function TopPagesTable({ table }: { table: Table<TopPageRow> }) {
|
export function TopPagesTable({ table }: { table: Table<TopPageRow> }) {
|
||||||
if (table.getRowModel().rows.length === 0) {
|
if (table.getRowModel().rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="p-6 text-center text-sm text-base-content/60">
|
<p className="p-6 text-center text-sm text-base-content/60">
|
||||||
No cited pages returned.
|
No cited sources to show.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -142,6 +368,7 @@ function BrandLookupTable<T>({
|
|||||||
return (
|
return (
|
||||||
<AppDataTable
|
<AppDataTable
|
||||||
table={table}
|
table={table}
|
||||||
|
getRowClassName={() => "group transition-colors hover:bg-base-200/40"}
|
||||||
getCellClassName={(_, columnId) =>
|
getCellClassName={(_, columnId) =>
|
||||||
cellClassName(
|
cellClassName(
|
||||||
columnId,
|
columnId,
|
||||||
@ -161,6 +388,9 @@ function cellClassName(
|
|||||||
if (columnId === urlLikeColumnId) {
|
if (columnId === urlLikeColumnId) {
|
||||||
return "min-w-80 max-w-2xl align-top";
|
return "min-w-80 max-w-2xl align-top";
|
||||||
}
|
}
|
||||||
|
if (columnId === "keywords") {
|
||||||
|
return "max-w-lg align-top";
|
||||||
|
}
|
||||||
if (isNumeric) {
|
if (isNumeric) {
|
||||||
return "whitespace-nowrap text-right align-top";
|
return "whitespace-nowrap text-right align-top";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]">
|
<div className="min-w-[220px]">
|
||||||
<FilterRangeInputs
|
<FilterRangeInputs
|
||||||
form={form}
|
form={form}
|
||||||
title="Mentions"
|
title="Source mentions"
|
||||||
minName="minMentions"
|
minName="minMentions"
|
||||||
maxName="maxMentions"
|
maxName="maxMentions"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -25,7 +25,13 @@ export function BrandLookupHistorySection({ projectId, ...props }: Props) {
|
|||||||
from="/p/$projectId/brand-lookup"
|
from="/p/$projectId/brand-lookup"
|
||||||
to="/p/$projectId/brand-lookup"
|
to="/p/$projectId/brand-lookup"
|
||||||
params={{ projectId }}
|
params={{ projectId }}
|
||||||
search={{ q: item.query }}
|
search={{
|
||||||
|
q: item.query,
|
||||||
|
c:
|
||||||
|
item.competitors.length > 0
|
||||||
|
? item.competitors.join(",")
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
replace
|
replace
|
||||||
className={HISTORY_ITEM_LINK_CLASS}
|
className={HISTORY_ITEM_LINK_CLASS}
|
||||||
>
|
>
|
||||||
@ -33,7 +39,14 @@ export function BrandLookupHistorySection({ projectId, ...props }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<p className="font-medium text-base-content truncate">{item.query}</p>
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium text-base-content">{item.query}</p>
|
||||||
|
{item.competitors.length > 0 ? (
|
||||||
|
<p className="truncate text-xs text-base-content/50">
|
||||||
|
vs {item.competitors.join(", ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,45 +1,23 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { Info } from "lucide-react";
|
||||||
import { type SortingState } from "@tanstack/react-table";
|
|
||||||
import { Download, Info, SlidersHorizontal } from "lucide-react";
|
|
||||||
import { useAppTable } from "@/client/components/table/AppDataTable";
|
|
||||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
|
||||||
import {
|
|
||||||
buildBrandLookupExport,
|
|
||||||
downloadBrandLookupCsv,
|
|
||||||
} from "@/client/features/ai-search/components/brandLookupExport";
|
|
||||||
import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard";
|
import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard";
|
||||||
import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel";
|
import { BrandLookupShareOfVoice } from "@/client/features/ai-search/components/BrandLookupShareOfVoice";
|
||||||
import {
|
import { CitationTabsCard } from "@/client/features/ai-search/components/BrandLookupCitationsCard";
|
||||||
TopPagesTable,
|
|
||||||
TopQueriesTable,
|
|
||||||
topPagesColumns,
|
|
||||||
topQueriesColumns,
|
|
||||||
} from "@/client/features/ai-search/components/BrandLookupCitationTables";
|
|
||||||
import {
|
import {
|
||||||
formatCount,
|
formatCount,
|
||||||
formatPlatformLabel,
|
formatPlatformLabel,
|
||||||
|
PLATFORM_DOT_CLASS,
|
||||||
} from "@/client/features/ai-search/platformLabels";
|
} from "@/client/features/ai-search/platformLabels";
|
||||||
import {
|
|
||||||
filterQueries,
|
|
||||||
filterTopPages,
|
|
||||||
} from "@/client/features/ai-search/brandLookupFiltering";
|
|
||||||
import { useBrandLookupFilters } from "@/client/features/ai-search/useBrandLookupFilters";
|
|
||||||
import type { CitationTab } from "@/client/features/ai-search/brandLookupFilterTypes";
|
|
||||||
import type { BrandLookupResult } from "@/types/schemas/ai-search";
|
import type { BrandLookupResult } from "@/types/schemas/ai-search";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
result: BrandLookupResult;
|
result: BrandLookupResult;
|
||||||
|
projectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PlatformRow = BrandLookupResult["perPlatform"][number];
|
type PlatformRow = BrandLookupResult["perPlatform"][number];
|
||||||
type MetricKey = "mentions" | "aiSearchVolume" | "impressions";
|
type MetricKey = "mentions" | "aiSearchVolume";
|
||||||
|
|
||||||
const PLATFORM_DOT_CLASS: Record<PlatformRow["platform"], string> = {
|
export function BrandLookupResults({ result, projectId }: Props) {
|
||||||
chat_gpt: "bg-emerald-500",
|
|
||||||
google: "bg-sky-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BrandLookupResults({ result }: Props) {
|
|
||||||
if (!result.hasData) {
|
if (!result.hasData) {
|
||||||
const erroredPlatforms = result.perPlatform.filter(
|
const erroredPlatforms = result.perPlatform.filter(
|
||||||
(p) => p.status === "error",
|
(p) => p.status === "error",
|
||||||
@ -76,17 +54,27 @@ export function BrandLookupResults({ result }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasTrendData = result.monthlyVolume.length > 0;
|
const hasTrendData = result.monthlyVolume.length > 0;
|
||||||
|
const sov = result.shareOfVoice;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<BrandHeader result={result} />
|
<BrandHeader result={result} />
|
||||||
|
|
||||||
|
{/* One shared grid so the cards align by construction: stats left, trend
|
||||||
|
right, Share of Voice flowing into the next free half-width cell —
|
||||||
|
whichever of trend/SoV is absent, the rest stay column-aligned. A
|
||||||
|
lone stats card keeps full width instead of half a grid. */}
|
||||||
<div
|
<div
|
||||||
className={`grid gap-4 ${hasTrendData ? "lg:grid-cols-2" : "grid-cols-1"}`}
|
className={
|
||||||
|
hasTrendData || sov ? "grid gap-4 lg:grid-cols-2" : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<KpiTiles result={result} />
|
<StatsCard result={result} />
|
||||||
{hasTrendData ? <MentionTrendCard result={result} /> : null}
|
{hasTrendData ? <MentionTrendCard result={result} /> : null}
|
||||||
|
{sov ? <BrandLookupShareOfVoice shareOfVoice={sov} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<CitationTabsCard result={result} />
|
|
||||||
|
<CitationTabsCard result={result} projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -109,64 +97,54 @@ function BrandHeader({ result }: { result: BrandLookupResult }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiTiles({ result }: { result: BrandLookupResult }) {
|
function StatsCard({ result }: { result: BrandLookupResult }) {
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col divide-y divide-base-200 rounded-xl border border-base-300 bg-base-100">
|
<section className="rounded-xl border border-base-300 bg-base-100">
|
||||||
<KpiTile
|
<div className="flex h-full flex-col divide-y divide-base-200">
|
||||||
label="Total mentions"
|
<StatBlock
|
||||||
tooltip="Number of LLM answers where your domain appeared in the text or citations."
|
label="Mentions"
|
||||||
total={result.totalMentions}
|
tooltip="Estimated count of AI answers where the searched brand or domain appeared in the answer text or cited sources."
|
||||||
|
value={result.totalMentions}
|
||||||
perPlatform={result.perPlatform}
|
perPlatform={result.perPlatform}
|
||||||
metric="mentions"
|
metric="mentions"
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<StatBlock
|
||||||
label="AI search volume"
|
label="AI search volume"
|
||||||
tooltip="Monthly volume of user prompts on topics where your domain shows up in LLM answers."
|
tooltip="Estimated monthly search demand for prompts where the searched brand or domain appears in AI answers. This is prompt demand, not mention count."
|
||||||
total={result.totalAiSearchVolume}
|
value={result.totalAiSearchVolume}
|
||||||
perPlatform={result.perPlatform}
|
perPlatform={result.perPlatform}
|
||||||
metric="aiSearchVolume"
|
metric="aiSearchVolume"
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
</div>
|
||||||
label="Estimated impressions"
|
|
||||||
tooltip="How often your domain is shown to users across LLM answers, based on mention frequency and topic search volume."
|
|
||||||
total={result.totalImpressions}
|
|
||||||
perPlatform={result.perPlatform}
|
|
||||||
metric="impressions"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiTile({
|
function StatBlock({
|
||||||
label,
|
label,
|
||||||
tooltip,
|
tooltip,
|
||||||
total,
|
value,
|
||||||
perPlatform,
|
perPlatform,
|
||||||
metric,
|
metric,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
total: number | null;
|
value: number | null;
|
||||||
perPlatform: PlatformRow[];
|
perPlatform: PlatformRow[];
|
||||||
metric: MetricKey;
|
metric: MetricKey;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-between gap-6 px-5 py-3">
|
<div className="flex flex-1 flex-col justify-center p-4">
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-base-content/50">
|
<p className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-base-content/50">
|
||||||
{label}
|
{label}
|
||||||
<span
|
<span className="tooltip inline-flex normal-case" data-tip={tooltip}>
|
||||||
className="tooltip tooltip-right inline-flex normal-case"
|
|
||||||
data-tip={tooltip}
|
|
||||||
>
|
|
||||||
<Info className="size-3 text-base-content/40" />
|
<Info className="size-3 text-base-content/40" />
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-2xl font-semibold tabular-nums">
|
<p className="mt-1 text-3xl font-semibold tabular-nums">
|
||||||
{formatCount(total)}
|
{formatCount(value)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<div className="mt-3 space-y-1 border-t border-base-200 pt-2.5">
|
||||||
<div className="flex shrink-0 flex-col gap-1.5 min-w-[12rem]">
|
|
||||||
{perPlatform.map((row) => (
|
{perPlatform.map((row) => (
|
||||||
<PlatformStatRow key={row.platform} row={row} metric={metric} />
|
<PlatformStatRow key={row.platform} row={row} metric={metric} />
|
||||||
))}
|
))}
|
||||||
@ -193,7 +171,7 @@ function PlatformStatRow({
|
|||||||
{formatPlatformLabel(row.platform)}
|
{formatPlatformLabel(row.platform)}
|
||||||
{row.platform === "chat_gpt" ? (
|
{row.platform === "chat_gpt" ? (
|
||||||
<span
|
<span
|
||||||
className="tooltip tooltip-right z-20 inline-flex"
|
className="tooltip z-20 inline-flex"
|
||||||
data-tip="DataForSEO indexes ChatGPT mentions for US English only — country selection is not available for this platform."
|
data-tip="DataForSEO indexes ChatGPT mentions for US English only — country selection is not available for this platform."
|
||||||
>
|
>
|
||||||
<Info className="size-3 text-base-content/40" />
|
<Info className="size-3 text-base-content/40" />
|
||||||
@ -225,156 +203,6 @@ function MentionTrendCard({ result }: { result: BrandLookupResult }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PAGES_SORT: SortingState = [{ id: "mentions", desc: true }];
|
|
||||||
const DEFAULT_QUERIES_SORT: SortingState = [
|
|
||||||
{ id: "aiSearchVolume", desc: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
function CitationTabsCard({ result }: { result: BrandLookupResult }) {
|
|
||||||
const [activeTab, setActiveTab] = useState<CitationTab>("queries");
|
|
||||||
const [pagesSort, setPagesSort] = useState<SortingState>(DEFAULT_PAGES_SORT);
|
|
||||||
const [queriesSort, setQueriesSort] =
|
|
||||||
useState<SortingState>(DEFAULT_QUERIES_SORT);
|
|
||||||
const filters = useBrandLookupFilters();
|
|
||||||
|
|
||||||
const filteredPages = useMemo(
|
|
||||||
() => filterTopPages(result.topPages, filters.pages.values),
|
|
||||||
[result.topPages, filters.pages.values],
|
|
||||||
);
|
|
||||||
const filteredQueries = useMemo(
|
|
||||||
() => filterQueries(result.topQueries, filters.queries.values),
|
|
||||||
[result.topQueries, filters.queries.values],
|
|
||||||
);
|
|
||||||
|
|
||||||
const pagesTable = useAppTable({
|
|
||||||
data: filteredPages,
|
|
||||||
columns: topPagesColumns,
|
|
||||||
state: { sorting: pagesSort },
|
|
||||||
onSortingChange: setPagesSort,
|
|
||||||
withSorting: true,
|
|
||||||
});
|
|
||||||
const queriesTable = useAppTable({
|
|
||||||
data: filteredQueries,
|
|
||||||
columns: topQueriesColumns,
|
|
||||||
state: { sorting: queriesSort },
|
|
||||||
onSortingChange: setQueriesSort,
|
|
||||||
withSorting: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Not memoized: TanStack's `getSortedRowModel()` is internally cached, and
|
|
||||||
// memoing on the table refs alone (which are stable across renders) would
|
|
||||||
// serve stale data when sort or filters change.
|
|
||||||
const exportTable = buildBrandLookupExport(
|
|
||||||
activeTab,
|
|
||||||
pagesTable.getSortedRowModel().rows.map((row) => row.original),
|
|
||||||
queriesTable.getSortedRowModel().rows.map((row) => row.original),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleExport = () =>
|
|
||||||
downloadBrandLookupCsv(activeTab, result.resolvedTarget, exportTable);
|
|
||||||
|
|
||||||
const canExport = exportTable.rows.length > 0;
|
|
||||||
|
|
||||||
const currentFilterCount = filters[activeTab].activeFilterCount;
|
|
||||||
const queriesActive = activeTab === "queries";
|
|
||||||
const pagesActive = activeTab === "pages";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
|
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3">
|
|
||||||
<div role="tablist" className="tabs tabs-box w-fit">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={queriesActive}
|
|
||||||
className={`tab ${queriesActive ? "tab-active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("queries")}
|
|
||||||
>
|
|
||||||
Queries
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={pagesActive}
|
|
||||||
className={`tab ${pagesActive ? "tab-active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("pages")}
|
|
||||||
>
|
|
||||||
Related pages
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ExportToSheetsButton
|
|
||||||
headers={exportTable.headers}
|
|
||||||
rows={exportTable.rows}
|
|
||||||
feature={`brand_lookup_${activeTab}`}
|
|
||||||
className="btn-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm gap-1.5"
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={!canExport}
|
|
||||||
aria-label="Export current tab as CSV"
|
|
||||||
>
|
|
||||||
<Download className="size-3.5" />
|
|
||||||
Export CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 border-b border-base-300 px-4 py-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-ghost btn-sm gap-1.5 ${filters.showFilters ? "btn-active" : ""}`}
|
|
||||||
onClick={() => filters.setShowFilters((current) => !current)}
|
|
||||||
title="Toggle table filters"
|
|
||||||
>
|
|
||||||
<SlidersHorizontal className="size-3.5" />
|
|
||||||
Filters
|
|
||||||
{currentFilterCount > 0 ? (
|
|
||||||
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
|
||||||
{currentFilterCount}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-b border-base-300 px-4 py-2 text-xs text-base-content/60">
|
|
||||||
{activeTab === "pages" ? (
|
|
||||||
<>
|
|
||||||
Other pages LLMs cited in the same answers that referenced{" "}
|
|
||||||
<strong className="text-base-content/80">
|
|
||||||
{result.resolvedTarget}
|
|
||||||
</strong>
|
|
||||||
. Useful for spotting the sources competing for attention alongside
|
|
||||||
your domain.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
User prompts where the LLM's answer referenced{" "}
|
|
||||||
<strong className="text-base-content/80">
|
|
||||||
{result.resolvedTarget}
|
|
||||||
</strong>{" "}
|
|
||||||
in its text or citations. The prompt itself does not have to mention
|
|
||||||
your domain.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filters.showFilters ? (
|
|
||||||
<BrandLookupFilterPanel activeTab={activeTab} filters={filters} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === "pages" ? (
|
|
||||||
<TopPagesTable table={pagesTable} />
|
|
||||||
) : (
|
|
||||||
<TopQueriesTable table={queriesTable} />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelative(iso: string): string {
|
function formatRelative(iso: string): string {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
if (Number.isNaN(date.getTime())) return "just now";
|
if (Number.isNaN(date.getTime())) return "just now";
|
||||||
|
|||||||
@ -7,41 +7,59 @@ import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
|
|||||||
type Props = {
|
type Props = {
|
||||||
query: string;
|
query: string;
|
||||||
onQueryChange: (next: string) => void;
|
onQueryChange: (next: string) => void;
|
||||||
|
competitors: string;
|
||||||
|
onCompetitorsChange: (next: string) => void;
|
||||||
onSubmit: (event: FormEvent) => void;
|
onSubmit: (event: FormEvent) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
validationError: string | null;
|
validationError: { field: "query" | "competitors"; message: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One brand lookup = 6 DataForSEO calls (3 endpoints × 2 platforms). Measured
|
* One brand lookup = 6 DataForSEO calls (aggregated_metrics + top_pages +
|
||||||
* live at ~$0.634 raw via `pnpm billing:brand-lookup`; rounded up to leave
|
* mentions_search × 2 platforms). Rounded up with headroom because
|
||||||
* headroom for per-query variance.
|
* mentions_search is row-priced at the full 100-row sample per platform.
|
||||||
*/
|
*/
|
||||||
const BRAND_LOOKUP_RAW_COST_USD = 0.65;
|
const BRAND_LOOKUP_RAW_COST_USD = 0.85;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adding competitors triggers 2 extra cross_aggregated_metrics calls (one per
|
||||||
|
* platform). Measured live (Jun 2026) at $0.101 each — $0.202 total for a
|
||||||
|
* 4-group comparison — via `pnpm billing:brand-lookup --competitors=...`. A
|
||||||
|
* fixed estimate, marked up once at module load exactly like the base.
|
||||||
|
*/
|
||||||
|
const BRAND_LOOKUP_COMPETITOR_RAW_COST_USD = 0.2;
|
||||||
|
|
||||||
// Hosted customers are billed the marked-up USD; self-hosted users pay
|
// Hosted customers are billed the marked-up USD; self-hosted users pay
|
||||||
// DataForSEO directly at the raw rate.
|
// DataForSEO directly at the raw rate.
|
||||||
const BRAND_LOOKUP_DISPLAYED_COST_USD = isHostedClientAuthMode()
|
const markup = (rawUsd: number) =>
|
||||||
? applyBillingMarkupUsd(BRAND_LOOKUP_RAW_COST_USD)
|
isHostedClientAuthMode() ? applyBillingMarkupUsd(rawUsd) : rawUsd;
|
||||||
: BRAND_LOOKUP_RAW_COST_USD;
|
|
||||||
|
const BRAND_LOOKUP_DISPLAYED_COST_USD = markup(BRAND_LOOKUP_RAW_COST_USD);
|
||||||
|
const BRAND_LOOKUP_COMPETITOR_DISPLAYED_COST_USD = markup(
|
||||||
|
BRAND_LOOKUP_COMPETITOR_RAW_COST_USD,
|
||||||
|
);
|
||||||
|
|
||||||
export function BrandLookupSearchCard({
|
export function BrandLookupSearchCard({
|
||||||
query,
|
query,
|
||||||
onQueryChange,
|
onQueryChange,
|
||||||
|
competitors,
|
||||||
|
onCompetitorsChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading,
|
isLoading,
|
||||||
validationError,
|
validationError,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const hasCompetitors = competitors.trim().length > 0;
|
||||||
|
const queryError = validationError?.field === "query";
|
||||||
|
const competitorsError = validationError?.field === "competitors";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card border border-base-300 bg-base-100">
|
<div className="card border border-base-300 bg-base-100">
|
||||||
<div className="card-body gap-4">
|
<div className="card-body gap-4">
|
||||||
<form
|
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||||
onSubmit={onSubmit}
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
className="flex flex-col gap-3 lg:flex-row lg:items-center"
|
|
||||||
>
|
|
||||||
<label
|
<label
|
||||||
className={`input input-bordered flex flex-1 items-center gap-2 ${
|
className={`input input-bordered flex flex-1 items-center gap-2 ${
|
||||||
validationError ? "input-error" : ""
|
queryError ? "input-error" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Search className="size-4 text-base-content/60" />
|
<Search className="size-4 text-base-content/60" />
|
||||||
@ -51,9 +69,9 @@ export function BrandLookupSearchCard({
|
|||||||
value={query}
|
value={query}
|
||||||
maxLength={BRAND_LOOKUP_MAX_INPUT_LENGTH}
|
maxLength={BRAND_LOOKUP_MAX_INPUT_LENGTH}
|
||||||
onChange={(event) => onQueryChange(event.target.value)}
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
aria-invalid={validationError ? true : undefined}
|
aria-invalid={queryError || undefined}
|
||||||
aria-describedby={
|
aria-describedby={
|
||||||
validationError ? "brand-lookup-input-error" : undefined
|
queryError ? "brand-lookup-input-error" : undefined
|
||||||
}
|
}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
@ -68,11 +86,35 @@ export function BrandLookupSearchCard({
|
|||||||
>
|
>
|
||||||
{isLoading ? "Looking up..." : "Look up"}
|
{isLoading ? "Looking up..." : "Look up"}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add competitors (comma-separated)"
|
||||||
|
value={competitors}
|
||||||
|
onChange={(event) => onCompetitorsChange(event.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className={`input input-bordered w-full ${
|
||||||
|
competitorsError ? "input-error" : ""
|
||||||
|
}`}
|
||||||
|
aria-label="Competitors"
|
||||||
|
aria-invalid={competitorsError || undefined}
|
||||||
|
aria-describedby={
|
||||||
|
competitorsError ? "brand-lookup-input-error" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-base-content/60">
|
||||||
|
Add up to 5 competitor brands or domains to see your Share of
|
||||||
|
Voice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{validationError ? (
|
{validationError ? (
|
||||||
<p id="brand-lookup-input-error" className="text-sm text-error">
|
<p id="brand-lookup-input-error" className="text-sm text-error">
|
||||||
{validationError}
|
{validationError.message}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -82,6 +124,14 @@ export function BrandLookupSearchCard({
|
|||||||
<span className="font-medium text-base-content/80">
|
<span className="font-medium text-base-content/80">
|
||||||
${BRAND_LOOKUP_DISPLAYED_COST_USD.toFixed(2)}
|
${BRAND_LOOKUP_DISPLAYED_COST_USD.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
|
{hasCompetitors ? (
|
||||||
|
<span>
|
||||||
|
{" "}
|
||||||
|
plus ~$
|
||||||
|
{BRAND_LOOKUP_COMPETITOR_DISPLAYED_COST_USD.toFixed(2)} to
|
||||||
|
compare competitors
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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[][] } {
|
): { headers: string[]; rows: CsvValue[][] } {
|
||||||
if (tab === "pages") {
|
if (tab === "pages") {
|
||||||
return {
|
return {
|
||||||
headers: ["URL", "Domain", "Platform", "Mentions"],
|
headers: [
|
||||||
|
"URL",
|
||||||
|
"Domain",
|
||||||
|
"Platform",
|
||||||
|
"Source mentions",
|
||||||
|
"Source AI search volume",
|
||||||
|
"Fetched-sample prompt examples",
|
||||||
|
],
|
||||||
rows: sortedPages.map((row) => [
|
rows: sortedPages.map((row) => [
|
||||||
row.url,
|
row.url,
|
||||||
row.domain ?? "",
|
row.domain ?? "",
|
||||||
formatPlatformLabel(row.platform),
|
formatPlatformLabel(row.platform),
|
||||||
row.mentions ?? "",
|
row.mentions ?? "",
|
||||||
|
row.capturedVolume ?? "",
|
||||||
|
row.keywords.map((keyword) => keyword.question).join("; "),
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,17 @@ export function formatPlatformLabel(platform: "chat_gpt" | "google"): string {
|
|||||||
return MENTION_PLATFORM_LABELS[platform];
|
return MENTION_PLATFORM_LABELS[platform];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared per-platform accent dot + short label for compact table/KPI rows. */
|
||||||
|
export const PLATFORM_DOT_CLASS: Record<"chat_gpt" | "google", string> = {
|
||||||
|
chat_gpt: "bg-emerald-500",
|
||||||
|
google: "bg-sky-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLATFORM_SHORT_LABEL: Record<"chat_gpt" | "google", string> = {
|
||||||
|
chat_gpt: "ChatGPT",
|
||||||
|
google: "Google",
|
||||||
|
};
|
||||||
|
|
||||||
export function formatModelLabel(model: PromptExplorerModel): string {
|
export function formatModelLabel(model: PromptExplorerModel): string {
|
||||||
return MODEL_LABELS[model];
|
return MODEL_LABELS[model];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,10 @@ import {
|
|||||||
} from "./brandLookupFilterTypes";
|
} from "./brandLookupFilterTypes";
|
||||||
import { countActiveFilters } from "./brandLookupFiltering";
|
import { countActiveFilters } from "./brandLookupFiltering";
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = "brand-lookup-filters:";
|
// v3: the pages tab returned to provider page-level metrics after a brief
|
||||||
|
// sampled-prompt scale. Bump the prefix so local min/max filters do not carry
|
||||||
|
// between incompatible metric scales.
|
||||||
|
const STORAGE_KEY_PREFIX = "brand-lookup-filters-v3:";
|
||||||
|
|
||||||
type FilterValues = Record<string, string>;
|
type FilterValues = Record<string, string>;
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearch
|
|||||||
|
|
||||||
const brandLookupSearchBodySchema = z.object({
|
const brandLookupSearchBodySchema = z.object({
|
||||||
query: z.string(),
|
query: z.string(),
|
||||||
|
// Optional/defaulted so pre-existing history entries (query only) still parse.
|
||||||
|
competitors: z.array(z.string()).optional().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BrandLookupSearchBody = z.infer<typeof brandLookupSearchBodySchema>;
|
type BrandLookupSearchBody = z.infer<typeof brandLookupSearchBodySchema>;
|
||||||
@ -15,6 +17,10 @@ export function useBrandLookupSearchHistory(projectId: string) {
|
|||||||
return useTimestampedSearchHistory({
|
return useTimestampedSearchHistory({
|
||||||
storageKey: `brand-lookup-search-history:${projectId}`,
|
storageKey: `brand-lookup-search-history:${projectId}`,
|
||||||
bodySchema: brandLookupSearchBodySchema,
|
bodySchema: brandLookupSearchBodySchema,
|
||||||
isSame: (a, b) => a.query === b.query,
|
// Competitor set is part of the identity: a plain lookup must not replace
|
||||||
|
// the saved (already paid for) Share-of-Voice comparison of the same brand.
|
||||||
|
isSame: (a, b) =>
|
||||||
|
a.query === b.query &&
|
||||||
|
a.competitors.join(",") === b.competitors.join(","),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,17 +10,24 @@ export const Route = createFileRoute("/_project/p/$projectId/brand-lookup")({
|
|||||||
function BrandLookupRoute() {
|
function BrandLookupRoute() {
|
||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
const navigate = useNavigate({ from: Route.fullPath });
|
const navigate = useNavigate({ from: Route.fullPath });
|
||||||
const { q = "" } = Route.useSearch();
|
// `c` is already an opaque competitor string array via the schema transform.
|
||||||
|
const { q = "", c = [] } = Route.useSearch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrandLookupPage
|
<BrandLookupPage
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
initialQuery={q}
|
initialQuery={q}
|
||||||
onQueryChange={(nextQuery) => {
|
initialCompetitors={c}
|
||||||
|
onSearchChange={(nextQuery, nextCompetitors) => {
|
||||||
void navigate({
|
void navigate({
|
||||||
search: (prev) => ({
|
search: (prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
q: nextQuery.trim() || undefined,
|
q: nextQuery.trim() || undefined,
|
||||||
|
// One serialization site: comma-join the competitor list.
|
||||||
|
c:
|
||||||
|
nextCompetitors.length > 0
|
||||||
|
? nextCompetitors.join(",")
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
|
|||||||
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 { waitUntil } from "cloudflare:workers";
|
||||||
import { sortBy } from "remeda";
|
|
||||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
import { createDataforseoClient } from "@/server/lib/dataforseo";
|
import { createDataforseoClient } from "@/server/lib/dataforseo";
|
||||||
import {
|
import {
|
||||||
@ -8,20 +7,25 @@ import {
|
|||||||
CHATGPT_LOCATION_CODE,
|
CHATGPT_LOCATION_CODE,
|
||||||
type LlmPlatform,
|
type LlmPlatform,
|
||||||
} from "@/server/lib/dataforseo";
|
} from "@/server/lib/dataforseo";
|
||||||
import type {
|
import type { LlmCrossAggregatedItem } from "@/server/lib/dataforseoLlmSchemas";
|
||||||
LlmAggregatedTotal,
|
|
||||||
LlmMentionItem,
|
|
||||||
LlmTopPagesItem,
|
|
||||||
} from "@/server/lib/dataforseoLlmSchemas";
|
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
|
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
|
||||||
import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl";
|
import {
|
||||||
|
resolveCompetitorGroups,
|
||||||
|
type CompetitorGroup,
|
||||||
|
type CrossOutcome,
|
||||||
|
} from "@/server/features/ai-search/services/shareOfVoice";
|
||||||
|
import {
|
||||||
|
shapeResult,
|
||||||
|
type PlatformBundle,
|
||||||
|
type PlatformOutcome,
|
||||||
|
} from "@/server/features/ai-search/services/brandLookupShaping";
|
||||||
import {
|
import {
|
||||||
brandLookupResultSchema,
|
brandLookupResultSchema,
|
||||||
type BrandLookupInput,
|
type BrandLookupInput,
|
||||||
type BrandLookupResult,
|
type BrandLookupResult,
|
||||||
} from "@/types/schemas/ai-search";
|
} from "@/types/schemas/ai-search";
|
||||||
import { detectTarget } from "@/server/features/ai-search/targetDetection";
|
import { detectTarget } from "@/shared/targetDetection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brand Lookup is the AI-search analog of Domain Overview. The user types a
|
* Brand Lookup is the AI-search analog of Domain Overview. The user types a
|
||||||
@ -35,39 +39,77 @@ const BRAND_LOOKUP_TTL_SECONDS = 24 * 60 * 60;
|
|||||||
|
|
||||||
const PLATFORMS: LlmPlatform[] = ["chat_gpt", "google"];
|
const PLATFORMS: LlmPlatform[] = ["chat_gpt", "google"];
|
||||||
|
|
||||||
const TOP_PAGES_PER_PLATFORM = 10;
|
// Prompt rows supply explainable examples for cited pages. Ranked source rows
|
||||||
const TOP_QUERIES_PER_PLATFORM = 25;
|
// come from top_pages so the table is not limited to this sample.
|
||||||
|
const MENTIONS_PER_PLATFORM = 100;
|
||||||
|
const TOP_SOURCES_PER_PLATFORM = 10;
|
||||||
|
|
||||||
export async function getBrandLookup(
|
export async function getBrandLookup(
|
||||||
input: BrandLookupInput,
|
input: BrandLookupInput,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
): Promise<BrandLookupResult> {
|
): Promise<BrandLookupResult> {
|
||||||
const detected = detectTarget(input.query);
|
const detected = detectTarget(input.query);
|
||||||
|
const competitorGroups = resolveCompetitorGroups(
|
||||||
|
detected.value,
|
||||||
|
input.competitors,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Changing this key's param set orphans every pre-deploy cache entry; with a
|
||||||
|
// 24h TTL that's at most one re-charged lookup per cached target — accepted
|
||||||
|
// rather than maintaining parallel legacy-shape parsing.
|
||||||
const cacheKey = await buildCacheKey("ai-search:brand-lookup", {
|
const cacheKey = await buildCacheKey("ai-search:brand-lookup", {
|
||||||
organizationId: billingCustomer.organizationId,
|
organizationId: billingCustomer.organizationId,
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
targetType: detected.type,
|
targetType: detected.type,
|
||||||
targetValue: detected.value,
|
// Values are lowercased for DataForSEO's matching semantics. Competitors
|
||||||
|
// are canonical detected values too, so equivalent casing/order shares one
|
||||||
|
// paid cache entry.
|
||||||
|
targetValue: detected.value.toLowerCase(),
|
||||||
|
competitors: competitorGroups
|
||||||
|
.map((g) => g.detected.value.toLowerCase())
|
||||||
|
.toSorted()
|
||||||
|
.join("|"),
|
||||||
locationCode: input.locationCode,
|
locationCode: input.locationCode,
|
||||||
languageCode: input.languageCode,
|
languageCode: input.languageCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cached = brandLookupResultSchema.safeParse(await getCached(cacheKey));
|
const cached = brandLookupResultSchema.safeParse(await getCached(cacheKey));
|
||||||
if (cached.success) return cached.data;
|
if (cached.success) {
|
||||||
|
return {
|
||||||
|
...cached.data,
|
||||||
|
query: input.query,
|
||||||
|
resolvedTarget: detected.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const dataforseo = createDataforseoClient(billingCustomer);
|
const dataforseo = createDataforseoClient(billingCustomer);
|
||||||
|
|
||||||
// Settle each platform independently so a failure in one doesn't discard
|
// Settle each platform independently so a failure in one doesn't discard the
|
||||||
// the other (which the caller already paid for via meterDataforseoCall).
|
// other. Keep the metered DataForSEO calls sequenced: in hosted mode each
|
||||||
const settled = await Promise.allSettled(
|
// call checks balance before execution and records spend after, so parallel
|
||||||
PLATFORMS.map((platform) =>
|
// fan-out can overrun a low remaining balance.
|
||||||
|
const settled: Array<PromiseSettledResult<PlatformBundle>> = [];
|
||||||
|
for (const platform of PLATFORMS) {
|
||||||
|
settled.push(
|
||||||
|
await settle(() =>
|
||||||
fetchPlatformData(platform, detected, input, dataforseo),
|
fetchPlatformData(platform, detected, input, dataforseo),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
rethrowIfBlockingAiSearchError(settled);
|
rethrowIfBlockingAiSearchError(settled);
|
||||||
|
|
||||||
|
const crossSettled =
|
||||||
|
competitorGroups.length > 0
|
||||||
|
? await settle(() =>
|
||||||
|
fetchCrossAggregated(detected, competitorGroups, input, dataforseo),
|
||||||
|
)
|
||||||
|
: ({ status: "fulfilled", value: [] } as PromiseFulfilledResult<
|
||||||
|
CrossOutcome[]
|
||||||
|
>);
|
||||||
|
if (crossSettled.status === "rejected") throw crossSettled.reason;
|
||||||
|
const crossOutcomes = crossSettled.value;
|
||||||
|
|
||||||
const platformBundles: PlatformOutcome[] = settled.map((settledResult, i) => {
|
const platformBundles: PlatformOutcome[] = settled.map((settledResult, i) => {
|
||||||
const platform = PLATFORMS[i];
|
const platform = PLATFORMS[i];
|
||||||
if (settledResult.status === "fulfilled") {
|
if (settledResult.status === "fulfilled") {
|
||||||
@ -84,13 +126,20 @@ export async function getBrandLookup(
|
|||||||
query: input.query,
|
query: input.query,
|
||||||
detected,
|
detected,
|
||||||
platformBundles,
|
platformBundles,
|
||||||
|
crossOutcomes,
|
||||||
|
competitorKeys: competitorGroups.map((g) => g.label),
|
||||||
userLocationCode: input.locationCode,
|
userLocationCode: input.locationCode,
|
||||||
userLanguageCode: input.languageCode,
|
userLanguageCode: input.languageCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only cache when every platform succeeded — otherwise users would see a
|
// Only cache when every call succeeded — a platform bundle that swallowed a
|
||||||
// stale partial result for 24h and have no way to retry without busting it.
|
// failed sub-call into empty fallback data is renderable but must not be
|
||||||
const allSucceeded = platformBundles.every((b) => b.status === "success");
|
// frozen for 24h with no way to retry; same for a partial SoV miss when
|
||||||
|
// competitors were requested.
|
||||||
|
const allSucceeded =
|
||||||
|
platformBundles.every(
|
||||||
|
(b) => b.status === "success" && b.bundle?.complete,
|
||||||
|
) && crossOutcomes.every((c) => c.status === "success");
|
||||||
if (allSucceeded && result.hasData) {
|
if (allSucceeded && result.hasData) {
|
||||||
waitUntil(
|
waitUntil(
|
||||||
setCached(cacheKey, result, BRAND_LOOKUP_TTL_SECONDS).catch((err) => {
|
setCached(cacheKey, result, BRAND_LOOKUP_TTL_SECONDS).catch((err) => {
|
||||||
@ -102,23 +151,21 @@ export async function getBrandLookup(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function settle<T>(
|
||||||
|
execute: () => Promise<T>,
|
||||||
|
): Promise<PromiseSettledResult<T>> {
|
||||||
|
try {
|
||||||
|
return { status: "fulfilled", value: await execute() };
|
||||||
|
} catch (reason) {
|
||||||
|
return { status: "rejected", reason };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type PlatformFetchInput = Pick<
|
type PlatformFetchInput = Pick<
|
||||||
BrandLookupInput,
|
BrandLookupInput,
|
||||||
"locationCode" | "languageCode"
|
"locationCode" | "languageCode"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type PlatformBundle = {
|
|
||||||
aggregated: LlmAggregatedTotal;
|
|
||||||
topPages: LlmTopPagesItem[];
|
|
||||||
mentions: LlmMentionItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type PlatformOutcome = {
|
|
||||||
platform: LlmPlatform;
|
|
||||||
status: "success" | "error";
|
|
||||||
bundle: PlatformBundle | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function fetchPlatformData(
|
async function fetchPlatformData(
|
||||||
platform: LlmPlatform,
|
platform: LlmPlatform,
|
||||||
detected: ReturnType<typeof detectTarget>,
|
detected: ReturnType<typeof detectTarget>,
|
||||||
@ -136,9 +183,9 @@ async function fetchPlatformData(
|
|||||||
const languageCode =
|
const languageCode =
|
||||||
platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : input.languageCode;
|
platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : input.languageCode;
|
||||||
|
|
||||||
// `allSettled` so one sub-call failing doesn't discard the other two we
|
// Settle sub-calls independently so one failure doesn't discard the others we
|
||||||
// already paid for. Each sub-call is metered independently upstream.
|
// already paid for, but keep them sequenced for hosted billing checks.
|
||||||
const [aggregated, topPages, mentions] = await Promise.allSettled([
|
const aggregated = await settle(() =>
|
||||||
dataforseo.aiSearch.aggregatedMetrics({
|
dataforseo.aiSearch.aggregatedMetrics({
|
||||||
target,
|
target,
|
||||||
platform,
|
platform,
|
||||||
@ -146,21 +193,25 @@ async function fetchPlatformData(
|
|||||||
languageCode,
|
languageCode,
|
||||||
internalListLimit: 20,
|
internalListLimit: 20,
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
const topPages = await settle(() =>
|
||||||
dataforseo.aiSearch.topPages({
|
dataforseo.aiSearch.topPages({
|
||||||
target,
|
target,
|
||||||
platform,
|
platform,
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode,
|
languageCode,
|
||||||
itemsListLimit: TOP_PAGES_PER_PLATFORM,
|
itemsListLimit: TOP_SOURCES_PER_PLATFORM,
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
const mentions = await settle(() =>
|
||||||
dataforseo.aiSearch.mentionsSearch({
|
dataforseo.aiSearch.mentionsSearch({
|
||||||
target,
|
target,
|
||||||
platform,
|
platform,
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode,
|
languageCode,
|
||||||
limit: TOP_QUERIES_PER_PLATFORM,
|
limit: MENTIONS_PER_PLATFORM,
|
||||||
}),
|
}),
|
||||||
]);
|
);
|
||||||
|
|
||||||
rethrowIfBlockingAiSearchError([aggregated, topPages, mentions]);
|
rethrowIfBlockingAiSearchError([aggregated, topPages, mentions]);
|
||||||
|
|
||||||
@ -176,9 +227,76 @@ async function fetchPlatformData(
|
|||||||
aggregated: fulfilledOr(aggregated, () => ({}), platform, "aggregated"),
|
aggregated: fulfilledOr(aggregated, () => ({}), platform, "aggregated"),
|
||||||
topPages: fulfilledOr(topPages, () => [], platform, "topPages"),
|
topPages: fulfilledOr(topPages, () => [], platform, "topPages"),
|
||||||
mentions: fulfilledOr(mentions, () => [], platform, "mentions"),
|
mentions: fulfilledOr(mentions, () => [], platform, "mentions"),
|
||||||
|
complete:
|
||||||
|
aggregated.status === "fulfilled" &&
|
||||||
|
topPages.status === "fulfilled" &&
|
||||||
|
mentions.status === "fulfilled",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One cross_aggregated_metrics call per platform (ChatGPT forced to US/en),
|
||||||
|
* each comparing the target against the competitors. Settled per-platform so a
|
||||||
|
* single failure doesn't discard the other — matching the per-platform
|
||||||
|
* fan-out in {@link getBrandLookup}. The target's aggregation_key is the
|
||||||
|
* resolved target value so SoV can flag the target row.
|
||||||
|
*/
|
||||||
|
async function fetchCrossAggregated(
|
||||||
|
detected: ReturnType<typeof detectTarget>,
|
||||||
|
competitors: CompetitorGroup[],
|
||||||
|
input: PlatformFetchInput,
|
||||||
|
dataforseo: ReturnType<typeof createDataforseoClient>,
|
||||||
|
): Promise<CrossOutcome[]> {
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
key: detected.value,
|
||||||
|
target: buildLlmTarget({ type: detected.type, value: detected.value }),
|
||||||
|
},
|
||||||
|
...competitors.map((competitor) => ({
|
||||||
|
key: competitor.label,
|
||||||
|
target: buildLlmTarget({
|
||||||
|
type: competitor.detected.type,
|
||||||
|
value: competitor.detected.value,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const settled: Array<PromiseSettledResult<LlmCrossAggregatedItem[]>> = [];
|
||||||
|
for (const platform of PLATFORMS) {
|
||||||
|
settled.push(
|
||||||
|
await settle(() =>
|
||||||
|
dataforseo.aiSearch.crossAggregatedMetrics({
|
||||||
|
groups,
|
||||||
|
platform,
|
||||||
|
// ChatGPT mentions DB only contains US/en data per DataForSEO docs.
|
||||||
|
locationCode:
|
||||||
|
platform === "chat_gpt"
|
||||||
|
? CHATGPT_LOCATION_CODE
|
||||||
|
: input.locationCode,
|
||||||
|
languageCode:
|
||||||
|
platform === "chat_gpt"
|
||||||
|
? CHATGPT_LANGUAGE_CODE
|
||||||
|
: input.languageCode,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rethrowIfBlockingAiSearchError(settled);
|
||||||
|
|
||||||
|
return settled.map((result, i) => {
|
||||||
|
const platform = PLATFORMS[i];
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
return { platform, status: "success" as const, items: result.value };
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
`ai-search.brand-lookup.${platform}.cross-aggregated.error:`,
|
||||||
|
result.reason,
|
||||||
|
);
|
||||||
|
return { platform, status: "error" as const, items: [] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function rethrowIfBlockingAiSearchError(
|
function rethrowIfBlockingAiSearchError(
|
||||||
results: Array<PromiseSettledResult<unknown>>,
|
results: Array<PromiseSettledResult<unknown>>,
|
||||||
): void {
|
): void {
|
||||||
@ -208,197 +326,3 @@ function fulfilledOr<T>(
|
|||||||
);
|
);
|
||||||
return fallback();
|
return fallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShapeArgs = {
|
|
||||||
query: string;
|
|
||||||
detected: ReturnType<typeof detectTarget>;
|
|
||||||
platformBundles: PlatformOutcome[];
|
|
||||||
userLocationCode: number;
|
|
||||||
userLanguageCode: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function shapeResult(args: ShapeArgs): BrandLookupResult {
|
|
||||||
const successfulBundles = args.platformBundles.filter(
|
|
||||||
(b): b is PlatformOutcome & { bundle: PlatformBundle } =>
|
|
||||||
b.status === "success" && b.bundle !== null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ChatGPT data is always fetched US/en (DataForSEO only indexes that
|
|
||||||
// locale), so when the user picks a non-US/en locale we must not fold its
|
|
||||||
// numbers into cross-platform totals or the monthly trend — doing so would
|
|
||||||
// mix two different datasets under one locale label. Per-platform rows
|
|
||||||
// still render ChatGPT separately with the "US-only" tooltip.
|
|
||||||
// Match the primary subtag so "en-US"/"en_US" still count as English.
|
|
||||||
const primaryLanguage = args.userLanguageCode.toLowerCase().split(/[-_]/)[0];
|
|
||||||
const chatGptLocaleMatches =
|
|
||||||
args.userLocationCode === CHATGPT_LOCATION_CODE &&
|
|
||||||
primaryLanguage === CHATGPT_LANGUAGE_CODE;
|
|
||||||
|
|
||||||
const perPlatform = args.platformBundles.map((outcome) => {
|
|
||||||
if (outcome.status === "error" || !outcome.bundle) {
|
|
||||||
return {
|
|
||||||
platform: outcome.platform,
|
|
||||||
status: "error" as const,
|
|
||||||
mentions: null,
|
|
||||||
aiSearchVolume: null,
|
|
||||||
impressions: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const platformGroup = outcome.bundle.aggregated.platform?.find(
|
|
||||||
(entry) => entry.key === outcome.platform,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
platform: outcome.platform,
|
|
||||||
status: "success" as const,
|
|
||||||
mentions: roundOrNull(platformGroup?.mentions),
|
|
||||||
aiSearchVolume: roundOrNull(platformGroup?.ai_search_volume),
|
|
||||||
impressions: roundOrNull(platformGroup?.impressions),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const aggregatablePlatforms = perPlatform.filter(
|
|
||||||
(p) => chatGptLocaleMatches || p.platform !== "chat_gpt",
|
|
||||||
);
|
|
||||||
const totalMentions = sumNullable(
|
|
||||||
aggregatablePlatforms.map((p) => p.mentions),
|
|
||||||
);
|
|
||||||
const totalAiSearchVolume = sumNullable(
|
|
||||||
aggregatablePlatforms.map((p) => p.aiSearchVolume),
|
|
||||||
);
|
|
||||||
const totalImpressions = sumNullable(
|
|
||||||
aggregatablePlatforms.map((p) => p.impressions),
|
|
||||||
);
|
|
||||||
|
|
||||||
const topPages = sortBy(
|
|
||||||
successfulBundles.flatMap((bundle) =>
|
|
||||||
bundle.bundle.topPages
|
|
||||||
.map((page) => {
|
|
||||||
const safeUrl = safeHttpUrl(page.key);
|
|
||||||
if (!safeUrl) return null;
|
|
||||||
return {
|
|
||||||
url: safeUrl,
|
|
||||||
domain: safeHostname(safeUrl),
|
|
||||||
mentions: roundOrNull(
|
|
||||||
page.platform?.find((entry) => entry.key === bundle.platform)
|
|
||||||
?.mentions,
|
|
||||||
),
|
|
||||||
platform: bundle.platform,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((page): page is NonNullable<typeof page> => page !== null),
|
|
||||||
),
|
|
||||||
[(page) => page.mentions ?? 0, "desc"],
|
|
||||||
).slice(0, 20);
|
|
||||||
|
|
||||||
const topQueries = sortBy(
|
|
||||||
successfulBundles.flatMap((bundle) =>
|
|
||||||
bundle.bundle.mentions
|
|
||||||
.filter(
|
|
||||||
(item): item is LlmMentionItem & { question: string } =>
|
|
||||||
typeof item.question === "string" && item.question.length > 0,
|
|
||||||
)
|
|
||||||
.map((item) => ({
|
|
||||||
question: item.question,
|
|
||||||
platform: bundle.platform,
|
|
||||||
aiSearchVolume: roundOrNull(item.ai_search_volume),
|
|
||||||
firstSeenAt: item.first_response_at ?? null,
|
|
||||||
lastSeenAt: item.last_response_at ?? null,
|
|
||||||
citedSources: (item.sources ?? [])
|
|
||||||
.map((src) => {
|
|
||||||
const safeUrl = safeHttpUrl(src.url);
|
|
||||||
if (!safeUrl) return null;
|
|
||||||
return {
|
|
||||||
url: safeUrl,
|
|
||||||
domain: src.domain ?? safeHostname(safeUrl),
|
|
||||||
title: src.title ?? null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((src): src is NonNullable<typeof src> => src !== null)
|
|
||||||
.slice(0, 10),
|
|
||||||
brandsMentioned: (item.brand_entities ?? [])
|
|
||||||
.map((entity) => entity.title ?? "")
|
|
||||||
.filter((title) => title.length > 0)
|
|
||||||
.slice(0, 20),
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
[(query) => query.aiSearchVolume ?? 0, "desc"],
|
|
||||||
).slice(0, 50);
|
|
||||||
|
|
||||||
const trendBundles = chatGptLocaleMatches
|
|
||||||
? successfulBundles
|
|
||||||
: successfulBundles.filter((b) => b.platform !== "chat_gpt");
|
|
||||||
const monthlyVolume = aggregateMonthlyVolume(trendBundles);
|
|
||||||
|
|
||||||
const hasData =
|
|
||||||
(totalMentions ?? 0) > 0 ||
|
|
||||||
topPages.length > 0 ||
|
|
||||||
topQueries.length > 0 ||
|
|
||||||
monthlyVolume.length > 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
query: args.query,
|
|
||||||
detectedTargetType: args.detected.type,
|
|
||||||
resolvedTarget: args.detected.value,
|
|
||||||
fetchedAt: new Date().toISOString(),
|
|
||||||
hasData,
|
|
||||||
totalMentions,
|
|
||||||
totalAiSearchVolume,
|
|
||||||
totalImpressions,
|
|
||||||
perPlatform,
|
|
||||||
topPages,
|
|
||||||
topQueries,
|
|
||||||
monthlyVolume,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sumNullable(values: Array<number | null>): number | null {
|
|
||||||
let total = 0;
|
|
||||||
let hasValue = false;
|
|
||||||
for (const value of values) {
|
|
||||||
if (value != null) {
|
|
||||||
total += value;
|
|
||||||
hasValue = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasValue ? total : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundOrNull(value: number | null | undefined): number | null {
|
|
||||||
if (value == null) return null;
|
|
||||||
return Math.round(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sum monthly mention volume across all returned mention items, regardless of
|
|
||||||
* platform. Returns the most recent 12 months in chronological order.
|
|
||||||
*/
|
|
||||||
function aggregateMonthlyVolume(
|
|
||||||
bundles: Array<PlatformOutcome & { bundle: PlatformBundle }>,
|
|
||||||
): BrandLookupResult["monthlyVolume"] {
|
|
||||||
const totals = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const outcome of bundles) {
|
|
||||||
for (const mention of outcome.bundle.mentions) {
|
|
||||||
for (const monthly of mention.monthly_searches ?? []) {
|
|
||||||
if (monthly.search_volume == null) continue;
|
|
||||||
const key = `${monthly.year}-${monthly.month}`;
|
|
||||||
totals.set(key, (totals.get(key) ?? 0) + monthly.search_volume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = Array.from(totals.entries()).map(([key, volume]) => {
|
|
||||||
const [yearStr, monthStr] = key.split("-");
|
|
||||||
return {
|
|
||||||
year: Number(yearStr),
|
|
||||||
month: Number(monthStr),
|
|
||||||
volume: Math.round(volume),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return sortBy(
|
|
||||||
entries,
|
|
||||||
[(entry) => entry.year, "asc"],
|
|
||||||
[(entry) => entry.month, "asc"],
|
|
||||||
).slice(-12);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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,
|
AiOptimizationChatGptLlmResponsesLiveRequestInfo,
|
||||||
AiOptimizationClaudeLlmResponsesLiveRequestInfo,
|
AiOptimizationClaudeLlmResponsesLiveRequestInfo,
|
||||||
AiOptimizationGeminiLlmResponsesLiveRequestInfo,
|
AiOptimizationGeminiLlmResponsesLiveRequestInfo,
|
||||||
|
AiOptimizationLLmMentionsCrossAggregateMetricsTargetInfo,
|
||||||
AiOptimizationLLmMentionsDomainElement,
|
AiOptimizationLLmMentionsDomainElement,
|
||||||
AiOptimizationLLmMentionsKeywordElement,
|
AiOptimizationLLmMentionsKeywordElement,
|
||||||
AiOptimizationLlmMentionsAggregatedMetricsLiveRequestInfo,
|
AiOptimizationLlmMentionsAggregatedMetricsLiveRequestInfo,
|
||||||
|
AiOptimizationLlmMentionsCrossAggregatedMetricsLiveRequestInfo,
|
||||||
AiOptimizationLlmMentionsSearchLiveRequestInfo,
|
AiOptimizationLlmMentionsSearchLiveRequestInfo,
|
||||||
AiOptimizationLlmMentionsTopPagesLiveRequestInfo,
|
AiOptimizationLlmMentionsTopPagesLiveRequestInfo,
|
||||||
type BaseAiOptimizationLLmMentionsTargetElement,
|
type BaseAiOptimizationLLmMentionsTargetElement,
|
||||||
@ -13,10 +15,12 @@ import {
|
|||||||
} from "dataforseo-client";
|
} from "dataforseo-client";
|
||||||
import {
|
import {
|
||||||
llmAggregatedTotalSchema,
|
llmAggregatedTotalSchema,
|
||||||
|
llmCrossAggregatedItemSchema,
|
||||||
llmMentionItemSchema,
|
llmMentionItemSchema,
|
||||||
llmResponseResultSchema,
|
llmResponseResultSchema,
|
||||||
llmTopPagesItemSchema,
|
llmTopPagesItemSchema,
|
||||||
type LlmAggregatedTotal,
|
type LlmAggregatedTotal,
|
||||||
|
type LlmCrossAggregatedItem,
|
||||||
type LlmMentionItem,
|
type LlmMentionItem,
|
||||||
type LlmResponseResult,
|
type LlmResponseResult,
|
||||||
type LlmTopPagesItem,
|
type LlmTopPagesItem,
|
||||||
@ -240,6 +244,66 @@ export async function fetchLlmTopPages(
|
|||||||
return { data: items.data, billing: buildTaskBilling(task) };
|
return { data: items.data, billing: buildTaskBilling(task) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LLM Mentions Cross-Aggregated Metrics
|
||||||
|
// Compares 2..10 aggregation groups (target + competitors) in one call and
|
||||||
|
// returns one item per group, keyed by its aggregation_key (brand label).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type LlmCrossAggregatedMetricsInput = {
|
||||||
|
groups: Array<{ key: string; target: LlmTarget }>;
|
||||||
|
platform: LlmPlatform;
|
||||||
|
locationCode: number;
|
||||||
|
languageCode: string;
|
||||||
|
internalListLimit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchLlmCrossAggregatedMetrics(
|
||||||
|
input: LlmCrossAggregatedMetricsInput,
|
||||||
|
): Promise<DataforseoApiResponse<LlmCrossAggregatedItem[]>> {
|
||||||
|
if (input.groups.length < 2 || input.groups.length > 10) {
|
||||||
|
throw new AppError(
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
"DataForSEO llm_mentions/cross_aggregated_metrics requires 2 to 10 target groups",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await aiOptimizationApi(
|
||||||
|
classifyAiSearchError,
|
||||||
|
).llmMentionsCrossAggregatedMetricsLive([
|
||||||
|
new AiOptimizationLlmMentionsCrossAggregatedMetricsLiveRequestInfo({
|
||||||
|
targets: input.groups.map(
|
||||||
|
(group) =>
|
||||||
|
new AiOptimizationLLmMentionsCrossAggregateMetricsTargetInfo({
|
||||||
|
aggregation_key: group.key,
|
||||||
|
target: targetList(group.target),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
platform: input.platform,
|
||||||
|
location_code: input.locationCode,
|
||||||
|
language_code: input.languageCode,
|
||||||
|
internal_list_limit: clampLimit(input.internalListLimit ?? 5, 1, 10),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const task = assertOk(
|
||||||
|
response,
|
||||||
|
assertOptions(
|
||||||
|
"/v3/ai_optimization/llm_mentions/cross_aggregated_metrics/live",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = z
|
||||||
|
.array(llmCrossAggregatedItemSchema)
|
||||||
|
.safeParse(firstResult(task)?.items ?? []);
|
||||||
|
if (!items.success) {
|
||||||
|
throw new AppError(
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
"DataForSEO llm_mentions/cross_aggregated_metrics returned an invalid shape",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { data: items.data, billing: buildTaskBilling(task) };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LLM Responses (per-model)
|
// LLM Responses (per-model)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -80,6 +80,7 @@ vi.mock("@/server/lib/dataforseo/ai", () => ({
|
|||||||
fetchLlmMentionsSearch: vi.fn(),
|
fetchLlmMentionsSearch: vi.fn(),
|
||||||
fetchLlmAggregatedMetrics: vi.fn(),
|
fetchLlmAggregatedMetrics: vi.fn(),
|
||||||
fetchLlmTopPages: vi.fn(),
|
fetchLlmTopPages: vi.fn(),
|
||||||
|
fetchLlmCrossAggregatedMetrics: vi.fn(),
|
||||||
fetchLlmResponse: vi.fn(),
|
fetchLlmResponse: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import {
|
|||||||
import { fetchLighthouseResult } from "@/server/lib/dataforseo/lighthouse";
|
import { fetchLighthouseResult } from "@/server/lib/dataforseo/lighthouse";
|
||||||
import {
|
import {
|
||||||
fetchLlmAggregatedMetrics,
|
fetchLlmAggregatedMetrics,
|
||||||
|
fetchLlmCrossAggregatedMetrics,
|
||||||
fetchLlmMentionsSearch,
|
fetchLlmMentionsSearch,
|
||||||
fetchLlmResponse,
|
fetchLlmResponse,
|
||||||
fetchLlmTopPages,
|
fetchLlmTopPages,
|
||||||
@ -125,6 +126,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
|||||||
mentionsSearch: meter(customer, fetchLlmMentionsSearch),
|
mentionsSearch: meter(customer, fetchLlmMentionsSearch),
|
||||||
aggregatedMetrics: meter(customer, fetchLlmAggregatedMetrics),
|
aggregatedMetrics: meter(customer, fetchLlmAggregatedMetrics),
|
||||||
topPages: meter(customer, fetchLlmTopPages),
|
topPages: meter(customer, fetchLlmTopPages),
|
||||||
|
crossAggregatedMetrics: meter(customer, fetchLlmCrossAggregatedMetrics),
|
||||||
llmResponse: meter(customer, fetchLlmResponse),
|
llmResponse: meter(customer, fetchLlmResponse),
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { fetchQuestionsAnswers } from "@/server/lib/dataforseo/business";
|
|||||||
import {
|
import {
|
||||||
buildLlmTarget,
|
buildLlmTarget,
|
||||||
fetchLlmAggregatedMetrics,
|
fetchLlmAggregatedMetrics,
|
||||||
|
fetchLlmCrossAggregatedMetrics,
|
||||||
fetchLlmMentionsSearch,
|
fetchLlmMentionsSearch,
|
||||||
fetchLlmResponse,
|
fetchLlmResponse,
|
||||||
fetchLlmTopPages,
|
fetchLlmTopPages,
|
||||||
@ -83,7 +84,7 @@ describe("DataForSEO SDK-backed endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes LLM mentions domain targets for all live endpoints", async () => {
|
it("serializes LLM mentions domain targets for search, top pages, and aggregated endpoints", async () => {
|
||||||
const fetchMock = vi.fn<typeof fetch>().mockImplementation((url) => {
|
const fetchMock = vi.fn<typeof fetch>().mockImplementation((url) => {
|
||||||
const path =
|
const path =
|
||||||
typeof url === "string" || url instanceof URL
|
typeof url === "string" || url instanceof URL
|
||||||
@ -132,8 +133,8 @@ describe("DataForSEO SDK-backed endpoints", () => {
|
|||||||
platform: "google",
|
platform: "google",
|
||||||
locationCode: 2840,
|
locationCode: 2840,
|
||||||
languageCode: "en",
|
languageCode: "en",
|
||||||
|
itemsListLimit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectedTarget = [
|
const expectedTarget = [
|
||||||
{
|
{
|
||||||
search_scope: ["any"],
|
search_scope: ["any"],
|
||||||
@ -179,6 +180,79 @@ describe("DataForSEO SDK-backed endpoints", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serializes cross-aggregated target groups", async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
||||||
|
Response.json({
|
||||||
|
status_code: 20000,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
status_code: 20000,
|
||||||
|
path: [
|
||||||
|
"v3",
|
||||||
|
"ai_optimization",
|
||||||
|
"llm_mentions",
|
||||||
|
"cross_aggregated_metrics",
|
||||||
|
"live",
|
||||||
|
],
|
||||||
|
cost: 0.0001,
|
||||||
|
result_count: 1,
|
||||||
|
result: [{ items: [] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await fetchLlmCrossAggregatedMetrics({
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: "example.com",
|
||||||
|
target: buildLlmTarget({ type: "domain", value: "example.com" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Acme Storage",
|
||||||
|
target: buildLlmTarget({ type: "keyword", value: "Acme Storage" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
platform: "google",
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parseDataforseoRequestBody(fetchMock.mock.calls[0]?.[1])).toEqual([
|
||||||
|
{
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
aggregation_key: "example.com",
|
||||||
|
target: [
|
||||||
|
{
|
||||||
|
search_scope: ["any"],
|
||||||
|
search_filter: "include",
|
||||||
|
domain: "example.com",
|
||||||
|
include_subdomains: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aggregation_key: "Acme Storage",
|
||||||
|
target: [
|
||||||
|
{
|
||||||
|
search_scope: ["any", "brand_entities"],
|
||||||
|
search_filter: "include",
|
||||||
|
keyword: "Acme Storage",
|
||||||
|
match_type: "word_match",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
location_code: 2840,
|
||||||
|
language_code: "en",
|
||||||
|
platform: "google",
|
||||||
|
internal_list_limit: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("serializes LLM mentions keyword targets", async () => {
|
it("serializes LLM mentions keyword targets", async () => {
|
||||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
||||||
Response.json({
|
Response.json({
|
||||||
|
|||||||
@ -90,6 +90,27 @@ export const llmTopPagesItemSchema = z
|
|||||||
|
|
||||||
export type LlmTopPagesItem = z.infer<typeof llmTopPagesItemSchema>;
|
export type LlmTopPagesItem = z.infer<typeof llmTopPagesItemSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LLM Mentions Cross-Aggregated Metrics — `/v3/ai_optimization/llm_mentions/cross_aggregated_metrics/live`
|
||||||
|
// One item per requested aggregation group (target + competitors).
|
||||||
|
// `.passthrough()` because the real item also carries location, language,
|
||||||
|
// sources_domain, and brand_entities arrays we intentionally ignore.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const llmCrossAggregatedItemSchema = z
|
||||||
|
.object({
|
||||||
|
// The shared SDK type AiOptimizationLlmMentionssLiveItem documents `key` as
|
||||||
|
// the URL of a found page, but for cross_aggregated `key` is the request
|
||||||
|
// aggregation_key (the brand label).
|
||||||
|
key: z.string().nullable().optional(),
|
||||||
|
platform: z.array(groupElementSchema).nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export type LlmCrossAggregatedItem = z.infer<
|
||||||
|
typeof llmCrossAggregatedItemSchema
|
||||||
|
>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LLM Responses — shared between ChatGPT/Claude/Gemini/Perplexity
|
// LLM Responses — shared between ChatGPT/Claude/Gemini/Perplexity
|
||||||
// All four model endpoints return the same envelope shape.
|
// All four model endpoints return the same envelope shape.
|
||||||
|
|||||||
@ -22,9 +22,34 @@ export const aiSearchProjectSchema = z.object({
|
|||||||
/** Maximum allowed length for a free-text brand or domain search input. */
|
/** Maximum allowed length for a free-text brand or domain search input. */
|
||||||
export const BRAND_LOOKUP_MAX_INPUT_LENGTH = 250;
|
export const BRAND_LOOKUP_MAX_INPUT_LENGTH = 250;
|
||||||
|
|
||||||
|
/** Maximum number of competitors compared in one Share-of-Voice lookup. */
|
||||||
|
const BRAND_LOOKUP_MAX_COMPETITORS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize raw comma-separated competitor text: split, trim, drop empties,
|
||||||
|
* dedupe, cap. Shared by the `c` URL-param transform and the page form so the
|
||||||
|
* two never diverge.
|
||||||
|
*/
|
||||||
|
export function parseCompetitorList(raw: string): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
raw
|
||||||
|
.split(",")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 0),
|
||||||
|
),
|
||||||
|
).slice(0, BRAND_LOOKUP_MAX_COMPETITORS);
|
||||||
|
}
|
||||||
|
|
||||||
export const brandLookupInputSchema = z.object({
|
export const brandLookupInputSchema = z.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
query: z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH),
|
query: z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH),
|
||||||
|
// Optional competitor brands/domains to compare Share of Voice against.
|
||||||
|
// cross_aggregated_metrics caps groups at 10 (target + 9); we cap at 5.
|
||||||
|
competitors: z
|
||||||
|
.array(z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH))
|
||||||
|
.max(BRAND_LOOKUP_MAX_COMPETITORS)
|
||||||
|
.default([]),
|
||||||
locationCode: z.number().int().positive().default(2840),
|
locationCode: z.number().int().positive().default(2840),
|
||||||
languageCode: z.string().min(2).max(8).default("en"),
|
languageCode: z.string().min(2).max(8).default("en"),
|
||||||
});
|
});
|
||||||
@ -36,18 +61,42 @@ const brandPlatformBreakdownSchema = z.object({
|
|||||||
status: z.enum(["success", "error"]),
|
status: z.enum(["success", "error"]),
|
||||||
mentions: z.number().int().nonnegative().nullable(),
|
mentions: z.number().int().nonnegative().nullable(),
|
||||||
aiSearchVolume: z.number().int().nonnegative().nullable(),
|
aiSearchVolume: z.number().int().nonnegative().nullable(),
|
||||||
impressions: z.number().int().nonnegative().nullable(),
|
});
|
||||||
|
|
||||||
|
const brandShareOfVoiceSchema = z.object({
|
||||||
|
// The platforms whose cross_aggregated call succeeded and are summed into
|
||||||
|
// the entries — so the UI can caption a single-platform leaderboard honestly
|
||||||
|
// when the other platform's call failed.
|
||||||
|
platforms: z.array(z.enum(["chat_gpt", "google"])),
|
||||||
|
entries: z.array(
|
||||||
|
z.object({
|
||||||
|
label: z.string().max(BRAND_LOOKUP_MAX_INPUT_LENGTH),
|
||||||
|
isTarget: z.boolean(),
|
||||||
|
mentions: z.number().int().nonnegative().nullable(),
|
||||||
|
sharePct: z.number().nullable(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const brandTopPageKeywordSchema = z.object({
|
||||||
|
question: z.string().max(500),
|
||||||
|
aiSearchVolume: z.number().int().nonnegative().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const brandTopPageSchema = z.object({
|
const brandTopPageSchema = z.object({
|
||||||
url: z.string(),
|
url: z.string().max(2048),
|
||||||
domain: z.string().nullable(),
|
domain: z.string().max(253).nullable(),
|
||||||
mentions: z.number().int().nonnegative().nullable(),
|
|
||||||
platform: z.enum(["chat_gpt", "google"]),
|
platform: z.enum(["chat_gpt", "google"]),
|
||||||
|
// Page-level citation mentions from DataForSEO top_pages.
|
||||||
|
mentions: z.number().int().nonnegative().nullable(),
|
||||||
|
// Page-level AI search volume from DataForSEO top_pages.
|
||||||
|
capturedVolume: z.number().int().nonnegative().nullable(),
|
||||||
|
// Example prompts from the fetched mentions sample that cited this page.
|
||||||
|
keywords: z.array(brandTopPageKeywordSchema).max(50),
|
||||||
});
|
});
|
||||||
|
|
||||||
const brandTopQuerySchema = z.object({
|
const brandTopQuerySchema = z.object({
|
||||||
question: z.string(),
|
question: z.string().max(500),
|
||||||
platform: z.enum(["chat_gpt", "google"]),
|
platform: z.enum(["chat_gpt", "google"]),
|
||||||
aiSearchVolume: z.number().int().nonnegative().nullable(),
|
aiSearchVolume: z.number().int().nonnegative().nullable(),
|
||||||
firstSeenAt: z.string().nullable(),
|
firstSeenAt: z.string().nullable(),
|
||||||
@ -55,13 +104,13 @@ const brandTopQuerySchema = z.object({
|
|||||||
citedSources: z
|
citedSources: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
url: z.string(),
|
url: z.string().max(2048),
|
||||||
domain: z.string().nullable(),
|
domain: z.string().max(253).nullable(),
|
||||||
title: z.string().nullable(),
|
title: z.string().max(300).nullable(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.max(10),
|
.max(10),
|
||||||
brandsMentioned: z.array(z.string()).max(20),
|
brandsMentioned: z.array(z.string().max(200)).max(20),
|
||||||
});
|
});
|
||||||
|
|
||||||
const brandMonthlyVolumeSchema = z.object({
|
const brandMonthlyVolumeSchema = z.object({
|
||||||
@ -78,9 +127,13 @@ export const brandLookupResultSchema = z.object({
|
|||||||
hasData: z.boolean(),
|
hasData: z.boolean(),
|
||||||
totalMentions: z.number().int().nonnegative().nullable(),
|
totalMentions: z.number().int().nonnegative().nullable(),
|
||||||
totalAiSearchVolume: z.number().int().nonnegative().nullable(),
|
totalAiSearchVolume: z.number().int().nonnegative().nullable(),
|
||||||
totalImpressions: z.number().int().nonnegative().nullable(),
|
|
||||||
perPlatform: z.array(brandPlatformBreakdownSchema),
|
perPlatform: z.array(brandPlatformBreakdownSchema),
|
||||||
topPages: z.array(brandTopPageSchema).max(20),
|
// Competitor Share of Voice — null when no competitors were supplied or both
|
||||||
|
// cross_aggregated calls failed. No legacy-cache shim: pre-SoV cache entries
|
||||||
|
// are unreachable anyway (the cache key's param set changed), see the
|
||||||
|
// buildCacheKey comment in brandLookup.ts.
|
||||||
|
shareOfVoice: brandShareOfVoiceSchema.nullable(),
|
||||||
|
topPages: z.array(brandTopPageSchema).max(40),
|
||||||
topQueries: z.array(brandTopQuerySchema).max(50),
|
topQueries: z.array(brandTopQuerySchema).max(50),
|
||||||
monthlyVolume: z.array(brandMonthlyVolumeSchema),
|
monthlyVolume: z.array(brandMonthlyVolumeSchema),
|
||||||
});
|
});
|
||||||
@ -205,9 +258,23 @@ export type PromptExplorerResult = z.infer<typeof promptExplorerResultSchema>;
|
|||||||
// URL search params
|
// URL search params
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** /p/$projectId/brand-lookup query params — `q` keeps the lookup shareable. */
|
/**
|
||||||
|
* /p/$projectId/brand-lookup query params. `q` keeps the lookup shareable; `c`
|
||||||
|
* is a comma-joined competitor list (route + page treat the parsed result as an
|
||||||
|
* opaque string array). `c` accepts a raw string (from the URL) OR an array
|
||||||
|
* (TanStack Router re-validates its own transformed output on navigate) — same
|
||||||
|
* union pattern as `models` below.
|
||||||
|
*/
|
||||||
export const brandLookupSearchSchema = z.object({
|
export const brandLookupSearchSchema = z.object({
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
|
c: z
|
||||||
|
.union([z.string(), z.array(z.string())])
|
||||||
|
.optional()
|
||||||
|
.transform((value) =>
|
||||||
|
value === undefined
|
||||||
|
? undefined
|
||||||
|
: parseCompetitorList(Array.isArray(value) ? value.join(",") : value),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user