feat: add keyword metrics (volume, difficulty, CPC) to rank tracking (#118)

This commit is contained in:
Ben Senescu 2026-04-16 10:41:31 -04:00 committed by GitHub
parent 2b7a3eae05
commit 9a9a9a2a6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 2427 additions and 2 deletions

View File

@ -0,0 +1,4 @@
ALTER TABLE `rank_tracking_keywords` ADD `search_volume` integer;--> statement-breakpoint
ALTER TABLE `rank_tracking_keywords` ADD `keyword_difficulty` integer;--> statement-breakpoint
ALTER TABLE `rank_tracking_keywords` ADD `cpc` real;--> statement-breakpoint
ALTER TABLE `rank_tracking_keywords` ADD `metrics_fetched_at` text;

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,13 @@
"when": 1776288697036, "when": 1776288697036,
"tag": "0008_luxuriant_colossus", "tag": "0008_luxuriant_colossus",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1776347182067,
"tag": "0009_smart_kitty_pryde",
"breakpoints": true
} }
] ]
} }

View File

@ -1,18 +1,22 @@
import { useState } from "react"; import { useState } from "react";
import { MoreHorizontal, Play, Download, Copy } from "lucide-react"; import { MoreHorizontal, Play, Download, Copy, RefreshCw } from "lucide-react";
export function ActionsMenu({ export function ActionsMenu({
onCheckNow, onCheckNow,
onExport, onExport,
onCopyKeywords, onCopyKeywords,
onRefreshMetrics,
isRunning, isRunning,
metricsRefreshing,
hasData, hasData,
checkDisabled, checkDisabled,
}: { }: {
onCheckNow: () => void; onCheckNow: () => void;
onExport: () => void; onExport: () => void;
onCopyKeywords: () => void; onCopyKeywords: () => void;
onRefreshMetrics: () => void;
isRunning: boolean; isRunning: boolean;
metricsRefreshing: boolean;
hasData: boolean; hasData: boolean;
checkDisabled?: boolean; checkDisabled?: boolean;
}) { }) {
@ -42,6 +46,19 @@ export function ActionsMenu({
{isRunning ? "Running..." : "Check Now"} {isRunning ? "Running..." : "Check Now"}
</button> </button>
)} )}
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => {
onRefreshMetrics();
setOpen(false);
}}
disabled={metricsRefreshing || !hasData}
>
<RefreshCw
className={`size-3.5 ${metricsRefreshing ? "animate-spin" : ""}`}
/>
{metricsRefreshing ? "Refreshing..." : "Refresh Metrics"}
</button>
<button <button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200" className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => { onClick={() => {

View File

@ -4,13 +4,19 @@ import type { ColumnDef, SortingFn } from "@tanstack/react-table";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import { import {
comparePositions, comparePositions,
CpcCell,
DeviceRankCell, DeviceRankCell,
DeviceUrlCell, DeviceUrlCell,
DifficultyCell,
SerpFeatureTags, SerpFeatureTags,
VolumeCell,
} from "./RankTrackingTableParts"; } from "./RankTrackingTableParts";
const HEADER_TOOLTIPS: Record<string, string> = { const HEADER_TOOLTIPS: Record<string, string> = {
keyword: "The search term being tracked in Google", keyword: "The search term being tracked in Google",
volume: "Estimated monthly search volume from Google",
kd: "Keyword difficulty score (0-100) — higher means harder to rank",
cpc: "Average cost per click in Google Ads (USD)",
desktopPosition: desktopPosition:
"Current Google ranking position, showing change from the comparison period", "Current Google ranking position, showing change from the comparison period",
mobilePosition: mobilePosition:
@ -53,6 +59,46 @@ export function SortableHeader({
); );
} }
const nullsLastNumeric: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
const a = rowA.getValue<number | null>(columnId);
const b = rowB.getValue<number | null>(columnId);
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return a - b;
};
const volumeColumn: ColumnDef<RankTrackingRow> = {
id: "volume",
accessorKey: "searchVolume",
header: ({ column }) => (
<SortableHeader column={column} label="Volume" id="volume" />
),
size: 90,
cell: ({ getValue }) => <VolumeCell value={getValue<number | null>()} />,
sortingFn: nullsLastNumeric,
};
const kdColumn: ColumnDef<RankTrackingRow> = {
id: "kd",
accessorKey: "keywordDifficulty",
header: ({ column }) => <SortableHeader column={column} label="KD" id="kd" />,
size: 70,
cell: ({ getValue }) => <DifficultyCell value={getValue<number | null>()} />,
sortingFn: nullsLastNumeric,
};
const cpcColumn: ColumnDef<RankTrackingRow> = {
id: "cpc",
accessorKey: "cpc",
header: ({ column }) => (
<SortableHeader column={column} label="CPC" id="cpc" />
),
size: 80,
cell: ({ getValue }) => <CpcCell value={getValue<number | null>()} />,
sortingFn: nullsLastNumeric,
};
const positionSort: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => { const positionSort: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
const device = columnId === "desktopPosition" ? "desktop" : "mobile"; const device = columnId === "desktopPosition" ? "desktop" : "mobile";
return comparePositions( return comparePositions(
@ -166,11 +212,16 @@ export function useRankTrackingColumns(
if (showDesktop) { if (showDesktop) {
cols.push(makeDeviceColumn("desktop")); cols.push(makeDeviceColumn("desktop"));
cols.push(makeUrlColumn("desktop", domain)); cols.push(makeUrlColumn("desktop", domain));
cols.push(makeSerpColumn("desktop"));
} }
if (showMobile) { if (showMobile) {
cols.push(makeDeviceColumn("mobile")); cols.push(makeDeviceColumn("mobile"));
cols.push(makeUrlColumn("mobile", domain)); cols.push(makeUrlColumn("mobile", domain));
}
cols.push(volumeColumn, kdColumn, cpcColumn);
if (showDesktop) {
cols.push(makeSerpColumn("desktop"));
}
if (showMobile) {
cols.push(makeSerpColumn("mobile")); cols.push(makeSerpColumn("mobile"));
} }
return cols; return cols;

View File

@ -40,6 +40,7 @@ import {
} from "./RankTrackingFilters"; } from "./RankTrackingFilters";
import { CheckConfirmModal } from "./CheckConfirmModal"; import { CheckConfirmModal } from "./CheckConfirmModal";
import { SegmentedToggle } from "@/client/components/SegmentedToggle"; import { SegmentedToggle } from "@/client/components/SegmentedToggle";
import { useMetricsRefresh } from "./useMetricsRefresh";
import { useRankCheckTrigger } from "./useRankCheckTrigger"; import { useRankCheckTrigger } from "./useRankCheckTrigger";
import { useRankRunPolling } from "./useRankRunPolling"; import { useRankRunPolling } from "./useRankRunPolling";
@ -172,6 +173,9 @@ function RankTrackingDomainDetailInner({
onSuccess: () => setPendingCheck(null), onSuccess: () => setPendingCheck(null),
}); });
const { refresh: refreshMetrics, isRefreshing: metricsRefreshing } =
useMetricsRefresh(projectId, config.id);
const requestCheck = (count: number, keywordIds?: string[]) => { const requestCheck = (count: number, keywordIds?: string[]) => {
if (count < 50) { if (count < 50) {
startCheck({ keywordIds }); startCheck({ keywordIds });
@ -356,6 +360,8 @@ function RankTrackingDomainDetailInner({
const count = costEstimate?.keywordCount ?? rows?.length ?? 0; const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
if (count > 0) requestCheck(count); if (count > 0) requestCheck(count);
}} }}
onRefreshMetrics={() => refreshMetrics()}
metricsRefreshing={metricsRefreshing}
onExport={() => onExport={() =>
exportRankTrackingCsv( exportRankTrackingCsv(
filtered, filtered,

View File

@ -127,6 +127,37 @@ export function DeviceUrlCell({
); );
} }
const compactFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
export function VolumeCell({ value }: { value: number | null }) {
if (value == null) return <span className="text-base-content/40">-</span>;
return (
<span className="font-mono text-sm">{compactFormatter.format(value)}</span>
);
}
export function DifficultyCell({ value }: { value: number | null }) {
if (value == null) return <span className="text-base-content/40">-</span>;
let badgeClass = "bg-success/20 text-success";
if (value > 60) badgeClass = "bg-error/20 text-error";
else if (value > 30) badgeClass = "bg-warning/20 text-warning";
return (
<span
className={`font-mono rounded px-1.5 py-0.5 text-xs font-semibold ${badgeClass}`}
>
{value}
</span>
);
}
export function CpcCell({ value }: { value: number | null }) {
if (value == null) return <span className="text-base-content/40">-</span>;
return <span className="font-mono text-sm">${value.toFixed(2)}</span>;
}
export function comparePositions(a: number | null, b: number | null): number { export function comparePositions(a: number | null, b: number | null): number {
if (a === null && b === null) return 0; if (a === null && b === null) return 0;
if (a === null) return 1; // nulls sort last if (a === null) return 1; // nulls sort last
@ -156,6 +187,9 @@ export function exportRankTrackingCsv(
} }
const headers = [ const headers = [
"Keyword", "Keyword",
"Volume",
"KD",
"CPC",
...(showDesktop ...(showDesktop
? [ ? [
"Desktop Position", "Desktop Position",
@ -175,6 +209,9 @@ export function exportRankTrackingCsv(
]; ];
const csvRows = sorted.map((row) => [ const csvRows = sorted.map((row) => [
row.keyword, row.keyword,
row.searchVolume ?? "",
row.keywordDifficulty ?? "",
row.cpc != null ? row.cpc.toFixed(2) : "",
...(showDesktop ...(showDesktop
? [ ? [
row.desktop.position ?? "Not ranking", row.desktop.position ?? "Not ranking",

View File

@ -0,0 +1,23 @@
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { refreshTrackingKeywordMetrics } from "@/serverFunctions/rank-tracking";
export function useMetricsRefresh(projectId: string, configId: string) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () =>
refreshTrackingKeywordMetrics({
data: { projectId, configId },
}),
onSuccess: (result) => {
void queryClient.invalidateQueries({
queryKey: ["rankTrackingResults", projectId, configId],
});
toast.success(`Metrics updated for ${result.updated} keywords`);
},
onError: () => {
toast.error("Failed to refresh keyword metrics");
},
});
return { refresh: mutation.mutate, isRefreshing: mutation.isPending };
}

View File

@ -152,6 +152,10 @@ export const rankTrackingKeywords = sqliteTable(
.notNull() .notNull()
.references(() => rankTrackingConfigs.id, { onDelete: "cascade" }), .references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(), keyword: text("keyword").notNull(),
searchVolume: integer("search_volume"),
keywordDifficulty: integer("keyword_difficulty"),
cpc: real("cpc"),
metricsFetchedAt: text("metrics_fetched_at"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),

View File

@ -332,6 +332,28 @@ async function getConfigSummaries(projectId: string) {
})); }));
} }
async function updateKeywordMetrics(
updates: Array<{
id: string;
searchVolume: number | null;
keywordDifficulty: number | null;
cpc: number | null;
metricsFetchedAt: string;
}>,
) {
await executeInBatches(updates, (u) =>
db
.update(rankTrackingKeywords)
.set({
searchVolume: u.searchVolume,
keywordDifficulty: u.keywordDifficulty,
cpc: u.cpc,
metricsFetchedAt: u.metricsFetchedAt,
})
.where(eq(rankTrackingKeywords.id, u.id)),
);
}
async function getKeywordCountForConfig(configId: string) { async function getKeywordCountForConfig(configId: string) {
const rows = await db const rows = await db
.select({ value: count() }) .select({ value: count() })
@ -359,6 +381,7 @@ export const RankTrackingRepository = {
getKeywordsForConfig, getKeywordsForConfig,
addKeywordsToConfig, addKeywordsToConfig,
removeKeywordsFromConfig, removeKeywordsFromConfig,
updateKeywordMetrics,
getKeywordCountForConfig, getKeywordCountForConfig,
getConfigSummaries, getConfigSummaries,
getLatestSnapshotsForKeywords, getLatestSnapshotsForKeywords,

View File

@ -1,5 +1,6 @@
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/dataforseoClient";
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 {
@ -232,6 +233,73 @@ async function getLatestRun(configId: string, projectId: string) {
return formatRun(run); return formatRun(run);
} }
// ---------------------------------------------------------------------------
// Keyword metrics (volume, difficulty, CPC)
// ---------------------------------------------------------------------------
const KEYWORD_OVERVIEW_BATCH_SIZE = 700;
async function refreshKeywordMetrics(
configId: string,
projectId: string,
billingCustomer: BillingCustomerContext,
): Promise<{ updated: number }> {
const config = await getValidatedConfig(configId, projectId);
const keywords = await RankTrackingRepository.getKeywordsForConfig(configId);
if (keywords.length === 0) return { updated: 0 };
const client = createDataforseoClient(billingCustomer);
const now = new Date().toISOString();
let updated = 0;
for (let i = 0; i < keywords.length; i += KEYWORD_OVERVIEW_BATCH_SIZE) {
const batch = keywords.slice(i, i + KEYWORD_OVERVIEW_BATCH_SIZE);
const items = await client.labs.keywordOverview({
keywords: batch.map((kw) => kw.keyword),
locationCode: config.locationCode,
languageCode: config.languageCode,
});
// Build a lookup by lowercase keyword
const metricsMap = new Map<
string,
{
searchVolume: number | null;
keywordDifficulty: number | null;
cpc: number | null;
}
>();
for (const item of items) {
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
.map((kw) => {
const metrics = metricsMap.get(kw.keyword.toLowerCase());
if (!metrics) return null;
return {
id: kw.id,
searchVolume: metrics.searchVolume,
keywordDifficulty: metrics.keywordDifficulty,
cpc: metrics.cpc,
metricsFetchedAt: now,
};
})
.filter((u): u is NonNullable<typeof u> => u !== null);
if (updates.length > 0) {
await RankTrackingRepository.updateKeywordMetrics(updates);
updated += updates.length;
}
}
return { updated };
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cost estimation // Cost estimation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -314,4 +382,5 @@ export const RankTrackingService = {
triggerCheck, triggerCheck,
getLatestRun, getLatestRun,
estimateCost, estimateCost,
refreshKeywordMetrics,
}; };

View File

@ -89,6 +89,9 @@ export async function getLatestResults(
{ {
trackingKeywordId: keyword.id, trackingKeywordId: keyword.id,
keyword: keyword.keyword, keyword: keyword.keyword,
searchVolume: keyword.searchVolume,
keywordDifficulty: keyword.keywordDifficulty,
cpc: keyword.cpc,
desktop: createEmptyDeviceResult( desktop: createEmptyDeviceResult(
previousPositions.get(`${keyword.id}:desktop`) ?? null, previousPositions.get(`${keyword.id}:desktop`) ?? null,
), ),

View File

@ -15,6 +15,7 @@ import {
dataforseoResponseSchema, dataforseoResponseSchema,
domainMetricsItemSchema, domainMetricsItemSchema,
domainRankedKeywordItemSchema, domainRankedKeywordItemSchema,
keywordOverviewItemSchema,
labsKeywordDataItemSchema, labsKeywordDataItemSchema,
parseTaskItems, parseTaskItems,
relatedKeywordItemSchema, relatedKeywordItemSchema,
@ -22,6 +23,7 @@ import {
type DataforseoTask, type DataforseoTask,
type DomainMetricsItem, type DomainMetricsItem,
type DomainRankedKeywordItem, type DomainRankedKeywordItem,
type KeywordOverviewItem,
type LabsKeywordDataItem, type LabsKeywordDataItem,
type RelatedKeywordItem, type RelatedKeywordItem,
type SerpLiveItem, type SerpLiveItem,
@ -479,3 +481,67 @@ export async function fetchRankCheckSerpRaw(input: {
billing: buildTaskBilling(parsedTask.data), billing: buildTaskBilling(parsedTask.data),
}; };
} }
// ---------------------------------------------------------------------------
// DataForSEO Labs — Keyword Overview (batch up to 700 keywords)
// ---------------------------------------------------------------------------
export async function fetchKeywordOverviewRaw(
keywords: string[],
locationCode: number,
languageCode: string,
): Promise<DataforseoApiResponse<KeywordOverviewItem[]>> {
const responseRaw = await postDataforseo(
"/v3/dataforseo_labs/google/keyword_overview/live",
[
{
keywords,
location_code: locationCode,
language_code: languageCode,
},
],
);
const response = dataforseoResponseSchema.parse(responseRaw);
if (response.status_code !== 20000) {
throw new AppError(
"INTERNAL_ERROR",
response.status_message || "DataForSEO keyword overview request failed",
);
}
const task = response.tasks?.[0];
if (!task) {
throw new AppError(
"INTERNAL_ERROR",
"DataForSEO keyword overview response missing task",
);
}
if (task.status_code !== 20000) {
throw new AppError(
"INTERNAL_ERROR",
task.status_message || "DataForSEO keyword overview task failed",
);
}
const parsedTask = successfulDataforseoTaskSchema.safeParse(task);
if (!parsedTask.success) {
throw new AppError(
"INTERNAL_ERROR",
"DataForSEO keyword overview task missing billing metadata",
);
}
const data = parseTaskItems(
"google-keyword-overview-live",
parsedTask.data,
keywordOverviewItemSchema,
);
return {
data,
billing: buildTaskBilling(parsedTask.data),
};
}

View File

@ -10,6 +10,7 @@ import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { import {
fetchKeywordIdeasRaw, fetchKeywordIdeasRaw,
fetchKeywordOverviewRaw,
fetchKeywordSuggestionsRaw, fetchKeywordSuggestionsRaw,
fetchRelatedKeywordsRaw, fetchRelatedKeywordsRaw,
fetchDomainRankOverviewRaw, fetchDomainRankOverviewRaw,
@ -186,6 +187,24 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
); );
}, },
}, },
labs: {
keywordOverview(input: {
keywords: string[];
locationCode: number;
languageCode: string;
}) {
return meterDataforseoCall(
customer,
() =>
fetchKeywordOverviewRaw(
input.keywords,
input.locationCode,
input.languageCode,
),
"rank_tracking",
);
},
},
serp: { serp: {
live(input: { live(input: {
keyword: string; keyword: string;

View File

@ -189,6 +189,16 @@ export const serpSnapshotItemSchema = z
}) })
.passthrough(); .passthrough();
export const keywordOverviewItemSchema = z
.object({
keyword: z.string(),
keyword_info: keywordInfoSchema.optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
search_intent_info: searchIntentInfoSchema.nullable().optional(),
})
.passthrough();
export type KeywordOverviewItem = z.infer<typeof keywordOverviewItemSchema>;
export type RelatedKeywordItem = z.infer<typeof relatedKeywordItemSchema>; export type RelatedKeywordItem = z.infer<typeof relatedKeywordItemSchema>;
export type LabsKeywordDataItem = z.infer<typeof labsKeywordDataItemSchema>; export type LabsKeywordDataItem = z.infer<typeof labsKeywordDataItemSchema>;
export type DomainMetricsItem = z.infer<typeof domainMetricsItemSchema>; export type DomainMetricsItem = z.infer<typeof domainMetricsItemSchema>;

View File

@ -18,6 +18,7 @@ import {
estimateCostSchema, estimateCostSchema,
addKeywordsSchema, addKeywordsSchema,
removeKeywordsSchema, removeKeywordsSchema,
refreshMetricsSchema,
} from "@/types/schemas/rank-tracking"; } from "@/types/schemas/rank-tracking";
export const getRankTrackingConfigs = createServerFn({ method: "POST" }) export const getRankTrackingConfigs = createServerFn({ method: "POST" })
@ -190,6 +191,26 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
} }
} }
// Fetch keyword metrics (awaited so they're in the DB before client re-fetches)
if (result.added > 0) {
try {
await RankTrackingService.refreshKeywordMetrics(
data.configId,
context.projectId,
context,
);
} catch (err) {
const appErr = asAppError(err);
if (appErr?.code === "INSUFFICIENT_CREDITS") {
console.info(
"[rank-tracking] auto-metrics-refresh skipped: insufficient credits",
);
} else {
console.error("[rank-tracking] auto-metrics-refresh failed:", err);
}
}
}
return { ...result, checkTriggered }; return { ...result, checkTriggered };
}); });
@ -204,3 +225,29 @@ export const removeTrackingKeywords = createServerFn({ method: "POST" })
); );
return { removed: data.keywordIds.length }; return { removed: data.keywordIds.length };
}); });
export const refreshTrackingKeywordMetrics = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => refreshMetricsSchema.parse(data))
.handler(async ({ data, context }) => {
const result = await RankTrackingService.refreshKeywordMetrics(
data.configId,
context.projectId,
context,
);
waitUntil(
captureServerEvent({
distinctId: context.userId,
event: "rank_tracking:metrics_refresh",
organizationId: context.organizationId,
properties: {
project_id: context.projectId,
config_id: data.configId,
updated: result.updated,
},
}),
);
return result;
});

View File

@ -34,6 +34,9 @@ export interface RankTrackingDeviceResult {
export interface RankTrackingRow { export interface RankTrackingRow {
trackingKeywordId: string; trackingKeywordId: string;
keyword: string; keyword: string;
searchVolume: number | null;
keywordDifficulty: number | null;
cpc: number | null;
desktop: RankTrackingDeviceResult; desktop: RankTrackingDeviceResult;
mobile: RankTrackingDeviceResult; mobile: RankTrackingDeviceResult;
} }
@ -106,3 +109,8 @@ export const removeKeywordsSchema = z.object({
configId: z.string().uuid(), configId: z.string().uuid(),
keywordIds: z.array(z.string().uuid()).min(1).max(2000), keywordIds: z.array(z.string().uuid()).min(1).max(2000),
}); });
export const refreshMetricsSchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
});