feat: add keyword metrics (volume, difficulty, CPC) to rank tracking (#118)
This commit is contained in:
parent
2b7a3eae05
commit
9a9a9a2a6c
4
drizzle/0009_smart_kitty_pryde.sql
Normal file
4
drizzle/0009_smart_kitty_pryde.sql
Normal 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;
|
||||||
2031
drizzle/meta/0009_snapshot.json
Normal file
2031
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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={() => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
23
src/client/features/rank-tracking/useMetricsRefresh.ts
Normal file
23
src/client/features/rank-tracking/useMetricsRefresh.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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)`),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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;
|
||||||
|
});
|
||||||
|
|||||||
@ -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(),
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user