refactor(keywords): unify keyword-metric fetching behind one helper (#320)

This commit is contained in:
Ben Senescu 2026-06-30 17:23:27 -04:00 committed by Ben Senescu
parent 116719a019
commit 868924e1fe
8 changed files with 396 additions and 250 deletions

View File

@ -1,89 +1,11 @@
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository"; import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { normalizeIntent } from "@/server/features/keywords/services/research/helpers"; import { normalizeIntent } from "@/server/features/keywords/services/research/helpers";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import {
createDataforseoClient,
fetchKeywordMetricsForList,
} from "@/server/lib/dataforseo";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import type { KeywordIntent, MonthlySearch } from "@/types/keywords";
import type { RefreshSavedKeywordMetricsInput } from "@/types/schemas/keywords"; import type { RefreshSavedKeywordMetricsInput } from "@/types/schemas/keywords";
import { getKeywordDataProvider } from "@/shared/keyword-locations";
const REFRESH_BATCH_SIZE = 700;
// Match the shape the research/save flow persists so a refresh never degrades
// stored metrics (see research-data.ts mapKeywordDataItems / mapAdsKeywordItems).
function toMonthlySearchesJson(
entries:
| {
year?: number | null;
month?: number | null;
search_volume?: number | null;
}[]
| null
| undefined,
): string {
const trend: MonthlySearch[] = (entries ?? []).map((entry) => ({
year: entry.year ?? 0,
month: entry.month ?? 0,
searchVolume: entry.search_volume ?? 0,
}));
return JSON.stringify(trend);
}
type RefreshedMetric = {
searchVolume: number | null;
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: KeywordIntent;
monthlySearchesJson: string;
};
// Fetch fresh metrics for one homogeneous batch and key them by lowercase
// keyword. Labs covers most countries; the rest fall back to Google Ads, which
// carries volume/CPC/competition but no difficulty or intent.
async function fetchBatchMetrics(
client: ReturnType<typeof createDataforseoClient>,
request: { keywords: string[]; locationCode: number; languageCode: string },
useGoogleAds: boolean,
): Promise<Map<string, RefreshedMetric>> {
const metricsMap = new Map<string, RefreshedMetric>();
if (useGoogleAds) {
const items = await client.keywords.adsSearchVolume({
...request,
creditFeature: "keyword_research",
});
for (const item of items) {
if (!item.keyword) continue;
metricsMap.set(item.keyword.toLowerCase(), {
searchVolume: item.search_volume ?? null,
cpc: item.cpc ?? null,
// competition_index is 0-100; the rest of the app stores a 0-1 ratio.
competition:
item.competition_index != null ? item.competition_index / 100 : null,
keywordDifficulty: null,
intent: "unknown",
monthlySearchesJson: toMonthlySearchesJson(item.monthly_searches),
});
}
return metricsMap;
}
const items = await client.labs.keywordOverview(request);
for (const item of items) {
if (!item.keyword) continue;
metricsMap.set(item.keyword.toLowerCase(), {
searchVolume: item.keyword_info?.search_volume ?? null,
cpc: item.keyword_info?.cpc ?? null,
competition: item.keyword_info?.competition ?? null,
keywordDifficulty: item.keyword_properties?.keyword_difficulty ?? null,
intent: normalizeIntent(item.search_intent_info?.main_intent),
monthlySearchesJson: toMonthlySearchesJson(
item.keyword_info?.monthly_searches,
),
});
}
return metricsMap;
}
export async function refreshSavedKeywordMetrics( export async function refreshSavedKeywordMetrics(
input: RefreshSavedKeywordMetricsInput, input: RefreshSavedKeywordMetricsInput,
@ -98,7 +20,7 @@ export async function refreshSavedKeywordMetrics(
const client = createDataforseoClient(billingCustomer); const client = createDataforseoClient(billingCustomer);
let updated = 0; let updated = 0;
// Group by (locationCode, languageCode) so each DataForSEO call is homogeneous. // Group by (locationCode, languageCode) so each provider call is homogeneous.
const groups = new Map<string, typeof rows>(); const groups = new Map<string, typeof rows>();
for (const row of rows) { for (const row of rows) {
const key = `${row.row.locationCode}:${row.row.languageCode}`; const key = `${row.row.locationCode}:${row.row.languageCode}`;
@ -109,36 +31,36 @@ export async function refreshSavedKeywordMetrics(
for (const groupRows of groups.values()) { for (const groupRows of groups.values()) {
const { locationCode, languageCode } = groupRows[0].row; const { locationCode, languageCode } = groupRows[0].row;
const useGoogleAds = getKeywordDataProvider(locationCode) === "google_ads"; const metrics = await fetchKeywordMetricsForList(client, {
keywords: groupRows.map((r) => r.row.keyword),
for (let i = 0; i < groupRows.length; i += REFRESH_BATCH_SIZE) { locationCode,
const batch = groupRows.slice(i, i + REFRESH_BATCH_SIZE); languageCode,
const keywords = batch.map((r) => r.row.keyword); creditFeature: "keyword_research",
const request = { keywords, locationCode, languageCode }; });
const byKeyword = new Map(
const metricsMap = await fetchBatchMetrics(client, request, useGoogleAds); metrics.map((metric) => [metric.keyword.toLowerCase(), metric]),
);
await Promise.all( await Promise.all(
batch.map((r) => { groupRows.map((r) => {
const metrics = metricsMap.get(r.row.keyword.toLowerCase()); const metric = byKeyword.get(r.row.keyword.toLowerCase());
if (!metrics) return Promise.resolve(); if (!metric) return Promise.resolve();
return KeywordResearchRepository.upsertKeywordMetric({ return KeywordResearchRepository.upsertKeywordMetric({
projectId: input.projectId, projectId: input.projectId,
keyword: r.row.keyword, keyword: r.row.keyword,
locationCode, locationCode,
languageCode, languageCode,
searchVolume: metrics.searchVolume, searchVolume: metric.searchVolume,
cpc: metrics.cpc, cpc: metric.cpc,
competition: metrics.competition, competition: metric.competition,
keywordDifficulty: metrics.keywordDifficulty, keywordDifficulty: metric.keywordDifficulty,
intent: metrics.intent, intent: normalizeIntent(metric.intent),
monthlySearchesJson: metrics.monthlySearchesJson, monthlySearchesJson: JSON.stringify(metric.monthlySearches),
}); });
}), }),
); );
updated += metricsMap.size; updated += byKeyword.size;
}
} }
return { updated }; return { updated };

View File

@ -1,7 +1,9 @@
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import {
import { getKeywordDataProvider } from "@/shared/keyword-locations"; createDataforseoClient,
fetchKeywordMetricsForList,
} from "@/server/lib/dataforseo";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import type { import type {
@ -260,8 +262,6 @@ async function getLatestRun(configId: string, projectId: string) {
// Keyword metrics (volume, difficulty, CPC) // Keyword metrics (volume, difficulty, CPC)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const KEYWORD_OVERVIEW_BATCH_SIZE = 700;
async function refreshKeywordMetrics( async function refreshKeywordMetrics(
configId: string, configId: string,
projectId: string, projectId: string,
@ -272,76 +272,35 @@ async function refreshKeywordMetrics(
if (keywords.length === 0) return { updated: 0 }; if (keywords.length === 0) return { updated: 0 };
const client = createDataforseoClient(billingCustomer); const client = createDataforseoClient(billingCustomer);
const now = new Date().toISOString(); const metrics = await fetchKeywordMetricsForList(client, {
let updated = 0; keywords: keywords.map((kw) => kw.keyword),
// Countries Labs doesn't cover get volume/CPC from Google Ads (no KD).
const useGoogleAds =
getKeywordDataProvider(config.locationCode) === "google_ads";
for (let i = 0; i < keywords.length; i += KEYWORD_OVERVIEW_BATCH_SIZE) {
const batch = keywords.slice(i, i + KEYWORD_OVERVIEW_BATCH_SIZE);
const request = {
keywords: batch.map((kw) => kw.keyword),
locationCode: config.locationCode, locationCode: config.locationCode,
languageCode: config.languageCode, languageCode: config.languageCode,
};
// Build a lookup by lowercase keyword
const metricsMap = new Map<
string,
{
searchVolume: number | null;
keywordDifficulty: number | null;
cpc: number | null;
}
>();
if (useGoogleAds) {
const adsItems = await client.keywords.adsSearchVolume({
...request,
creditFeature: "rank_tracking", creditFeature: "rank_tracking",
}); });
for (const item of adsItems) { const byKeyword = new Map(
if (!item.keyword) continue; metrics.map((metric) => [metric.keyword.toLowerCase(), metric]),
metricsMap.set(item.keyword.toLowerCase(), { );
searchVolume: item.search_volume ?? null,
keywordDifficulty: null,
cpc: item.cpc ?? null,
});
}
} else {
for (const item of await client.labs.keywordOverview(request)) {
if (!item.keyword) continue;
metricsMap.set(item.keyword.toLowerCase(), {
searchVolume: item.keyword_info?.search_volume ?? null,
keywordDifficulty:
item.keyword_properties?.keyword_difficulty ?? null,
cpc: item.keyword_info?.cpc ?? null,
});
}
}
const updates = batch const now = new Date().toISOString();
const updates = keywords
.map((kw) => { .map((kw) => {
const metrics = metricsMap.get(kw.keyword.toLowerCase()); const metric = byKeyword.get(kw.keyword.toLowerCase());
if (!metrics) return null; if (!metric) return null;
// Rank tracking only tracks volume / difficulty / CPC.
return { return {
id: kw.id, id: kw.id,
searchVolume: metrics.searchVolume, searchVolume: metric.searchVolume,
keywordDifficulty: metrics.keywordDifficulty, keywordDifficulty: metric.keywordDifficulty,
cpc: metrics.cpc, cpc: metric.cpc,
metricsFetchedAt: now, metricsFetchedAt: now,
}; };
}) })
.filter((u): u is NonNullable<typeof u> => u !== null); .filter((u): u is NonNullable<typeof u> => u !== null);
if (updates.length > 0) { if (updates.length === 0) return { updated: 0 };
await RankTrackingRepository.updateKeywordMetrics(updates); await RankTrackingRepository.updateKeywordMetrics(updates);
updated += updates.length; return { updated: updates.length };
}
}
return { updated };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -5,17 +5,18 @@
export { createDataforseoClient } from "@/server/lib/dataforseo/client"; export { createDataforseoClient } from "@/server/lib/dataforseo/client";
export {
fetchKeywordMetricsForList,
type KeywordMetricRow,
} from "@/server/lib/dataforseo/keyword-metrics";
export { export {
type LabsKeywordDataItem, type LabsKeywordDataItem,
type DomainRankedKeywordItem, type DomainRankedKeywordItem,
type RelevantPagesItem, type RelevantPagesItem,
type KeywordOverviewItem,
} from "@/server/lib/dataforseo/labs"; } from "@/server/lib/dataforseo/labs";
export { export { type AdsKeywordIdeaItem } from "@/server/lib/dataforseo/google-ads";
type AdsKeywordItem,
type AdsKeywordIdeaItem,
} from "@/server/lib/dataforseo/google-ads";
export { export {
fetchRankCheckTaskResult, fetchRankCheckTaskResult,

View File

@ -0,0 +1,132 @@
import { describe, expect, it, vi } from "vitest";
import { fetchKeywordMetricsForList } from "./keyword-metrics";
type Client = Parameters<typeof fetchKeywordMetricsForList>[0];
// Minimal fake client exposing only the two endpoints the helper touches.
function fakeClient(overrides: {
keywordOverview?: Client["labs"]["keywordOverview"];
adsSearchVolume?: Client["keywords"]["adsSearchVolume"];
}): Client {
return {
labs: { keywordOverview: overrides.keywordOverview ?? vi.fn() },
keywords: { adsSearchVolume: overrides.adsSearchVolume ?? vi.fn() },
};
}
describe("fetchKeywordMetricsForList", () => {
it("normalizes Labs items and prefers clickstream-refined volume/trend", async () => {
const keywordOverview = vi.fn().mockResolvedValue([
{
keyword: "best crm",
keyword_info: {
search_volume: 1000,
cpc: 3.2,
competition: 0.5,
competition_level: "MEDIUM",
monthly_searches: [{ year: 2026, month: 1, search_volume: 1000 }],
},
keyword_info_normalized_with_clickstream: {
search_volume: 880,
monthly_searches: [{ year: 2026, month: 1, search_volume: 880 }],
},
keyword_properties: { keyword_difficulty: 42 },
search_intent_info: { main_intent: "commercial" },
},
]);
const client = fakeClient({ keywordOverview });
const rows = await fetchKeywordMetricsForList(client, {
keywords: ["best crm"],
locationCode: 2840,
languageCode: "en",
includeClickstreamData: true,
creditFeature: "keyword_research",
});
expect(keywordOverview).toHaveBeenCalledWith({
keywords: ["best crm"],
locationCode: 2840,
languageCode: "en",
includeClickstreamData: true,
creditFeature: "keyword_research",
});
expect(rows).toEqual([
{
keyword: "best crm",
searchVolume: 880,
cpc: 3.2,
competition: 0.5,
competitionLevel: "MEDIUM",
keywordDifficulty: 42,
intent: "commercial",
monthlySearches: [{ year: 2026, month: 1, searchVolume: 880 }],
},
]);
});
it("routes Google Ads locations and scales competition_index to a 0-1 ratio", async () => {
const adsSearchVolume = vi.fn().mockResolvedValue([
{
keyword: "hotel reykjavik",
search_volume: 1300,
cpc: 2.54,
competition: "HIGH",
competition_index: 42,
monthly_searches: [{ year: 2026, month: 5, search_volume: 1300 }],
},
{ keyword: undefined },
]);
const client = fakeClient({ adsSearchVolume });
const rows = await fetchKeywordMetricsForList(client, {
keywords: ["hotel reykjavik"],
locationCode: 2352,
languageCode: "is",
creditFeature: "rank_tracking",
});
expect(adsSearchVolume).toHaveBeenCalledWith({
keywords: ["hotel reykjavik"],
locationCode: 2352,
languageCode: "is",
creditFeature: "rank_tracking",
});
// Item without a keyword is dropped; Ads carries no KD/intent.
expect(rows).toEqual([
{
keyword: "hotel reykjavik",
searchVolume: 1300,
cpc: 2.54,
competition: 0.42,
competitionLevel: "HIGH",
keywordDifficulty: null,
intent: null,
monthlySearches: [{ year: 2026, month: 5, searchVolume: 1300 }],
},
]);
});
it("batches keyword lists above the per-call cap", async () => {
const keywordOverview = vi
.fn()
.mockImplementation(({ keywords }: { keywords: string[] }) =>
Promise.resolve(
keywords.map((keyword) => ({ keyword, keyword_info: {} })),
),
);
const client = fakeClient({ keywordOverview });
const keywords = Array.from({ length: 1500 }, (_, i) => `kw-${i}`);
const rows = await fetchKeywordMetricsForList(client, {
keywords,
locationCode: 2840,
languageCode: "en",
creditFeature: "keyword_research",
});
expect(keywordOverview).toHaveBeenCalledTimes(3); // 700 + 700 + 100
expect(rows).toHaveLength(1500);
});
});

View File

@ -0,0 +1,141 @@
import type { createDataforseoClient } from "@/server/lib/dataforseo/client";
import type { AdsKeywordItem } from "@/server/lib/dataforseo/google-ads";
import type { KeywordOverviewItem } from "@/server/lib/dataforseo/labs";
import type { CreditFeature } from "@/shared/billing-credit-features";
import { getKeywordDataProvider } from "@/shared/keyword-locations";
import type { MonthlySearch } from "@/types/keywords";
type DataforseoClient = ReturnType<typeof createDataforseoClient>;
// Narrowed to the two endpoints the helper uses, so tests can fake it cheaply.
type KeywordMetricsClient = {
labs: Pick<DataforseoClient["labs"], "keywordOverview">;
keywords: Pick<DataforseoClient["keywords"], "adsSearchVolume">;
};
// DataForSEO's batch metric endpoints accept up to ~700 keywords per request.
const KEYWORD_METRICS_BATCH_SIZE = 700;
// `intent` is the raw `main_intent` (null for Google Ads); run it through
// `normalizeIntent` for the app enum. `competition` is a 0-1 ratio.
export type KeywordMetricRow = {
keyword: string;
searchVolume: number | null;
cpc: number | null;
competition: number | null;
competitionLevel: string | null;
keywordDifficulty: number | null;
intent: string | null;
monthlySearches: MonthlySearch[];
};
function toMonthlySearches(
entries:
| {
year?: number | null;
month?: number | null;
search_volume?: number | null;
}[]
| null
| undefined,
): MonthlySearch[] {
return (entries ?? []).map((entry) => ({
year: entry.year ?? 0,
month: entry.month ?? 0,
searchVolume: entry.search_volume ?? 0,
}));
}
function normalizeKeywordOverview(
item: KeywordOverviewItem,
keyword: string,
): KeywordMetricRow {
const info = item.keyword_info;
// The clickstream-normalized block only exists when the caller opted into
// clickstream data (it doubles request cost); prefer it when present.
const clickstreamInfo = item.keyword_info_normalized_with_clickstream;
const usesClickstream = clickstreamInfo?.search_volume != null;
return {
keyword,
searchVolume: clickstreamInfo?.search_volume ?? info?.search_volume ?? null,
cpc: info?.cpc ?? null,
competition: info?.competition ?? null,
competitionLevel: info?.competition_level ?? null,
keywordDifficulty: item.keyword_properties?.keyword_difficulty ?? null,
intent: item.search_intent_info?.main_intent ?? null,
monthlySearches: toMonthlySearches(
usesClickstream
? clickstreamInfo?.monthly_searches
: info?.monthly_searches,
),
};
}
// Google Ads items (countries Labs doesn't cover) carry volume/CPC/competition
// but no keyword difficulty or search intent.
function normalizeAdsKeyword(
item: AdsKeywordItem,
keyword: string,
): KeywordMetricRow {
return {
keyword,
searchVolume: item.search_volume ?? null,
cpc: item.cpc ?? null,
// competition_index is a 0-100 scale; the app stores a 0-1 ratio.
competition:
item.competition_index != null ? item.competition_index / 100 : null,
competitionLevel: item.competition ?? null,
keywordDifficulty: null,
intent: null,
monthlySearches: toMonthlySearches(item.monthly_searches),
};
}
// Hydrate a keyword list with fresh metrics: route by location (Labs vs Google
// Ads), batch under the per-call cap, and drop items DataForSEO returns without
// a keyword. `creditFeature` is required so spend is always attributed.
export async function fetchKeywordMetricsForList(
client: KeywordMetricsClient,
params: {
keywords: string[];
locationCode: number;
languageCode: string;
creditFeature: CreditFeature;
includeClickstreamData?: boolean;
},
): Promise<KeywordMetricRow[]> {
const useGoogleAds =
getKeywordDataProvider(params.locationCode) === "google_ads";
const rows: KeywordMetricRow[] = [];
for (let i = 0; i < params.keywords.length; i += KEYWORD_METRICS_BATCH_SIZE) {
const keywords = params.keywords.slice(i, i + KEYWORD_METRICS_BATCH_SIZE);
if (useGoogleAds) {
const items = await client.keywords.adsSearchVolume({
keywords,
locationCode: params.locationCode,
languageCode: params.languageCode,
creditFeature: params.creditFeature,
});
for (const item of items) {
if (!item.keyword) continue;
rows.push(normalizeAdsKeyword(item, item.keyword));
}
} else {
const items = await client.labs.keywordOverview({
keywords,
locationCode: params.locationCode,
languageCode: params.languageCode,
includeClickstreamData: params.includeClickstreamData ?? false,
creditFeature: params.creditFeature,
});
for (const item of items) {
if (!item.keyword) continue;
rows.push(normalizeKeywordOverview(item, item.keyword));
}
}
}
return rows;
}

View File

@ -3,6 +3,7 @@ import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(), createDataforseoClient: vi.fn(),
@ -13,9 +14,17 @@ vi.mock("cloudflare:workers", () => ({
env: {}, env: {},
})); }));
vi.mock("@/server/lib/dataforseo", () => ({ // Keep the real fetchKeywordMetricsForList (it only routes provider calls onto
// the supplied client) so the handler's normalization is exercised end-to-end.
vi.mock("@/server/lib/dataforseo", async () => {
const keywordMetrics = await vi.importActual<{
fetchKeywordMetricsForList: typeof FetchKeywordMetricsForList;
}>("@/server/lib/dataforseo/keyword-metrics");
return {
createDataforseoClient: mocks.createDataforseoClient, createDataforseoClient: mocks.createDataforseoClient,
})); fetchKeywordMetricsForList: keywordMetrics.fetchKeywordMetricsForList,
};
});
vi.mock("@/server/features/projects/services/ProjectService", () => ({ vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: { ProjectService: {

View File

@ -3,6 +3,7 @@ import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(), createDataforseoClient: vi.fn(),
@ -13,9 +14,17 @@ vi.mock("cloudflare:workers", () => ({
env: {}, env: {},
})); }));
vi.mock("@/server/lib/dataforseo", () => ({ // Keep the real fetchKeywordMetricsForList (it only routes provider calls onto
// the supplied client) so the handler's normalization is exercised end-to-end.
vi.mock("@/server/lib/dataforseo", async () => {
const keywordMetrics = await vi.importActual<{
fetchKeywordMetricsForList: typeof FetchKeywordMetricsForList;
}>("@/server/lib/dataforseo/keyword-metrics");
return {
createDataforseoClient: mocks.createDataforseoClient, createDataforseoClient: mocks.createDataforseoClient,
})); fetchKeywordMetricsForList: keywordMetrics.fetchKeywordMetricsForList,
};
});
vi.mock("@/server/features/projects/services/ProjectService", () => ({ vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: { ProjectService: {

View File

@ -2,10 +2,9 @@
import { z } from "zod"; import { z } from "zod";
import { import {
createDataforseoClient, createDataforseoClient,
type AdsKeywordItem, fetchKeywordMetricsForList,
type KeywordOverviewItem, type KeywordMetricRow,
} from "@/server/lib/dataforseo"; } from "@/server/lib/dataforseo";
import { getKeywordDataProvider } from "@/shared/keyword-locations";
import { buildProjectMeta } from "@/server/mcp/context"; import { buildProjectMeta } from "@/server/mcp/context";
import { mcpResponse } from "@/server/mcp/formatters"; import { mcpResponse } from "@/server/mcp/formatters";
import { import {
@ -439,45 +438,31 @@ function sortCompetitors(
}); });
} }
function normalizeKeywordOverview(item: KeywordOverviewItem) { // Project the shared canonical metric row onto this tool's snake_case API
const info = item.keyword_info; // shape (kept stable for MCP clients); an absent trend stays null as before.
// Only present when the caller opted into clickstream-refined volumes. function toMcpKeywordMetricRow(row: KeywordMetricRow) {
const clickstreamInfo = item.keyword_info_normalized_with_clickstream;
return { return {
keyword: item.keyword, keyword: row.keyword,
search_volume: search_volume: row.searchVolume,
clickstreamInfo?.search_volume ?? info?.search_volume ?? null, keyword_difficulty: row.keywordDifficulty,
keyword_difficulty: item.keyword_properties?.keyword_difficulty ?? null, main_intent: row.intent,
main_intent: item.search_intent_info?.main_intent ?? null, cpc: row.cpc,
cpc: info?.cpc ?? null, competition: row.competition,
competition: info?.competition ?? null, competition_level: row.competitionLevel,
competition_level: info?.competition_level ?? null, monthly_searches: row.monthlySearches.length
monthly_searches: ? row.monthlySearches.map((entry) => ({
(clickstreamInfo?.search_volume year: entry.year,
? clickstreamInfo.monthly_searches month: entry.month,
: info?.monthly_searches) ?? null, search_volume: entry.searchVolume,
}))
: null,
}; };
} }
type KeywordMetricRow = ReturnType<typeof normalizeKeywordOverview>; type McpKeywordMetricRow = ReturnType<typeof toMcpKeywordMetricRow>;
// Google Ads items (countries Labs doesn't cover) have no difficulty/intent.
function normalizeAdsKeyword(item: AdsKeywordItem): KeywordMetricRow {
return {
keyword: item.keyword,
search_volume: item.search_volume ?? null,
keyword_difficulty: null,
main_intent: null,
cpc: item.cpc ?? null,
competition:
item.competition_index != null ? item.competition_index / 100 : null,
competition_level: item.competition ?? null,
monthly_searches: item.monthly_searches ?? null,
};
}
function sortKeywordMetricRows( function sortKeywordMetricRows(
rows: KeywordMetricRow[], rows: McpKeywordMetricRow[],
sortBy: NonNullable<GetKeywordMetricsArgs["sortBy"]> = "search_volume", sortBy: NonNullable<GetKeywordMetricsArgs["sortBy"]> = "search_volume",
) { ) {
return rows.toSorted((a, b) => { return rows.toSorted((a, b) => {
@ -846,27 +831,15 @@ export const getKeywordMetricsTool = {
const client = createDataforseoClient(context.billing); const client = createDataforseoClient(context.billing);
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE; const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE; const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
const normalized = const metrics = await fetchKeywordMetricsForList(client, {
getKeywordDataProvider(locationCode) === "google_ads"
? (
await client.keywords.adsSearchVolume({
keywords: args.keywords,
locationCode,
languageCode,
creditFeature: "keyword_research",
})
).map(normalizeAdsKeyword)
: (
await client.labs.keywordOverview({
keywords: args.keywords, keywords: args.keywords,
locationCode, locationCode,
languageCode, languageCode,
includeClickstreamData: args.includeClickstreamData ?? false, includeClickstreamData: args.includeClickstreamData ?? false,
creditFeature: "keyword_research", creditFeature: "keyword_research",
}) });
).map(normalizeKeywordOverview);
const rows = sortKeywordMetricRows( const rows = sortKeywordMetricRows(
normalized, metrics.map(toMcpKeywordMetricRow),
args.sortBy ?? "search_volume", args.sortBy ?? "search_volume",
).map((row) => ).map((row) =>
args.includeMonthlyTrends === false args.includeMonthlyTrends === false