feat(rank-tracking): add city/region-level location targeting (#62)

This commit is contained in:
RDeemer63 2026-07-07 20:44:19 -05:00 committed by GitHub
parent c3caf2009f
commit ffa6ec5e70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 7009 additions and 96 deletions

View File

@ -0,0 +1,4 @@
DROP INDEX "rank_tracking_configs_project_domain_location_idx";--> statement-breakpoint
ALTER TABLE "rank_tracking_configs" ADD COLUMN "location_name" text;--> statement-breakpoint
CREATE UNIQUE INDEX "rank_tracking_configs_national_idx" ON "rank_tracking_configs" USING btree ("project_id","domain","location_code") WHERE "rank_tracking_configs"."location_name" IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "rank_tracking_configs_local_idx" ON "rank_tracking_configs" USING btree ("project_id","domain","location_code","location_name") WHERE "rank_tracking_configs"."location_name" IS NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,13 @@
"when": 1783117905771,
"tag": "0005_talented_wild_pack",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1783438455708,
"tag": "0006_location_name",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,4 @@
DROP INDEX `rank_tracking_configs_project_domain_location_idx`;--> statement-breakpoint
ALTER TABLE `rank_tracking_configs` ADD `location_name` text;--> statement-breakpoint
CREATE UNIQUE INDEX `rank_tracking_configs_national_idx` ON `rank_tracking_configs` (`project_id`,`domain`,`location_code`) WHERE "rank_tracking_configs"."location_name" IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX `rank_tracking_configs_local_idx` ON `rank_tracking_configs` (`project_id`,`domain`,`location_code`,`location_name`) WHERE "rank_tracking_configs"."location_name" IS NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -204,6 +204,13 @@
"when": 1783117904905,
"tag": "0028_empty_beyonder",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1783438453855,
"tag": "0029_location_name",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,78 @@
# 0008 — Local Rank Tracking: Location Data & Search
How city/region-level rank tracking stores and searches DataForSEO's location
registry, and why it works the way it does. Shipped July 2026; future
directions are listed at the end.
## The feature
Rank tracking configs take an optional `location_name` — a canonical DataForSEO
location string like `Enid,Oklahoma,United States`. Null means the existing
country-level behavior, unchanged. Uniqueness is enforced by two partial
indexes: one config per (project, domain, country) for national trackers, one
per (project, domain, country, location) for local ones.
When set, the location flows through verbatim:
- **SERP checks** (live and queued task-post) send `location_name` instead of
`location_code`, so positions reflect what a searcher in that city sees.
SERP pricing is location-independent, so cost estimates are unchanged.
- **Keyword metrics** come city-scoped: volume / CPC / competition from Google
Ads `search_volume` (the only DataForSEO source that accepts sub-country
geotargets), merged per keyword with national KD / intent from Labs (which
is country-only). This matters: "rv storage near me" is 135K/mo nationally
but 70/mo in Pittsburgh — a national number on a local tracker overstates
demand by orders of magnitude. Keywords Google Ads collapses away get
explicit nulls rather than a leaked national value; the UI column reads
"Local volume" and exports name the city. Adds ~$0.09 per metrics refresh.
- **The picker** is a debounced combobox in the config modal, searching the
country's registry and storing the selected canonical name. Local mode
requires a selection; switching country clears it.
## Location data: how search works
The registry endpoint (`/v3/serp/google/locations/{iso}`) is free but has no
search parameter and returns the full country list per call — 9.5 MB / 60k
entries for the US. Slimmed to the five types users target (City, County,
Municipality, DMA Region, Region) it is ~23k entries / 1.5 MB. The data
changes roughly quarterly (Google geotarget updates).
The search path: combobox (350 ms debounce) → `searchSerpLocations` server fn
→ per-country list from **KV** (`serp-locations:{iso}`, 30-day
`expirationTtl`, hot reads edge-cached with `cacheTtl: 86400`) → substring
filter, top 10. A KV miss triggers the origin fetch + slim + store, with
concurrent cold fills coalesced in-isolate so the prewarm and a fast first
keystroke can't both download the 9.5 MB payload.
Selecting **Local** in the modal fires `prewarmSerpLocations` (a `useQuery`
keyed on country, `staleTime: Infinity`), so the one slow cold fill (~3 s)
usually happens before the first keystroke. Warm searches are tens of ms.
## Why KV
| Alternative | Why not |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Workers Cache API layer | Documented no-op on workers.dev (self-hosters), per-colo only, no persistence guarantees. KV's `cacheTtl` provides the same hot-read caching, managed. |
| R2 (+ cache in front) | Works (an earlier iteration shipped it) but needs a second layer for read latency; KV is one primitive with the same profile. |
| D1 / Postgres table | Schema + migrations on two providers for quarterly-static reference data; revisit if server-side validation or MCP location search justify a real table. |
| Durable Object per country | Pins to its first-request region forever; new binding for self-host; buys coordination this read-only data doesn't need. |
| Static assets, client search | Refresh requires redeploy — self-hosters would be pinned to release-time snapshots. |
| "Just accept zip codes" | Doesn't avoid the registry: DataForSEO only accepts canonical values, and zips are registry entries themselves (~32k for the US). |
Cost is noise either way: KV bills per operation, so storage for all supported
countries is ~$0.02/month and each search read is fractions of a cent.
## Future directions (not built)
- **Picker quality**: prominence-ranked results (offline GeoNames/Census tier
table — the registry has no population data, so "Portland" currently ranks
the Maine DMA above Portland, OR), cities-first with a type filter,
recently-used/suggested locations (must come from our own config history —
GSC has no city dimension), and a selection-confirmation line.
- **ZIP fast path**: numeric queries search a separate cached Postal Code
blob; useful for sub-metro service areas inside large cities.
- **Multi-city fan-out**: multi-select in the picker creating one config per
city with a shared keyword set — the agency 35-metro workflow.
- **Server-side `location_name` validation** at config save, closing the gap
where a hand-crafted request can store an arbitrary string (fails at
DataForSEO at cost 0 today, so client-side validation suffices).

View File

@ -0,0 +1,222 @@
import { useEffect, useRef, useState } from "react";
import { Loader2, Search } from "lucide-react";
import { searchSerpLocations } from "@/serverFunctions/serp-locations";
import { formatLocationLabel } from "@/shared/keyword-locations";
import type { SerpLocationResult } from "@/server/lib/dataforseo/serp-locations";
type Props = {
value: string | undefined;
onChange: (locationName: string | undefined) => void;
/** ISO 3166-1 alpha-2 country code, e.g. "us". */
countryCode: string;
placeholder?: string;
};
function useDebounce(value: string, delayMs: number): string {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
export function SerpLocationCombobox({
value,
onChange,
countryCode,
placeholder = "Search cities...",
}: Props) {
const [inputValue, setInputValue] = useState(
value ? formatLocationLabel(value) : "",
);
const [results, setResults] = useState<SerpLocationResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Selecting a result sets the input to its display label; that change must
// not itself trigger a search for the label text.
const skipNextFetchRef = useRef(false);
const debouncedQuery = useDebounce(inputValue, 350);
// Sync display when value prop changes externally (e.g. mode reset)
useEffect(() => {
if (!value) {
setInputValue("");
setResults([]);
setOpen(false);
}
}, [value]);
// Fetch results when debounced query changes
useEffect(() => {
const trimmed = debouncedQuery.trim();
if (!trimmed) {
setResults([]);
setOpen(false);
setIsLoading(false);
return;
}
if (skipNextFetchRef.current) {
skipNextFetchRef.current = false;
// Any fetch this change superseded was cancelled before its own
// finally could clear the spinner, so clear it here.
setIsLoading(false);
return;
}
let cancelled = false;
setIsLoading(true);
setIsError(false);
searchSerpLocations({ data: { query: trimmed, countryCode } })
.then((data) => {
if (cancelled) return;
setResults(data);
setOpen(true);
setActiveIndex(0);
})
.catch(() => {
if (cancelled) return;
setIsError(true);
setOpen(true);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [debouncedQuery, countryCode]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handlePointerDown = (e: PointerEvent) => {
if (
e.target instanceof Node &&
!containerRef.current?.contains(e.target)
) {
setOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
// Scroll active item into view
useEffect(() => {
if (!open) return;
listRef.current?.children[activeIndex]?.scrollIntoView({
block: "nearest",
});
}, [activeIndex, open]);
const select = (loc: SerpLocationResult) => {
onChange(loc.locationName);
skipNextFetchRef.current = true;
setInputValue(loc.displayLabel);
setResults([]);
setOpen(false);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setInputValue(v);
if (!v.trim()) {
onChange(undefined);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
break;
case "Enter": {
e.preventDefault();
const loc = results[activeIndex];
if (loc) select(loc);
break;
}
case "Escape":
e.preventDefault();
setOpen(false);
break;
}
};
return (
<div ref={containerRef} className="relative w-full">
<label className="flex items-center gap-2 input input-bordered w-full pr-3">
{isLoading ? (
<Loader2 className="size-4 shrink-0 text-base-content/50 animate-spin" />
) : (
<Search className="size-4 shrink-0 text-base-content/50" />
)}
<input
type="text"
className="grow min-w-0 bg-transparent outline-none placeholder:text-base-content/40"
placeholder={placeholder}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => {
if (results.length > 0) setOpen(true);
}}
autoComplete="off"
/>
</label>
{open && (
<div className="absolute z-30 mt-1 w-full rounded-box border border-base-300 bg-base-100 shadow-lg p-1">
{isError ? (
<p className="px-3 py-2 text-sm text-error">
Unable to load locations
</p>
) : results.length === 0 ? (
<p className="px-3 py-2 text-sm text-base-content/50">
No locations found for "{debouncedQuery.trim()}"
</p>
) : (
<ul
ref={listRef}
role="listbox"
className="menu max-h-56 w-full flex-nowrap overflow-y-auto p-0"
>
{results.map((loc, index) => (
<li
key={loc.locationCode}
role="option"
aria-selected={loc.locationName === value}
>
<button
type="button"
className={`w-full flex items-center justify-between gap-2 ${index === activeIndex ? "menu-focus" : ""}`}
onClick={() => select(loc)}
onMouseEnter={() => setActiveIndex(index)}
>
<span className="truncate text-left">
{loc.displayLabel}
</span>
<span className="badge badge-xs bg-base-300 border-0 text-base-content/60 shrink-0">
{loc.locationType}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View File

@ -8,6 +8,7 @@ import { captureClientEvent } from "@/client/lib/posthog";
import { getRankKeywordHistory } from "@/serverFunctions/rank-tracking";
import type { RankKeywordHistoryPoint } from "@/serverFunctions/rank-tracking";
import { LOCATIONS } from "@/client/features/keywords/locations";
import { formatLocationLabel } from "@/shared/keyword-locations";
import { csvChange, DeviceRankCell } from "./RankTrackingTableParts";
import {
RankTrendChart,
@ -34,6 +35,7 @@ export function KeywordTrendModal({
configId,
domain,
locationCode,
locationName,
serpDepth,
onClose,
}: {
@ -42,6 +44,7 @@ export function KeywordTrendModal({
configId: string;
domain: string;
locationCode: number;
locationName?: string;
serpDepth: number;
onClose: () => void;
}) {
@ -145,8 +148,11 @@ export function KeywordTrendModal({
{target.keyword}
</h3>
<p className="text-xs text-base-content/60">
{domain} &middot; {LOCATIONS[locationCode] ?? "US"} &middot;
Position over time
{domain} &middot;{" "}
{locationName
? formatLocationLabel(locationName, 2)
: (LOCATIONS[locationCode] ?? "US")}{" "}
&middot; Position over time
</p>
</div>
<TrendRangeToggle value={sinceDays} onChange={setSinceDays} />

View File

@ -3,6 +3,7 @@ import { ArrowUp, ArrowDown } from "lucide-react";
import type { ColumnDef, SortingFn } from "@tanstack/react-table";
import { makeSelectionColumn } from "@/client/components/table/AppDataTable";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import { formatLocationLabel } from "@/shared/keyword-locations";
import {
CpcCell,
DeviceRankCell,
@ -69,16 +70,30 @@ const nullsLastNumeric: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
return a - b;
};
const volumeColumn: ColumnDef<RankTrackingRow> = {
// Local configs fetch volume scoped to the tracked city, so the header must
// say which number the user is looking at — national volume can overstate
// local demand by orders of magnitude.
function makeVolumeColumn(locationLabel?: string): ColumnDef<RankTrackingRow> {
return {
id: "volume",
accessorKey: "searchVolume",
header: ({ column }) => (
<SortableHeader column={column} label="Volume" id="volume" />
<SortableHeader
column={column}
label={locationLabel ? "Local volume" : "Volume"}
id="volume"
tooltip={
locationLabel
? `Estimated monthly searches in ${locationLabel} from Google Ads`
: undefined
}
/>
),
size: 90,
cell: ({ getValue }) => <VolumeCell value={getValue<number | null>()} />,
sortingFn: nullsLastNumeric,
};
}
const kdColumn: ColumnDef<RankTrackingRow> = {
id: "kd",
@ -184,13 +199,25 @@ function makeSerpColumn(
};
}
export function useRankTrackingColumns(
showDesktop: boolean,
showMobile: boolean,
domain: string,
selectAnchorRef: MutableRefObject<SelectionAnchor | null>,
onKeywordClick: (row: RankTrackingRow) => void,
): ColumnDef<RankTrackingRow>[] {
export function useRankTrackingColumns(options: {
showDesktop: boolean;
showMobile: boolean;
domain: string;
selectAnchorRef: MutableRefObject<SelectionAnchor | null>;
onKeywordClick: (row: RankTrackingRow) => void;
locationName?: string | null;
}): ColumnDef<RankTrackingRow>[] {
const {
showDesktop,
showMobile,
domain,
selectAnchorRef,
onKeywordClick,
locationName,
} = options;
const locationLabel = locationName
? formatLocationLabel(locationName, 2)
: undefined;
return useMemo(() => {
const cols: ColumnDef<RankTrackingRow>[] = [
makeSelectionColumn<RankTrackingRow>(selectAnchorRef),
@ -204,7 +231,7 @@ export function useRankTrackingColumns(
cols.push(makeDeviceColumn("mobile"));
cols.push(makeUrlColumn("mobile", domain));
}
cols.push(volumeColumn, kdColumn, cpcColumn);
cols.push(makeVolumeColumn(locationLabel), kdColumn, cpcColumn);
if (showDesktop) {
cols.push(makeSerpColumn("desktop"));
}
@ -212,5 +239,12 @@ export function useRankTrackingColumns(
cols.push(makeSerpColumn("mobile"));
}
return cols;
}, [showDesktop, showMobile, domain, selectAnchorRef, onKeywordClick]);
}, [
showDesktop,
showMobile,
domain,
selectAnchorRef,
onKeywordClick,
locationLabel,
]);
}

View File

@ -1,14 +1,7 @@
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
import {
createRankTrackingConfig,
updateRankTrackingConfig,
} from "@/serverFunctions/rank-tracking";
import { Info, Loader2, X } from "lucide-react";
import { Modal } from "@/client/components/Modal";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
import { domainField, normalizeDomain } from "@/types/schemas/domain";
import {
@ -21,8 +14,11 @@ import {
getLanguageCode,
getLanguageOptions,
} from "@/client/features/keywords/locations";
import { getIsoCountryCode } from "@/shared/keyword-locations";
import { LocationSelect } from "@/client/components/LocationSelect";
import { SearchTargetingField } from "./SearchTargetingField";
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
import { useSaveConfigMutations } from "./useSaveConfigMutations";
type Props = {
projectId: string;
@ -60,55 +56,37 @@ export function RankTrackingConfigModal({
const [schedule, setSchedule] = useState<
RankTrackingConfig["scheduleInterval"]
>(existingConfig?.scheduleInterval ?? "weekly");
const [targetingMode, setTargetingMode] = useState<"national" | "local">(
existingConfig?.locationName ? "local" : "national",
);
const [locationName, setLocationName] = useState<string | undefined>(
existingConfig?.locationName ?? undefined,
);
const [createdConfigId, setCreatedConfigId] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (normalizedDomain: string) =>
createRankTrackingConfig({
data: {
const selectedCountryCode = useMemo(
() => getIsoCountryCode(locationCode),
[locationCode],
);
const { createMutation, updateMutation } = useSaveConfigMutations({
projectId,
domain: normalizedDomain,
existingConfig,
fields: {
devices,
serpDepth,
locationCode,
languageCode,
scheduleInterval: schedule,
targetingMode,
locationName,
schedule,
},
}),
onSuccess: (result) => {
captureClientEvent("rank_tracking:config_create");
toast.success("Domain added for rank tracking");
setCreatedConfigId(result.configId);
onCreated: (configId) => {
setCreatedConfigId(configId);
onConfigCreated?.();
setStep("keywords");
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to save config"));
},
});
const updateMutation = useMutation({
mutationFn: (normalizedDomain: string) =>
updateRankTrackingConfig({
data: {
projectId,
configId: existingConfig!.id,
domain: normalizedDomain,
devices,
serpDepth,
locationCode,
languageCode,
scheduleInterval: schedule,
},
}),
onSuccess: () => {
captureClientEvent("rank_tracking:config_update");
toast.success("Configuration updated");
onSaved();
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to update config"));
},
onUpdated: () => onSaved(),
});
const handleSubmit = (e: React.FormEvent) => {
@ -118,6 +96,10 @@ export function RankTrackingConfigModal({
toast.error("Please enter a domain");
return;
}
if (targetingMode === "local" && !locationName) {
toast.error("Please select a city or region for local targeting");
return;
}
const parsedDomain = domainField.safeParse(domain);
if (!parsedDomain.success) {
toast.error("Please enter a valid domain");
@ -202,10 +184,20 @@ export function RankTrackingConfigModal({
onChange={(newLocationCode) => {
setLocationCode(newLocationCode);
setLanguageCode(getLanguageCode(newLocationCode));
// A picked city belongs to the previous country.
setLocationName(undefined);
}}
/>
</div>
<SearchTargetingField
mode={targetingMode}
onModeChange={setTargetingMode}
locationName={locationName}
onLocationNameChange={setLocationName}
countryCode={selectedCountryCode}
/>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Language</span>

View File

@ -2,6 +2,7 @@ import { Monitor, Plus, Settings, Smartphone } from "lucide-react";
import { SegmentedToggle } from "@/client/components/SegmentedToggle";
import { LOCATIONS } from "@/client/features/keywords/locations";
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
import { formatLocationLabel } from "@/shared/keyword-locations";
import type {
ComparePeriod,
RankTrackingConfig,
@ -45,8 +46,10 @@ export function RankTrackingDetailHeader({
<div>
<h2 className="text-lg font-semibold">{config.domain}</h2>
<p className="text-xs text-base-content/60">
{LOCATIONS[config.locationCode] ?? "US"} &middot;{" "}
{devicesLabel(config.devices)} &middot;{" "}
{config.locationName
? formatLocationLabel(config.locationName, 2)
: (LOCATIONS[config.locationCode] ?? "US")}{" "}
&middot; {devicesLabel(config.devices)} &middot;{" "}
{scheduleLabel(config.scheduleInterval)}
{run && (
<>

View File

@ -270,10 +270,16 @@ export function RankTrackingDomainDetail({
showDesktop,
showMobile,
config.domain,
config.locationName,
)
}
onExportToSheets={() =>
exportRankTrackingToSheets(filtered, showDesktop, showMobile)
exportRankTrackingToSheets(
filtered,
showDesktop,
showMobile,
config.locationName,
)
}
onCopyKeywords={() => {
void navigator.clipboard.writeText(
@ -326,6 +332,7 @@ export function RankTrackingDomainDetail({
configId={config.id}
projectId={projectId}
locationCode={config.locationCode}
locationName={config.locationName}
serpDepth={config.serpDepth}
/>
)}

View File

@ -16,6 +16,7 @@ import {
updateRankTrackingConfig,
} from "@/serverFunctions/rank-tracking";
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
import { formatLocationLabel } from "@/shared/keyword-locations";
import { Modal } from "@/client/components/Modal";
import {
applyDomainListFilters,
@ -205,8 +206,10 @@ function DomainRow({
<div className="min-w-0 flex-1 pointer-events-none">
<p className="font-medium truncate">{summary.domain}</p>
<p className="text-xs text-base-content/60">
{LOCATIONS[summary.locationCode] ?? "US"} &middot;{" "}
{devicesLabel(summary.devices)} &middot;{" "}
{summary.locationName
? formatLocationLabel(summary.locationName, 2)
: (LOCATIONS[summary.locationCode] ?? "US")}{" "}
&middot; {devicesLabel(summary.devices)} &middot;{" "}
{scheduleLabel(summary.scheduleInterval)}
{summary.lastRunCompletedAt && (
<>

View File

@ -38,6 +38,7 @@ export function RankTrackingTable({
configId,
projectId,
locationCode,
locationName,
serpDepth,
}: {
totalCount: number;
@ -50,6 +51,7 @@ export function RankTrackingTable({
configId: string;
projectId: string;
locationCode: number;
locationName?: string | null;
serpDepth: number;
}) {
const queryClient = useQueryClient();
@ -68,13 +70,14 @@ export function RankTrackingTable({
[],
);
const columns = useRankTrackingColumns(
const columns = useRankTrackingColumns({
showDesktop,
showMobile,
domain,
selectAnchorRef,
handleKeywordClick,
);
onKeywordClick: handleKeywordClick,
locationName,
});
const table = useAppTable({
data: rows,
@ -239,6 +242,7 @@ export function RankTrackingTable({
configId={configId}
domain={domain}
locationCode={locationCode}
locationName={locationName ?? undefined}
serpDepth={serpDepth}
onClose={() => setTrendTarget(null)}
/>

View File

@ -3,6 +3,7 @@ import { toast } from "sonner";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import { formatLocationLabel } from "@/shared/keyword-locations";
import type {
RankTrackingDeviceResult,
RankTrackingRow,
@ -173,10 +174,14 @@ export function buildRankTrackingExport(
sorted: RankTrackingRow[],
showDesktop: boolean,
showMobile: boolean,
locationName?: string | null,
): { headers: string[]; rows: (string | number)[][] } {
const headers = [
"Keyword",
"Volume",
// Exports lack the table's tooltip, so name the city inline.
locationName
? `Local volume (${formatLocationLabel(locationName, 2)})`
: "Volume",
"KD",
"CPC",
...(showDesktop
@ -227,11 +232,13 @@ export function exportRankTrackingToSheets(
sorted: RankTrackingRow[],
showDesktop: boolean,
showMobile: boolean,
locationName?: string | null,
) {
const { headers, rows } = buildRankTrackingExport(
sorted,
showDesktop,
showMobile,
locationName,
);
void exportTableToSheets({ headers, rows, feature: "rank_tracking" });
}
@ -241,6 +248,7 @@ export function exportRankTrackingCsv(
showDesktop: boolean,
showMobile: boolean,
domain: string,
locationName?: string | null,
) {
if (sorted.length === 0) {
toast.error("No data to export");
@ -250,6 +258,7 @@ export function exportRankTrackingCsv(
sorted,
showDesktop,
showMobile,
locationName,
);
// CSV file download keeps cents-formatted CPC for human readability;
// clipboard/Sheets export uses raw numbers (see buildRankTrackingExport).

View File

@ -0,0 +1,83 @@
import { useQuery } from "@tanstack/react-query";
import { SerpLocationCombobox } from "@/client/components/SerpLocationCombobox";
import { prewarmSerpLocations } from "@/serverFunctions/serp-locations";
type TargetingMode = "national" | "local";
export function SearchTargetingField({
mode,
onModeChange,
locationName,
onLocationNameChange,
countryCode,
}: {
mode: TargetingMode;
onModeChange: (mode: TargetingMode) => void;
locationName: string | undefined;
onLocationNameChange: (locationName: string | undefined) => void;
countryCode: string;
}) {
// Warm the server-side location cache the moment Local targeting is in
// play, so the country list is hot before the first keystroke. Best-effort:
// a failed warm just means the first search is slower, so no retries, and
// staleTime keeps one warm per country per session.
useQuery({
queryKey: ["serp-locations-prewarm", countryCode],
queryFn: () => prewarmSerpLocations({ data: { countryCode } }),
enabled: mode === "local",
staleTime: Infinity,
retry: false,
});
return (
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Search Targeting</span>
</label>
<div className="flex gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-sm"
checked={mode === "national"}
onChange={() => {
onModeChange("national");
onLocationNameChange(undefined);
}}
/>
<span className="text-sm">National</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-sm"
checked={mode === "local"}
onChange={() => onModeChange("local")}
/>
<span className="text-sm">Local</span>
</label>
</div>
<p className="text-xs text-base-content/50 mt-1.5">
{mode === "local" ? (
<>
<span className="text-success font-medium">Best for:</span> "near
me" queries, city/county keywords, service-area pages.
</>
) : (
<>
Local targeting can understate rankings for non-geo-modified terms.
</>
)}
</p>
{mode === "local" && (
<div className="mt-2">
<SerpLocationCombobox
value={locationName}
onChange={onLocationNameChange}
countryCode={countryCode}
placeholder="Search cities..."
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,83 @@
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
import {
createRankTrackingConfig,
updateRankTrackingConfig,
} from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
type ConfigFields = {
devices: "both" | "desktop" | "mobile";
serpDepth: number;
locationCode: number;
languageCode: string;
targetingMode: "national" | "local";
locationName: string | undefined;
schedule: RankTrackingConfig["scheduleInterval"];
};
export function useSaveConfigMutations(input: {
projectId: string;
existingConfig?: RankTrackingConfig | null;
fields: ConfigFields;
onCreated: (configId: string) => void;
onUpdated: () => void;
}) {
const { projectId, existingConfig, fields, onCreated, onUpdated } = input;
const common = {
devices: fields.devices,
serpDepth: fields.serpDepth,
locationCode: fields.locationCode,
languageCode: fields.languageCode,
scheduleInterval: fields.schedule,
};
const createMutation = useMutation({
mutationFn: (normalizedDomain: string) =>
createRankTrackingConfig({
data: {
projectId,
domain: normalizedDomain,
...common,
locationName:
fields.targetingMode === "local" ? fields.locationName : undefined,
},
}),
onSuccess: (result) => {
captureClientEvent("rank_tracking:config_create");
toast.success("Domain added for rank tracking");
onCreated(result.configId);
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to save config"));
},
});
const updateMutation = useMutation({
mutationFn: (normalizedDomain: string) =>
updateRankTrackingConfig({
data: {
projectId,
configId: existingConfig!.id,
domain: normalizedDomain,
...common,
// null clears a previously-set local target; undefined would leave
// the old location_name in the DB and silently keep city targeting.
locationName:
fields.targetingMode === "local" ? fields.locationName : null,
},
}),
onSuccess: () => {
captureClientEvent("rank_tracking:config_update");
toast.success("Configuration updated");
onUpdated();
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to update config"));
},
});
return { createMutation, updateMutation };
}

View File

@ -225,6 +225,7 @@ export const rankTrackingConfigs = sqliteTable(
})
.notNull()
.default("weekly"),
locationName: text("location_name"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastCheckedAt: text("last_checked_at"),
nextCheckAt: text("next_check_at"),
@ -234,11 +235,12 @@ export const rankTrackingConfigs = sqliteTable(
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("rank_tracking_configs_project_domain_location_idx").on(
table.projectId,
table.domain,
table.locationCode,
),
uniqueIndex("rank_tracking_configs_national_idx")
.on(table.projectId, table.domain, table.locationCode)
.where(sql`${table.locationName} IS NULL`),
uniqueIndex("rank_tracking_configs_local_idx")
.on(table.projectId, table.domain, table.locationCode, table.locationName)
.where(sql`${table.locationName} IS NOT NULL`),
],
);

View File

@ -228,6 +228,7 @@ export const rankTrackingConfigs = pgTable(
})
.notNull()
.default("weekly"),
locationName: text("location_name"),
isActive: boolean("is_active").notNull().default(true),
lastCheckedAt: timestampColumn("last_checked_at"),
nextCheckAt: timestampColumn("next_check_at"),
@ -235,11 +236,12 @@ export const rankTrackingConfigs = pgTable(
createdAt: timestampColumn("created_at").notNull().default(isoNow),
},
(table) => [
uniqueIndex("rank_tracking_configs_project_domain_location_idx").on(
table.projectId,
table.domain,
table.locationCode,
),
uniqueIndex("rank_tracking_configs_national_idx")
.on(table.projectId, table.domain, table.locationCode)
.where(sql`${table.locationName} IS NULL`),
uniqueIndex("rank_tracking_configs_local_idx")
.on(table.projectId, table.domain, table.locationCode, table.locationName)
.where(sql`${table.locationName} IS NOT NULL`),
],
);

View File

@ -59,6 +59,7 @@ async function getConfigByProjectDomainLocation(
projectId: string,
domain: string,
locationCode: number,
locationName: string | null,
) {
const rows = await db
.select()
@ -68,6 +69,12 @@ async function getConfigByProjectDomainLocation(
eq(rankTrackingConfigs.projectId, projectId),
eq(rankTrackingConfigs.domain, domain),
eq(rankTrackingConfigs.locationCode, locationCode),
// National (NULL) and per-city configs are distinct rows — mirrors
// the partial unique indexes, so a national config and any number of
// city configs can coexist for the same domain.
locationName === null
? isNull(rankTrackingConfigs.locationName)
: eq(rankTrackingConfigs.locationName, locationName),
),
)
.limit(1);
@ -104,6 +111,7 @@ async function getDueConfigsWithOrganization(nowIso: string) {
domain: rankTrackingConfigs.domain,
locationCode: rankTrackingConfigs.locationCode,
languageCode: rankTrackingConfigs.languageCode,
locationName: rankTrackingConfigs.locationName,
devices: rankTrackingConfigs.devices,
serpDepth: rankTrackingConfigs.serpDepth,
scheduleInterval: rankTrackingConfigs.scheduleInterval,

View File

@ -84,6 +84,35 @@ describe("RankTrackingService.createConfig", () => {
expect(mocks.createConfig).not.toHaveBeenCalled();
});
it("keys the duplicate check on locationName so national and city configs coexist", async () => {
mocks.getConfigByProjectDomainLocation.mockResolvedValue(null);
mocks.getConfigsForProject.mockResolvedValue([]);
mocks.createConfig.mockResolvedValue(undefined);
const { RankTrackingService } = await import("./RankTrackingService");
// Local config: the lookup must be scoped to this exact city, so an
// existing national row for the same domain doesn't collide.
await RankTrackingService.createConfig({
...baseInput,
locationName: "Enid,Oklahoma,United States",
});
expect(mocks.getConfigByProjectDomainLocation).toHaveBeenCalledWith(
"project_1",
"acme.com",
2840,
"Enid,Oklahoma,United States",
);
// National config: the lookup is scoped to NULL locationName.
await RankTrackingService.createConfig(baseInput);
expect(mocks.getConfigByProjectDomainLocation).toHaveBeenLastCalledWith(
"project_1",
"acme.com",
2840,
null,
);
});
it("rejects reactivating an archived config when the project is at the active-config cap", async () => {
const { MAX_CONFIGS_PER_PROJECT } = await import("@/shared/rank-tracking");
mocks.getConfigByProjectDomainLocation.mockResolvedValue(archivedConfig);

View File

@ -32,6 +32,7 @@ async function createConfig(input: {
domain: string;
locationCode?: number;
languageCode?: string;
locationName?: string;
devices?: RankTrackingConfig["devices"];
serpDepth: number;
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
@ -44,11 +45,13 @@ async function createConfig(input: {
? computeNextCheckAt(scheduleInterval)
: null;
const locationName = input.locationName ?? null;
const existing =
await RankTrackingRepository.getConfigByProjectDomainLocation(
input.projectId,
normalizedDomain,
locationCode,
locationName,
);
// The (project, domain, location) row still exists when a domain is
// archived — archiving only flips isActive to false. So re-adding an
@ -58,7 +61,9 @@ async function createConfig(input: {
if (existing?.isActive) {
throw new AppError(
"VALIDATION_ERROR",
"This domain + country combination is already being tracked",
locationName
? "This domain + city combination is already being tracked"
: "This domain + country combination is already being tracked",
);
}
@ -98,6 +103,7 @@ async function createConfig(input: {
domain: normalizedDomain,
locationCode,
languageCode: input.languageCode ?? "en",
locationName,
devices: input.devices ?? "both",
serpDepth: input.serpDepth,
scheduleInterval,
@ -114,6 +120,7 @@ async function updateConfig(
domain?: string;
locationCode?: number;
languageCode?: string;
locationName?: string | null;
devices?: RankTrackingConfig["devices"];
serpDepth?: number;
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
@ -128,6 +135,8 @@ async function updateConfig(
updates.locationCode = input.locationCode;
if (input.languageCode !== undefined)
updates.languageCode = input.languageCode;
if (input.locationName !== undefined)
updates.locationName = input.locationName;
if (input.devices !== undefined) updates.devices = input.devices;
if (input.serpDepth !== undefined) updates.serpDepth = input.serpDepth;
if (input.isActive !== undefined) updates.isActive = input.isActive;
@ -280,6 +289,9 @@ async function refreshKeywordMetrics(
keywords: keywords.map((kw) => kw.keyword),
locationCode: config.locationCode,
languageCode: config.languageCode,
// Local configs get volume/CPC scoped to the tracked city; national
// numbers can overstate local demand by orders of magnitude.
locationName: config.locationName ?? undefined,
creditFeature: "rank_tracking",
});
const byKeyword = new Map(

View File

@ -36,7 +36,13 @@ type RankCheckWorkflowStatus = {
type RankCheckConfigForStart = Pick<
RankTrackingConfig,
"id" | "domain" | "locationCode" | "languageCode" | "devices" | "serpDepth"
| "id"
| "domain"
| "locationCode"
| "languageCode"
| "locationName"
| "devices"
| "serpDepth"
>;
const ACTIVE_WORKFLOW_STATUSES = new Set<RankCheckWorkflowStatus["status"]>([
@ -164,6 +170,7 @@ export async function beginRankCheckRun(input: {
domain: input.config.domain,
locationCode: input.config.locationCode,
languageCode: input.config.languageCode,
locationName: input.config.locationName ?? undefined,
devices: input.config.devices,
serpDepth: input.config.serpDepth,
trigger: input.trigger,

View File

@ -30,11 +30,20 @@ export async function fetchAdsSearchVolume(input: {
keywords: string[];
locationCode: number;
languageCode: string;
/**
* Canonical DataForSEO location_name (e.g. "Pittsburgh,Pennsylvania,United
* States"). Google Ads accepts any geotarget, so this scopes volume / CPC /
* competition to a city or region instead of the whole country.
*/
locationName?: string;
}): Promise<DataforseoApiResponse<AdsKeywordItem[]>> {
const locationParams = input.locationName
? { location_name: input.locationName }
: { location_code: input.locationCode };
const response = await keywordsDataApi().googleAdsSearchVolumeLive([
new KeywordsDataGoogleAdsSearchVolumeLiveRequestInfo({
keywords: input.keywords,
location_code: input.locationCode,
...locationParams,
language_code: input.languageCode,
}),
]);

View File

@ -129,4 +129,64 @@ describe("fetchKeywordMetricsForList", () => {
expect(keywordOverview).toHaveBeenCalledTimes(3); // 700 + 700 + 100
expect(rows).toHaveLength(1500);
});
it("merges city-scoped Ads volume with national Labs KD for local requests", async () => {
const adsSearchVolume = vi.fn().mockResolvedValue([
{
keyword: "plumber near me",
search_volume: 260,
cpc: 7.54,
competition: "MEDIUM",
competition_index: 55,
monthly_searches: [],
},
// "emergency plumber near me" collapsed away by Google Ads normalization.
]);
const keywordOverview = vi.fn().mockResolvedValue([
{
keyword: "plumber near me",
keyword_info: { search_volume: 135000, cpc: 11.29 },
keyword_properties: { keyword_difficulty: 76 },
search_intent_info: { main_intent: "transactional" },
},
{
keyword: "emergency plumber near me",
keyword_info: { search_volume: 135000, cpc: 5.93 },
keyword_properties: { keyword_difficulty: 17 },
search_intent_info: { main_intent: "transactional" },
},
]);
const client = fakeClient({ adsSearchVolume, keywordOverview });
const rows = await fetchKeywordMetricsForList(client, {
keywords: ["plumber near me", "emergency plumber near me"],
locationCode: 2840,
languageCode: "en",
locationName: "Springfield,Illinois,United States",
creditFeature: "rank_tracking",
});
expect(adsSearchVolume).toHaveBeenCalledWith(
expect.objectContaining({
locationName: "Springfield,Illinois,United States",
}),
);
// Local volume/CPC from Ads, national KD/intent from Labs.
expect(rows[0]).toMatchObject({
keyword: "plumber near me",
searchVolume: 260,
cpc: 7.54,
keywordDifficulty: 76,
intent: "transactional",
});
// Keyword Ads dropped: KD/intent survive, but the national volume/CPC
// must NOT leak into a local request.
expect(rows[1]).toMatchObject({
keyword: "emergency plumber near me",
searchVolume: null,
cpc: null,
keywordDifficulty: 17,
intent: "transactional",
});
});
});

View File

@ -94,6 +94,14 @@ function normalizeAdsKeyword(
// 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: {
@ -102,6 +110,7 @@ export async function fetchKeywordMetricsForList(
languageCode: string;
creditFeature: CreditFeature;
includeClickstreamData?: boolean;
locationName?: string;
},
): Promise<KeywordMetricRow[]> {
const useGoogleAds =
@ -115,13 +124,44 @@ export async function fetchKeywordMetricsForList(
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,
@ -139,3 +179,57 @@ export async function fetchKeywordMetricsForList(
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;
}

View File

@ -0,0 +1,110 @@
import { env } from "cloudflare:workers";
import { z } from "zod";
import { serpApi } from "@/server/lib/dataforseo/core";
import { assertOk } from "@/server/lib/dataforseo/envelope";
import { formatLocationLabel } from "@/shared/keyword-locations";
export interface SerpLocationResult {
locationCode: number;
locationName: string;
locationType: string;
displayLabel: string;
}
// Sub-country granularities users actually target. Deliberately excludes
// Postal Code (~32k extra rows for the US alone), State (national-ish), and
// long-tail types like Airport / University.
const INCLUDED_LOCATION_TYPES = new Set([
"City",
"County",
"Municipality",
"DMA Region",
"Region",
]);
const locationItemSchema = z.object({
location_code: z.number(),
location_name: z.string(),
location_type: z.string().nullable().optional(),
});
const cachedLocationsSchema = z.array(
z.object({
locationCode: z.number(),
locationName: z.string(),
locationType: z.string(),
displayLabel: z.string(),
}),
);
/** Google refreshes geotargets roughly quarterly; 30 days keeps us current. */
const KV_TTL_SECONDS = 30 * 24 * 60 * 60;
/** Edge-cache hot reads so repeat searches skip the central KV store. */
const KV_HOT_READ_TTL_SECONDS = 24 * 60 * 60;
function cacheKey(iso: string): string {
return `serp-locations:${iso}`;
}
/**
* Full sub-country location list for one country. `countryCode` is ISO
* 3166-1 alpha-2 ("us", "gb") the endpoint rejects country *names* with a
* task-level Invalid Field error, which assertOk surfaces.
*
* The DataForSEO response is ~9.5MB for the US and the endpoint has no search
* parameter, so the slimmed list (~1.5MB) is cached in KV (30d TTL, hot reads
* edge-cached via cacheTtl); only a miss pays the origin fetch. The endpoint
* is free (cost 0), so no billing envelope.
*/
export async function fetchSerpLocationsForCountry(
countryCode: string,
): Promise<SerpLocationResult[]> {
const iso = countryCode.toLowerCase();
const cached = await env.KV.get(cacheKey(iso), {
type: "json",
cacheTtl: KV_HOT_READ_TTL_SECONDS,
});
const hit = cachedLocationsSchema.safeParse(cached);
if (hit.success) return hit.data;
return fillFromOrigin(iso);
}
// Coalesce concurrent cold fills within an isolate: the prewarm fired on
// selecting Local and the user's first debounced search otherwise both miss
// the cache and each fetch + parse the ~9.5MB origin payload. Entries are
// deleted on settle so the parsed array isn't retained past the fill (and a
// failed fill — e.g. the owning request got cancelled — isn't sticky).
const inflightFills = new Map<string, Promise<SerpLocationResult[]>>();
function fillFromOrigin(iso: string): Promise<SerpLocationResult[]> {
const inflight = inflightFills.get(iso);
if (inflight) return inflight;
const fill = fetchFromDataforseo(iso)
.then(async (fresh) => {
await env.KV.put(cacheKey(iso), JSON.stringify(fresh), {
expirationTtl: KV_TTL_SECONDS,
});
return fresh;
})
.finally(() => inflightFills.delete(iso));
inflightFills.set(iso, fill);
return fill;
}
async function fetchFromDataforseo(iso: string): Promise<SerpLocationResult[]> {
const response = await serpApi().googleLocationsCountry(iso);
const task = assertOk(response);
return (task.result ?? [])
.map((item) => locationItemSchema.safeParse(item))
.flatMap((parsed) => (parsed.success ? [parsed.data] : []))
.filter((item) => INCLUDED_LOCATION_TYPES.has(item.location_type ?? ""))
.map((item) => ({
locationCode: item.location_code,
locationName: item.location_name,
displayLabel: formatLocationLabel(item.location_name),
locationType: item.location_type ?? "",
}));
}

View File

@ -141,15 +141,19 @@ export async function fetchRankCheckSerp(input: {
keywordId: string;
locationCode: number;
languageCode: string;
locationName?: string;
device: "desktop" | "mobile";
targetDomain: string;
depth: number;
}): Promise<DataforseoApiResponse<RankCheckResult>> {
const depth = clampSerpDepth(input.depth);
const locationParams = input.locationName
? { location_name: input.locationName }
: { location_code: input.locationCode };
const response = await serpApi().googleOrganicLiveAdvanced([
new SerpGoogleOrganicLiveAdvancedRequestInfo({
keyword: input.keyword,
location_code: input.locationCode,
...locationParams,
language_code: input.languageCode,
device: input.device,
os: input.device === "desktop" ? "windows" : "android",
@ -197,6 +201,7 @@ export async function postRankCheckTasks(input: {
tasks: RankCheckTaskInput[];
locationCode: number;
languageCode: string;
locationName?: string;
depth: number;
targetDomain: string;
}): Promise<DataforseoApiResponse<PostedRankCheckTask[]>> {
@ -207,12 +212,15 @@ export async function postRankCheckTasks(input: {
);
}
const depth = clampSerpDepth(input.depth);
const locationParams = input.locationName
? { location_name: input.locationName }
: { location_code: input.locationCode };
const response = await serpApi().googleOrganicTaskPost(
input.tasks.map(
(task) =>
new SerpGoogleOrganicTaskPostRequestInfo({
keyword: task.keyword,
location_code: input.locationCode,
...locationParams,
language_code: input.languageCode,
device: task.device,
os: task.device === "desktop" ? "windows" : "android",

View File

@ -38,6 +38,7 @@ interface RankCheckParams {
domain: string;
locationCode: number;
languageCode: string;
locationName?: string;
devices: "both" | "desktop" | "mobile";
serpDepth: number;
trigger: "manual" | "scheduled";
@ -269,6 +270,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
domain,
locationCode,
languageCode,
locationName,
devices,
serpDepth,
trigger,
@ -332,6 +334,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
domain,
locationCode,
languageCode,
locationName,
runId,
};
// Scheduled checks use DataForSEO's task queue (~30% of live cost);

View File

@ -48,6 +48,7 @@ interface CheckContext {
domain: string;
locationCode: number;
languageCode: string;
locationName?: string;
runId: string;
}
@ -90,6 +91,7 @@ async function checkBatchLive(
keywordId: task.keywordId,
locationCode: ctx.locationCode,
languageCode: ctx.languageCode,
locationName: ctx.locationName,
device: task.device,
targetDomain: ctx.domain,
depth: ctx.serpDepth,
@ -294,6 +296,7 @@ export async function runQueuedCheck(
tasks: chunk,
locationCode: ctx.locationCode,
languageCode: ctx.languageCode,
locationName: ctx.locationName,
depth: ctx.serpDepth,
targetDomain: ctx.domain,
}),

View File

@ -80,6 +80,7 @@ export const createRankTrackingConfig = createServerFn({ method: "POST" })
domain: data.domain,
locationCode: data.locationCode,
languageCode: data.languageCode,
locationName: data.locationName,
devices: data.devices,
serpDepth: data.serpDepth,
scheduleInterval: data.scheduleInterval,
@ -110,6 +111,7 @@ export const updateRankTrackingConfig = createServerFn({ method: "POST" })
domain: data.domain,
locationCode: data.locationCode,
languageCode: data.languageCode,
locationName: data.locationName,
devices: data.devices,
serpDepth: data.serpDepth,
scheduleInterval: data.scheduleInterval,

View File

@ -0,0 +1,36 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
import { fetchSerpLocationsForCountry } from "@/server/lib/dataforseo/serp-locations";
/** ISO 3166-1 alpha-2, e.g. "us" — DataForSEO rejects country names. */
const countryCodeField = z.string().regex(/^[a-z]{2}$/i);
const searchSerpLocationsSchema = z.object({
query: z.string().min(1).max(100),
countryCode: countryCodeField,
});
export const searchSerpLocations = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.validator(searchSerpLocationsSchema)
.handler(async ({ data }) => {
const all = await fetchSerpLocationsForCountry(data.countryCode);
const needle = data.query.trim().toLowerCase();
return all
.filter((loc) => loc.displayLabel.toLowerCase().includes(needle))
.slice(0, 10);
});
/**
* Warm the per-country location cache so the first real search is fast.
* Fired when the user switches to Local targeting; the first search per
* country otherwise pays the full ~9.5MB DataForSEO fetch (~3s).
*/
export const prewarmSerpLocations = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.validator(z.object({ countryCode: countryCodeField }))
.handler(async ({ data }) => {
await fetchSerpLocationsForCountry(data.countryCode);
return { warmed: true };
});

View File

@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
LABS_LOCATION_OPTIONS,
LOCATION_OPTIONS,
formatLocationLabel,
getIsoCountryCode,
getKeywordDataProvider,
getLanguageCode,
isLabsLocationCode,
@ -63,3 +65,32 @@ describe("keyword locations", () => {
expect(new Set(codes).size).toBe(codes.length);
});
});
describe("getIsoCountryCode", () => {
it("lowercases the shortLabel for standard countries", () => {
expect(getIsoCountryCode(2840)).toBe("us");
expect(getIsoCountryCode(2036)).toBe("au");
});
it("maps the UK display label to its ISO code gb", () => {
expect(getIsoCountryCode(2826)).toBe("gb");
});
it("falls back to us for unknown location codes", () => {
expect(getIsoCountryCode(999999)).toBe("us");
});
});
describe("formatLocationLabel", () => {
it("trims uneven spacing around canonical name segments", () => {
expect(formatLocationLabel("Portland-Auburn, ME,United States")).toBe(
"Portland-Auburn, ME, United States",
);
});
it("truncates to maxSegments for compact display", () => {
expect(formatLocationLabel("Springfield,Illinois,United States", 2)).toBe(
"Springfield, Illinois",
);
});
});

View File

@ -21,6 +21,37 @@
*/
export const DEFAULT_LOCATION_CODE = 2840;
/**
* Human-readable form of a canonical DataForSEO location_name, whose segments
* are comma-separated with inconsistent spacing ("Portland-Auburn, ME,United
* States"). Trims each segment; `maxSegments` truncates for compact display
* ("Enid, Oklahoma").
*/
export function formatLocationLabel(
locationName: string,
maxSegments?: number,
): string {
const parts = locationName.split(",").map((part) => part.trim());
return (maxSegments ? parts.slice(0, maxSegments) : parts).join(", ");
}
/**
* shortLabel is a *display* label; the one entry that diverges from ISO
* 3166-1 alpha-2 is the United Kingdom ("UK" reads better, ISO is "GB").
*/
const ISO_COUNTRY_OVERRIDES: Record<string, string> = { UK: "GB" };
/**
* Lowercase ISO 3166-1 alpha-2 code for a country location_code the format
* DataForSEO's per-country endpoints (e.g. SERP locations) require.
*/
export function getIsoCountryCode(locationCode: number): string {
const shortLabel =
LOCATION_OPTIONS.find((option) => option.code === locationCode)
?.shortLabel ?? "US";
return (ISO_COUNTRY_OVERRIDES[shortLabel] ?? shortLabel).toLowerCase();
}
type KeywordDataProvider = "labs" | "google_ads";
type LocationOption = {

View File

@ -57,6 +57,7 @@ export const createConfigSchema = z.object({
domain: domainField,
locationCode: z.number().int().positive().optional(),
languageCode: z.string().max(10).optional(),
locationName: z.string().min(1).max(200).optional(),
devices: devicesEnum.optional(),
serpDepth: z.number().int().min(10).max(100).multipleOf(10),
scheduleInterval: scheduleEnum.optional(),
@ -68,6 +69,7 @@ export const updateConfigSchema = z.object({
domain: domainField.optional(),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().max(10).optional(),
locationName: z.string().min(1).max(200).nullable().optional(),
devices: devicesEnum.optional(),
serpDepth: z.number().int().min(10).max(100).multipleOf(10).optional(),
scheduleInterval: scheduleEnum.optional(),