236 lines
8.1 KiB
TypeScript
236 lines
8.1 KiB
TypeScript
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.
|
|
//
|
|
// `locationName` (canonical DataForSEO string, e.g. a city) scopes volume /
|
|
// CPC / competition to that location via Google Ads — the only source that
|
|
// accepts sub-country geotargets. Labs is country-only, so for Labs countries
|
|
// a local request runs both calls and merges: local volume from Google Ads,
|
|
// national KD / intent from Labs. National volume is never silently shown for
|
|
// a local request — keywords Google Ads doesn't return keep KD / intent but
|
|
// null volume / CPC.
|
|
export async function fetchKeywordMetricsForList(
|
|
client: KeywordMetricsClient,
|
|
params: {
|
|
keywords: string[];
|
|
locationCode: number;
|
|
languageCode: string;
|
|
creditFeature: CreditFeature;
|
|
includeClickstreamData?: boolean;
|
|
locationName?: string;
|
|
},
|
|
): 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,
|
|
locationName: params.locationName,
|
|
languageCode: params.languageCode,
|
|
creditFeature: params.creditFeature,
|
|
});
|
|
const covered = new Set<string>();
|
|
for (const item of items) {
|
|
if (!item.keyword) continue;
|
|
covered.add(item.keyword.toLowerCase());
|
|
rows.push(normalizeAdsKeyword(item, item.keyword));
|
|
}
|
|
if (params.locationName) {
|
|
// A local request must overwrite whatever scope the stored metrics
|
|
// had — keywords Ads collapsed away get explicit nulls so stale
|
|
// (possibly national) numbers can't survive under a local label.
|
|
rows.push(
|
|
...keywords
|
|
.filter((keyword) => !covered.has(keyword.toLowerCase()))
|
|
.map(nullMetricRow),
|
|
);
|
|
}
|
|
} else if (params.locationName) {
|
|
const [adsItems, labsItems] = await Promise.all([
|
|
client.keywords.adsSearchVolume({
|
|
keywords,
|
|
locationCode: params.locationCode,
|
|
locationName: params.locationName,
|
|
languageCode: params.languageCode,
|
|
creditFeature: params.creditFeature,
|
|
}),
|
|
client.labs.keywordOverview({
|
|
keywords,
|
|
locationCode: params.locationCode,
|
|
languageCode: params.languageCode,
|
|
includeClickstreamData: params.includeClickstreamData ?? false,
|
|
creditFeature: params.creditFeature,
|
|
}),
|
|
]);
|
|
rows.push(...mergeLocalAndNationalRows(keywords, adsItems, labsItems));
|
|
} 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;
|
|
}
|
|
|
|
function nullMetricRow(keyword: string): KeywordMetricRow {
|
|
return {
|
|
keyword,
|
|
searchVolume: null,
|
|
cpc: null,
|
|
competition: null,
|
|
competitionLevel: null,
|
|
keywordDifficulty: null,
|
|
intent: null,
|
|
monthlySearches: [],
|
|
};
|
|
}
|
|
|
|
function mergeLocalAndNationalRows(
|
|
keywords: string[],
|
|
adsItems: AdsKeywordItem[],
|
|
labsItems: KeywordOverviewItem[],
|
|
): KeywordMetricRow[] {
|
|
const labsByKeyword = new Map(
|
|
labsItems
|
|
.filter((item) => item.keyword)
|
|
.map((item) => [item.keyword!.toLowerCase(), item]),
|
|
);
|
|
const rows: KeywordMetricRow[] = [];
|
|
const covered = new Set<string>();
|
|
|
|
for (const item of adsItems) {
|
|
if (!item.keyword) continue;
|
|
covered.add(item.keyword.toLowerCase());
|
|
const row = normalizeAdsKeyword(item, item.keyword);
|
|
const labs = labsByKeyword.get(item.keyword.toLowerCase());
|
|
row.keywordDifficulty =
|
|
labs?.keyword_properties?.keyword_difficulty ?? null;
|
|
row.intent = labs?.search_intent_info?.main_intent ?? null;
|
|
rows.push(row);
|
|
}
|
|
|
|
// Google Ads occasionally collapses near-duplicate keywords into one item.
|
|
// Keep the national KD / intent for the missing ones but leave volume / CPC
|
|
// null rather than substituting the (misleading) national numbers.
|
|
for (const keyword of keywords) {
|
|
if (covered.has(keyword.toLowerCase())) continue;
|
|
const labs = labsByKeyword.get(keyword.toLowerCase());
|
|
if (!labs) continue;
|
|
rows.push({
|
|
...nullMetricRow(keyword),
|
|
keywordDifficulty: labs.keyword_properties?.keyword_difficulty ?? null,
|
|
intent: labs.search_intent_info?.main_intent ?? null,
|
|
});
|
|
}
|
|
|
|
return rows;
|
|
}
|