diff --git a/src/client/features/saved-keywords/SavedKeywordsHeader.tsx b/src/client/features/saved-keywords/SavedKeywordsHeader.tsx index fff85a0..6ff5e74 100644 --- a/src/client/features/saved-keywords/SavedKeywordsHeader.tsx +++ b/src/client/features/saved-keywords/SavedKeywordsHeader.tsx @@ -1,15 +1,26 @@ -import { ChevronDown, Download, FileDown, Loader2, Sheet } from "lucide-react"; +import { + ChevronDown, + Download, + FileDown, + Loader2, + RefreshCw, + Sheet, +} from "lucide-react"; export function SavedKeywordsHeader({ totalCount, exporting, + metricsRefreshing, onExportCsv, onExportSheets, + onRefreshMetrics, }: { totalCount: number; exporting: "csv" | "sheets" | null; + metricsRefreshing: boolean; onExportCsv: () => void; onExportSheets: () => void; + onRefreshMetrics: () => void; }) { const disabled = totalCount === 0 || exporting != null; @@ -23,40 +34,83 @@ export function SavedKeywordsHeader({

-
- - +
+
+ +
    +
  • + +
  • +
+
+ +
+ +
    +
  • + +
  • +
  • + +
  • +
+
); diff --git a/src/routes/_project/p/$projectId/saved.tsx b/src/routes/_project/p/$projectId/saved.tsx index 5016aad..d7cceeb 100644 --- a/src/routes/_project/p/$projectId/saved.tsx +++ b/src/routes/_project/p/$projectId/saved.tsx @@ -35,6 +35,7 @@ import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { captureClientEvent } from "@/client/lib/posthog"; import { getSavedKeywords, + refreshSavedKeywordMetrics, removeSavedKeywords, updateSavedKeywordTags, } from "@/serverFunctions/keywords"; @@ -192,6 +193,21 @@ function SavedKeywordsPage() { }, }); + const refreshMetricsMutation = useMutation({ + mutationFn: () => refreshSavedKeywordMetrics({ data: { projectId } }), + onSuccess: (result) => { + void invalidateSavedKeywords(); + toast.success( + `Updated stats for ${result.updated} keyword${result.updated !== 1 ? "s" : ""}`, + ); + }, + onError: (error) => { + toast.error( + getStandardErrorMessage(error, "Could not update keyword stats."), + ); + }, + }); + const tagManage = useTagManage(projectId); const exporter = useSavedKeywordsExport({ projectId, @@ -227,8 +243,10 @@ function SavedKeywordsPage() { void exporter.exportFilteredCsv()} onExportSheets={() => void exporter.exportFilteredSheets()} + onRefreshMetrics={() => refreshMetricsMutation.mutate()} />
diff --git a/src/server/features/keywords/services/KeywordResearchService.ts b/src/server/features/keywords/services/KeywordResearchService.ts index 98c7488..6f57634 100644 --- a/src/server/features/keywords/services/KeywordResearchService.ts +++ b/src/server/features/keywords/services/KeywordResearchService.ts @@ -8,6 +8,7 @@ import { exportSavedKeywords, updateSavedKeywordTag, updateSavedKeywordTags, + refreshSavedKeywordMetrics, } from "@/server/features/keywords/services/research"; export const KeywordResearchService = { @@ -20,4 +21,5 @@ export const KeywordResearchService = { updateSavedKeywordTag, deleteSavedKeywordTag, removeSavedKeywords, + refreshSavedKeywordMetrics, } as const; diff --git a/src/server/features/keywords/services/research/index.ts b/src/server/features/keywords/services/research/index.ts index 8eb7e20..e777f00 100644 --- a/src/server/features/keywords/services/research/index.ts +++ b/src/server/features/keywords/services/research/index.ts @@ -9,3 +9,4 @@ export { deleteSavedKeywordTag, removeSavedKeywords, } from "./saved-keywords"; +export { refreshSavedKeywordMetrics } from "./refresh-metrics"; diff --git a/src/server/features/keywords/services/research/refresh-metrics.ts b/src/server/features/keywords/services/research/refresh-metrics.ts new file mode 100644 index 0000000..337f059 --- /dev/null +++ b/src/server/features/keywords/services/research/refresh-metrics.ts @@ -0,0 +1,145 @@ +import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository"; +import { normalizeIntent } from "@/server/features/keywords/services/research/helpers"; +import { createDataforseoClient } from "@/server/lib/dataforseo"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; +import type { KeywordIntent, MonthlySearch } from "@/types/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, + request: { keywords: string[]; locationCode: number; languageCode: string }, + useGoogleAds: boolean, +): Promise> { + const metricsMap = new Map(); + + 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( + input: RefreshSavedKeywordMetricsInput, + billingCustomer: BillingCustomerContext, +): Promise<{ updated: number }> { + const { rows } = await KeywordResearchRepository.listSavedKeywordsByProject({ + projectId: input.projectId, + }); + + if (rows.length === 0) return { updated: 0 }; + + const client = createDataforseoClient(billingCustomer); + let updated = 0; + + // Group by (locationCode, languageCode) so each DataForSEO call is homogeneous. + const groups = new Map(); + for (const row of rows) { + const key = `${row.row.locationCode}:${row.row.languageCode}`; + const group = groups.get(key) ?? []; + group.push(row); + groups.set(key, group); + } + + for (const groupRows of groups.values()) { + const { locationCode, languageCode } = groupRows[0].row; + const useGoogleAds = getKeywordDataProvider(locationCode) === "google_ads"; + + for (let i = 0; i < groupRows.length; i += REFRESH_BATCH_SIZE) { + const batch = groupRows.slice(i, i + REFRESH_BATCH_SIZE); + const keywords = batch.map((r) => r.row.keyword); + const request = { keywords, locationCode, languageCode }; + + const metricsMap = await fetchBatchMetrics(client, request, useGoogleAds); + + await Promise.all( + batch.map((r) => { + const metrics = metricsMap.get(r.row.keyword.toLowerCase()); + if (!metrics) return Promise.resolve(); + return KeywordResearchRepository.upsertKeywordMetric({ + projectId: input.projectId, + keyword: r.row.keyword, + locationCode, + languageCode, + searchVolume: metrics.searchVolume, + cpc: metrics.cpc, + competition: metrics.competition, + keywordDifficulty: metrics.keywordDifficulty, + intent: metrics.intent, + monthlySearchesJson: metrics.monthlySearchesJson, + }); + }), + ); + + updated += metricsMap.size; + } + } + + return { updated }; +} diff --git a/src/serverFunctions/keywords.ts b/src/serverFunctions/keywords.ts index 279c13d..619f101 100644 --- a/src/serverFunctions/keywords.ts +++ b/src/serverFunctions/keywords.ts @@ -6,6 +6,7 @@ import { getSavedKeywordsSchema, exportSavedKeywordsSchema, removeSavedKeywordsSchema, + refreshSavedKeywordMetricsSchema, serpAnalysisSchema, updateSavedKeywordTagSchema, updateSavedKeywordTagsSchema, @@ -108,6 +109,18 @@ export const removeSavedKeywords = createServerFn({ return KeywordResearchService.removeSavedKeywords(context.projectId, data); }); +export const refreshSavedKeywordMetrics = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => + refreshSavedKeywordMetricsSchema.parse(data), + ) + .handler(async ({ context }) => { + return KeywordResearchService.refreshSavedKeywordMetrics( + { projectId: context.projectId }, + context, + ); + }); + export const getSerpAnalysis = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => serpAnalysisSchema.parse(data)) diff --git a/src/types/schemas/keywords.ts b/src/types/schemas/keywords.ts index 639b532..303d377 100644 --- a/src/types/schemas/keywords.ts +++ b/src/types/schemas/keywords.ts @@ -143,6 +143,10 @@ export const deleteSavedKeywordTagSchema = z.object({ tagId: z.string().min(1), }); +export const refreshSavedKeywordMetricsSchema = z.object({ + projectId: z.string().min(1), +}); + export type ResearchKeywordsInput = z.infer; export type SaveKeywordsInput = z.infer; export type RemoveSavedKeywordsInput = z.infer< @@ -161,6 +165,10 @@ export type UpdateSavedKeywordTagInput = z.infer< export type DeleteSavedKeywordTagInput = z.infer< typeof deleteSavedKeywordTagSchema >; + +export type RefreshSavedKeywordMetricsInput = z.infer< + typeof refreshSavedKeywordMetricsSchema +>; export const serpAnalysisSchema = z.object({ projectId: z.string().min(1), keyword: z.string().min(1),