SERP analysis: depth 20 by default, load top 100 on demand; classify DataForSEO timeouts (#523)

This commit is contained in:
Ben Senescu 2026-08-26 11:17:18 -04:00 committed by GitHub
parent a1c6eb6b11
commit 022a7f1944
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 418 additions and 23 deletions

View File

@ -6,8 +6,11 @@ export function SerpAnalysisCard({
items, items,
keyword, keyword,
loading, loading,
loadingMore,
canLoadMore,
error, error,
onRetry, onRetry,
deepFetchFailed,
page, page,
pageSize, pageSize,
onPageChange, onPageChange,
@ -15,8 +18,14 @@ export function SerpAnalysisCard({
items: SerpResultItem[]; items: SerpResultItem[];
keyword?: string | null; keyword?: string | null;
loading: boolean; loading: boolean;
/** A deeper snapshot is being fetched; `items` is still the shallow one. */
loadingMore: boolean;
/** Paging past the loaded results can buy a deeper snapshot. */
canLoadMore: boolean;
error?: string | null; error?: string | null;
onRetry?: () => void; onRetry?: () => void;
/** The failure was the deeper crawl, so retrying restores the shallow one. */
deepFetchFailed: boolean;
page: number; page: number;
pageSize: number; pageSize: number;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
@ -31,7 +40,7 @@ export function SerpAnalysisCard({
<p>{error}</p> <p>{error}</p>
{onRetry ? ( {onRetry ? (
<button className="btn btn-xs" onClick={onRetry}> <button className="btn btn-xs" onClick={onRetry}>
Retry {deepFetchFailed ? "Show top 20" : "Retry"}
</button> </button>
) : null} ) : null}
</div> </div>
@ -56,10 +65,16 @@ export function SerpAnalysisCard({
feature="serp_analysis" feature="serp_analysis"
/> />
</div> </div>
{pageItems.length === 0 && loadingMore ? (
<SerpAnalysisLoadingState />
) : (
<SerpAnalysisTable items={pageItems} /> <SerpAnalysisTable items={pageItems} />
)}
<SerpAnalysisPagination <SerpAnalysisPagination
page={page} page={page}
totalPages={totalPages} totalPages={totalPages}
loadingMore={loadingMore}
canLoadMore={canLoadMore}
onPageChange={onPageChange} onPageChange={onPageChange}
/> />
</div> </div>
@ -113,23 +128,38 @@ function SerpAnalysisTable({ items }: { items: SerpResultItem[] }) {
function SerpAnalysisPagination({ function SerpAnalysisPagination({
page, page,
totalPages, totalPages,
loadingMore,
canLoadMore,
onPageChange, onPageChange,
}: { }: {
page: number; page: number;
totalPages: number; totalPages: number;
loadingMore: boolean;
canLoadMore: boolean;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
}) { }) {
if (totalPages <= 1) return null; if (totalPages <= 1 && !canLoadMore) return null;
// Past the loaded results, "Next" stops being free paging and buys a deeper
// crawl — say so on the button rather than spending silently.
const nextBuysDeeperSnapshot =
canLoadMore && !loadingMore && page >= totalPages - 1;
return ( return (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-base-200"> <div className="flex items-center justify-between mt-3 pt-3 border-t border-base-200">
<span className="text-xs text-base-content/50"> <span className="text-xs text-base-content/50">
{loadingMore ? (
"Loading more results…"
) : (
<>
Page {page + 1} of {totalPages} Page {page + 1} of {totalPages}
</>
)}
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
className="btn btn-ghost btn-xs" className="btn btn-ghost btn-xs"
disabled={page === 0} disabled={page === 0 || loadingMore}
onClick={() => onPageChange(page - 1)} onClick={() => onPageChange(page - 1)}
> >
<ChevronLeft className="size-3.5" /> <ChevronLeft className="size-3.5" />
@ -137,10 +167,10 @@ function SerpAnalysisPagination({
</button> </button>
<button <button
className="btn btn-ghost btn-xs" className="btn btn-ghost btn-xs"
disabled={page >= totalPages - 1} disabled={loadingMore || (page >= totalPages - 1 && !canLoadMore)}
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
> >
Next {nextBuysDeeperSnapshot ? "Load top 100" : "Next"}
<ChevronRight className="size-3.5" /> <ChevronRight className="size-3.5" />
</button> </button>
</div> </div>

View File

@ -1,32 +1,67 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useCallback, useState } from "react";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getSerpAnalysis } from "@/serverFunctions/keywords"; import { getSerpAnalysis } from "@/serverFunctions/keywords";
const SERP_PAGE_SIZE = 10;
/** Depth every SERP panel opens at — two pages of results, ~5 credits. */
const SERP_INITIAL_DEPTH = 20;
/**
* Depth we buy when a user pages past what's loaded. Google has no offset, so
* this re-crawls the top of the SERP: the deeper snapshot replaces the shallow
* one instead of extending it, and costs ~20 more credits.
*/
const SERP_DEEP_DEPTH = 100;
export function useKeywordSerpAnalysis( export function useKeywordSerpAnalysis(
projectId: string, projectId: string,
locationCode: number | undefined, locationCode: number | undefined,
) { ) {
const [serpKeyword, setSerpKeyword] = useState<string | null>(null); const [serpKeyword, setSerpKeywordState] = useState<string | null>(null);
const [serpPage, setSerpPage] = useState(0); const [serpPage, setSerpPageState] = useState(0);
const SERP_PAGE_SIZE = 10; const [requestedDepth, setRequestedDepth] = useState<20 | 100>(
SERP_INITIAL_DEPTH,
);
// Everything but the depth identifies the snapshot; the depth decides how
// deep it goes.
const snapshotKey = ["serpAnalysis", projectId, serpKeyword, locationCode];
const serpQuery = useQuery({ const serpQuery = useQuery({
queryKey: ["serpAnalysis", projectId, serpKeyword, locationCode], queryKey: [...snapshotKey, requestedDepth],
queryFn: () => queryFn: () =>
getSerpAnalysis({ getSerpAnalysis({
data: { data: {
projectId, projectId,
keyword: serpKeyword!, keyword: serpKeyword!,
locationCode, locationCode,
depth: requestedDepth,
}, },
}), }),
// Keep the shallow snapshot on screen while the deeper refetch runs — but
// only when nothing except the depth changed, so a new keyword or market
// shows the loading state instead of the previous SERP.
placeholderData: (previous, previousQuery) => {
const previousKey = previousQuery?.queryKey;
const sameSnapshot = snapshotKey.every(
(part, index) => previousKey?.[index] === part,
);
return sameSnapshot ? previous : undefined;
},
// Every fetch here is billed, and a deep crawl can outrun DataForSEO's
// request deadline — a timed-out call may already be billed upstream while
// metering nothing. Never replay or re-issue one without the user asking;
// the card offers an explicit retry.
retry: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
enabled: !!serpKeyword, enabled: !!serpKeyword,
// Every attempt is a fresh billed DataForSEO task, so a failure must not be // Every attempt is a fresh billed DataForSEO task, so a failure must not be
// retried automatically — the user re-runs the search instead. // retried automatically — the user re-runs the search instead.
retry: false, retry: false,
}); });
const { refetch: refetchSerp } = serpQuery;
const serpResults = serpQuery.data?.items ?? []; const serpResults = serpQuery.data?.items ?? [];
const activeSerpKeyword = const activeSerpKeyword =
serpKeyword ?? serpQuery.data?.requestedKeyword ?? null; serpKeyword ?? serpQuery.data?.requestedKeyword ?? null;
@ -35,16 +70,72 @@ export function useKeywordSerpAnalysis(
? getStandardErrorMessage(serpQuery.error, "Failed to load SERP data.") ? getStandardErrorMessage(serpQuery.error, "Failed to load SERP data.")
: null; : null;
// A deeper fetch is available until the loaded snapshot is the deep one.
const canLoadMoreSerp =
(serpQuery.data?.depth ?? SERP_DEEP_DEPTH) < SERP_DEEP_DEPTH;
const serpLoadingMore = serpQuery.isPlaceholderData;
// A failed deep fetch leaves nothing on screen, and retrying it re-buys the
// expensive call. The card offers the shallow snapshot back instead.
const deepFetchFailed =
serpQuery.isError && requestedDepth === SERP_DEEP_DEPTH;
// Retrying a failed deep crawl re-buys the expensive call that just timed
// out. Drop back to the shallow snapshot instead: it's still cached, so the
// user gets their results back for free and can ask for 100 again.
const retrySerp = useCallback(() => {
if (requestedDepth === SERP_DEEP_DEPTH) {
setRequestedDepth(SERP_INITIAL_DEPTH);
return;
}
void refetchSerp();
}, [refetchSerp, requestedDepth]);
const setSerpKeyword = useCallback((keyword: string | null) => {
setSerpKeywordState(keyword);
setRequestedDepth(SERP_INITIAL_DEPTH);
}, []);
const loadedPages = Math.max(
1,
Math.ceil(serpResults.length / SERP_PAGE_SIZE),
);
// While the deeper snapshot loads the user stays on the page they asked for;
// once it lands, a SERP too small to fill that page falls back to the last.
const visiblePage = serpLoadingMore
? serpPage
: Math.min(serpPage, loadedPages - 1);
const setSerpPage = useCallback(
(nextPage: number) => {
// Paging forward past the loaded results buys the full 100-deep snapshot;
// the user lands on the requested page once it arrives.
if (
nextPage > visiblePage &&
nextPage >= loadedPages &&
canLoadMoreSerp
) {
setRequestedDepth(SERP_DEEP_DEPTH);
}
setSerpPageState(nextPage);
},
[canLoadMoreSerp, loadedPages, visiblePage],
);
return { return {
serpKeyword, serpKeyword,
setSerpKeyword, setSerpKeyword,
serpPage, retrySerp,
serpPage: visiblePage,
setSerpPage, setSerpPage,
SERP_PAGE_SIZE, SERP_PAGE_SIZE,
serpQuery, serpQuery,
serpResults, serpResults,
activeSerpKeyword, activeSerpKeyword,
serpLoading, serpLoading,
serpLoadingMore,
canLoadMoreSerp,
deepFetchFailed,
serpError, serpError,
}; };
} }

View File

@ -373,8 +373,11 @@ function DesktopSerpPanel({ controller }: Props) {
items={controller.serpResults} items={controller.serpResults}
keyword={controller.activeSerpKeyword} keyword={controller.activeSerpKeyword}
loading={controller.serpLoading} loading={controller.serpLoading}
loadingMore={controller.serpLoadingMore}
canLoadMore={controller.canLoadMoreSerp}
error={controller.serpError} error={controller.serpError}
onRetry={() => void controller.serpQuery.refetch()} onRetry={controller.retrySerp}
deepFetchFailed={controller.deepFetchFailed}
page={controller.serpPage} page={controller.serpPage}
pageSize={controller.SERP_PAGE_SIZE} pageSize={controller.SERP_PAGE_SIZE}
onPageChange={controller.setSerpPage} onPageChange={controller.setSerpPage}

View File

@ -68,8 +68,11 @@ export function KeywordResearchMobileResults({ controller }: Props) {
items={controller.serpResults} items={controller.serpResults}
keyword={controller.activeSerpKeyword} keyword={controller.activeSerpKeyword}
loading={controller.serpLoading} loading={controller.serpLoading}
loadingMore={controller.serpLoadingMore}
canLoadMore={controller.canLoadMoreSerp}
error={controller.serpError} error={controller.serpError}
onRetry={() => void controller.serpQuery.refetch()} onRetry={controller.retrySerp}
deepFetchFailed={controller.deepFetchFailed}
page={controller.serpPage} page={controller.serpPage}
pageSize={controller.SERP_PAGE_SIZE} pageSize={controller.SERP_PAGE_SIZE}
onPageChange={controller.setSerpPage} onPageChange={controller.setSerpPage}

View File

@ -76,6 +76,10 @@ export function useKeywordResearchController(
serpResults, serpResults,
activeSerpKeyword, activeSerpKeyword,
serpLoading, serpLoading,
serpLoadingMore,
canLoadMoreSerp,
deepFetchFailed,
retrySerp,
serpError, serpError,
} = useKeywordSerpAnalysis(input.projectId, locationCode); } = useKeywordSerpAnalysis(input.projectId, locationCode);
@ -260,11 +264,15 @@ export function useKeywordResearchController(
researchMutationError, researchMutationError,
retrySearch, retrySearch,
resetFilters, resetFilters,
retrySerp,
rows, rows,
searchedKeyword, searchedKeyword,
selectedRows, selectedRows,
canLoadMoreSerp,
deepFetchFailed,
serpError, serpError,
serpLoading, serpLoading,
serpLoadingMore,
serpPage, serpPage,
serpQuery, serpQuery,
serpResults, serpResults,

View File

@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import type { BillingCustomerContext } from "@/server/billing/subscription";
const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(),
getCached: vi.fn(),
setCached: vi.fn(async () => {}),
}));
vi.mock("cloudflare:workers", () => ({ waitUntil: vi.fn() }));
vi.mock("@/server/lib/r2-cache", () => ({
buildCacheKey: vi.fn(async () => "serp:analysis:key"),
getCached: mocks.getCached,
setCached: mocks.setCached,
}));
vi.mock("@/server/lib/dataforseo", () => ({
SERP_ANALYSIS_DEPTH: 20,
createDataforseoClient: mocks.createDataforseoClient,
}));
import { getSerpAnalysis } from "./serp";
const billingCustomer: BillingCustomerContext = {
organizationId: "org_1",
userId: "user_1",
userEmail: "alice@example.com",
};
const input = {
projectId: "proj_1",
keyword: "seo tools",
locationCode: 2840,
languageCode: "en",
};
function cachedSnapshot(depth: number) {
return {
requestedKeyword: "seo tools",
depth,
items: [
{
rank: 1,
title: "Cached",
url: "https://cached.example",
domain: "cached.example",
description: "",
etv: null,
estimatedPaidTrafficCost: null,
referringDomains: null,
backlinks: null,
isNew: false,
rankChange: null,
},
],
};
}
function mockLiveSerp() {
const live = vi.fn().mockResolvedValue([
{
type: "organic",
rank_group: 1,
title: "Live",
url: "https://live.example",
domain: "live.example",
},
]);
mocks.createDataforseoClient.mockReturnValue({ serp: { live } });
return live;
}
describe("getSerpAnalysis cache depth", () => {
it("keeps a good snapshot when the deeper crawl comes back empty", async () => {
mocks.getCached.mockResolvedValue(cachedSnapshot(20));
mocks.createDataforseoClient.mockReturnValue({
serp: { live: vi.fn().mockResolvedValue([]) },
});
const result = await getSerpAnalysis(
{ ...input, depth: 100 },
billingCustomer,
);
expect(result.items).toEqual([]);
// Caching the empty deep result would answer every shallower request with
// nothing until the entry expires.
expect(mocks.setCached).not.toHaveBeenCalled();
});
it("serves a depth-100 snapshot for a depth-20 request", async () => {
mocks.getCached.mockResolvedValue(cachedSnapshot(100));
const live = mockLiveSerp();
const result = await getSerpAnalysis(
{ ...input, depth: 20 },
billingCustomer,
);
expect(live).not.toHaveBeenCalled();
expect(result.depth).toBe(100);
expect(result.items[0]?.title).toBe("Cached");
});
it("refetches live at depth 100 when only a depth-20 snapshot is cached", async () => {
mocks.getCached.mockResolvedValue(cachedSnapshot(20));
const live = mockLiveSerp();
const result = await getSerpAnalysis(
{ ...input, depth: 100 },
billingCustomer,
);
expect(live).toHaveBeenCalledWith(expect.objectContaining({ depth: 100 }));
expect(result.items[0]?.title).toBe("Live");
// The deeper snapshot replaces the shallow entry under the same key.
expect(mocks.setCached).toHaveBeenCalledWith(
"serp:analysis:key",
expect.objectContaining({ depth: 100 }),
expect.any(Number),
);
});
});

View File

@ -1,5 +1,8 @@
import { waitUntil } from "cloudflare:workers"; import { waitUntil } from "cloudflare:workers";
import { type SerpLiveItem } from "@/server/lib/dataforseo"; import {
SERP_ANALYSIS_DEPTH,
type SerpLiveItem,
} from "@/server/lib/dataforseo";
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import type { SerpResultItem } from "@/types/keywords"; import type { SerpResultItem } from "@/types/keywords";
import { z } from "zod"; import { z } from "zod";
@ -14,6 +17,8 @@ type SerpAnalysisReason = "no_organic_results";
type SerpAnalysisResult = { type SerpAnalysisResult = {
requestedKeyword: string; requestedKeyword: string;
items: SerpResultItem[]; items: SerpResultItem[];
/** SERP depth this snapshot was crawled at — how deep the results go. */
depth: number;
reason?: SerpAnalysisReason; reason?: SerpAnalysisReason;
}; };
@ -34,6 +39,10 @@ const serpResultItemSchema = z.object({
const serpCacheSchema = z.object({ const serpCacheSchema = z.object({
requestedKeyword: z.string(), requestedKeyword: z.string(),
items: z.array(serpResultItemSchema), items: z.array(serpResultItemSchema),
// Entries written before depth was tracked don't say how deep they went, so
// they're treated as the shallow floor: worst case a user re-buys a deeper
// crawl once, rather than being shown 20 results as if they were 100.
depth: z.number().int().default(SERP_ANALYSIS_DEPTH),
reason: z.enum(["no_organic_results"]).optional(), reason: z.enum(["no_organic_results"]).optional(),
}); });
@ -61,10 +70,12 @@ async function getSerpLiveAnalysis(
keyword: string; keyword: string;
locationCode: number; locationCode: number;
languageCode: string; languageCode: string;
depth: number;
}, },
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
): Promise<SerpAnalysisResult> { ): Promise<SerpAnalysisResult> {
const keyword = normalizeKeyword(input.keyword); const keyword = normalizeKeyword(input.keyword);
const { depth } = input;
const cacheKey = await buildCacheKey("serp:analysis", { const cacheKey = await buildCacheKey("serp:analysis", {
organizationId: billingCustomer.organizationId, organizationId: billingCustomer.organizationId,
@ -74,9 +85,13 @@ async function getSerpLiveAnalysis(
languageCode: input.languageCode, languageCode: input.languageCode,
}); });
// Depth lives in the cached value, not the key: DataForSEO has no offset, so
// a deeper crawl re-fetches the head too. A depth-100 snapshot therefore also
// answers depth-20 requests, and a deeper request replaces the entry rather
// than appending to it.
const cachedRaw = await getCached(cacheKey); const cachedRaw = await getCached(cacheKey);
const cached = serpCacheSchema.safeParse(cachedRaw); const cached = serpCacheSchema.safeParse(cachedRaw);
if (cached.success) { if (cached.success && cached.data.depth >= depth) {
return cached.data; return cached.data;
} }
@ -84,14 +99,27 @@ async function getSerpLiveAnalysis(
keyword, keyword,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
depth,
}); });
const items = mapOrganicSerpItems(liveItems); const items = mapOrganicSerpItems(liveItems);
const result: SerpAnalysisResult = { requestedKeyword: keyword, items }; const result: SerpAnalysisResult = {
requestedKeyword: keyword,
items,
depth,
};
if (items.length === 0) { if (items.length === 0) {
result.reason = "no_organic_results"; result.reason = "no_organic_results";
} }
// DataForSEO reports "no results" transiently, and a deeper crawl overwrites
// the same key. Without this guard one empty re-crawl would evict a good
// snapshot and, being the deeper entry, answer every shallower request with
// nothing for the rest of the TTL.
if (items.length === 0 && cached.success && cached.data.items.length > 0) {
return result;
}
// waitUntil, not void: workerd cancels unregistered pending I/O once the // waitUntil, not void: workerd cancels unregistered pending I/O once the
// response is sent, so a fire-and-forget put never persists the cache. // response is sent, so a fire-and-forget put never persists the cache.
waitUntil( waitUntil(

View File

@ -29,4 +29,26 @@ describe("DataForSEO transport", () => {
"Basic encoded-credentials", "Basic encoded-credentials",
); );
}); });
// The request-deadline abort arrives as a bare DOMException. Left unclassified
// it escapes as an anonymous INTERNAL_ERROR; retrying it would replay a call
// DataForSEO may already have billed. Both names are reachable: the shared
// budget aborts with TimeoutError, Lighthouse's own controller with AbortError.
it.each(["TimeoutError", "AbortError"])(
"maps a %s abort to UPSTREAM_UNAVAILABLE without retrying",
async (name) => {
const fetchMock = vi
.fn<typeof fetch>()
.mockRejectedValue(new DOMException("aborted", name));
vi.stubGlobal("fetch", fetchMock);
await expect(
dataforseoPost("/v3/serp/google/organic/live/advanced", []),
).rejects.toMatchObject({
code: "UPSTREAM_UNAVAILABLE",
name: "DataForSEOTimeoutError",
});
expect(fetchMock).toHaveBeenCalledOnce();
},
);
}); });

View File

@ -75,7 +75,34 @@ function createAuthenticatedFetch(
init?.signal ?? AbortSignal.timeout(DATAFORSEO_REQUEST_TIMEOUT_MS); init?.signal ?? AbortSignal.timeout(DATAFORSEO_REQUEST_TIMEOUT_MS);
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
const response = await fetch(url, { ...init, headers, signal }); let response: Response;
try {
response = await fetch(url, { ...init, headers, signal });
} catch (error) {
// Both abort flavours mean "we ran out of time", and neither carries a
// useful name of its own: the shared budget above rejects with
// TimeoutError, while Lighthouse passes its own AbortController
// deadline via init.signal and rejects with AbortError. Classify them
// so callers get a provider-degradation error instead of an anonymous
// internal one. Deliberately not retried: a call that ran past the
// deadline may already be billed by DataForSEO, and because this is not
// a DataforseoChargedTaskError the customer is metered nothing for it,
// so replaying would be spend we eat twice.
if (
error instanceof Error &&
(error.name === "TimeoutError" || error.name === "AbortError")
) {
const path = formatDataforseoRequestPath(url);
const timeoutError = new AppError(
"UPSTREAM_UNAVAILABLE",
`DataForSEO request timed out on ${path}`,
{ provider: "dataforseo", providerPath: path },
);
timeoutError.name = "DataForSEOTimeoutError";
throw timeoutError;
}
throw error;
}
if (response.ok) return response; if (response.ok) return response;
// Transient upstream 5xx on an idempotent read -> back off and retry. // Transient upstream 5xx on an idempotent read -> back off and retry.

View File

@ -18,6 +18,8 @@ export {
type LlmPlatform, type LlmPlatform,
} from "@/server/lib/dataforseo/shared"; } from "@/server/lib/dataforseo/shared";
export { SERP_ANALYSIS_DEPTH } from "@/server/lib/dataforseo/serp";
export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget"; export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
// Section fetchers called outside the metered client. Task collection is free // Section fetchers called outside the metered client. Task collection is free

View File

@ -13,6 +13,15 @@ import {
} from "@/server/lib/dataforseo/envelope"; } from "@/server/lib/dataforseo/envelope";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
// Default depth for keyword SERP analysis. DataForSEO crawls (and bills) one
// Google page of 10 results at a time, and the crawls are sequential, so depth
// is the single lever on both latency and cost here: every 10 results is
// another page fetch against the shared 60s request budget. Keep this low —
// callers that need to see deeper ranks pass an explicit depth. There is no
// offset/cursor: a deeper request re-crawls pages 1..N/10 from the top, so it
// replaces the shallow snapshot rather than extending it.
export const SERP_ANALYSIS_DEPTH = 20;
/** DataForSEO bills SERPs in pages of 10; depth outside 10-100 is rejected. */ /** DataForSEO bills SERPs in pages of 10; depth outside 10-100 is rejected. */
function clampSerpDepth(depth: number): number { function clampSerpDepth(depth: number): number {
return Math.min(100, Math.max(10, depth)); return Math.min(100, Math.max(10, depth));
@ -78,6 +87,7 @@ export async function fetchLiveSerp(input: {
keyword: string; keyword: string;
locationCode: number; locationCode: number;
languageCode: string; languageCode: string;
depth?: number;
}): Promise<DataforseoApiResponse<SerpLiveItem[]>> { }): Promise<DataforseoApiResponse<SerpLiveItem[]>> {
const response = await dataforseoPost( const response = await dataforseoPost(
"/v3/serp/google/organic/live/advanced", "/v3/serp/google/organic/live/advanced",
@ -88,7 +98,7 @@ export async function fetchLiveSerp(input: {
language_code: input.languageCode, language_code: input.languageCode,
device: "desktop", device: "desktop",
os: "windows", os: "windows",
depth: 100, depth: clampSerpDepth(input.depth ?? SERP_ANALYSIS_DEPTH),
}, },
], ],
); );

View File

@ -1,5 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import {
createDataforseoClient,
SERP_ANALYSIS_DEPTH,
} from "@/server/lib/dataforseo";
import { mcpResponse } from "@/server/mcp/formatters"; import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context"; import { buildProjectMeta } from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
@ -43,6 +46,16 @@ const inputSchema = {
.describe( .describe(
"1-10 queries. Bulk-friendly — prefer this over multiple single-query calls.", "1-10 queries. Bulk-friendly — prefer this over multiple single-query calls.",
), ),
depth: z
.number()
.int()
.min(10)
.max(100)
.multipleOf(10)
.optional()
.describe(
"How many SERP rows to crawl per keyword — a multiple of 10 from 10 to 100, default 20. Google has no offset, so a deeper crawl re-fetches the top too: each additional 10 adds ~2.5 credits per keyword. Only raise it when you need ranks past the top 20.",
),
} as const; } as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>; type Args = z.infer<z.ZodObject<typeof inputSchema>>;
@ -52,7 +65,7 @@ export const getSerpResultsTool = {
config: { config: {
title: "Get Google SERP results", title: "Get Google SERP results",
description: description:
"Fetch live Google organic search results for 1-10 keywords. Use this to inspect who ranks for a query, verify competitors, compare SERPs across keywords, or gather source URLs before content planning. Charges credits per keyword (~30-60 each). Does not save results to OpenSEO. Per-keyword errors don't fail the batch.", "Fetch live Google organic search results for 1-10 keywords. Use this to inspect who ranks for a query, verify competitors, compare SERPs across keywords, or gather source URLs before content planning. Returns the top `depth` result rows per keyword (default 20). Charges credits per keyword: ~5 each at the default depth 20, and each additional 10 of depth adds ~2.5. Does not save results to OpenSEO. Per-keyword errors don't fail the batch.",
inputSchema, inputSchema,
outputSchema: { outputSchema: {
results: z.array( results: z.array(
@ -94,15 +107,17 @@ export const getSerpResultsTool = {
}, },
handler: withMcpProjectAuth(async (args: Args, context) => { handler: withMcpProjectAuth(async (args: Args, context) => {
const client = createDataforseoClient(context.billing); const client = createDataforseoClient(context.billing);
const depth = args.depth ?? SERP_ANALYSIS_DEPTH;
const results = await Promise.all( const results = await Promise.all(
args.queries.map(async (q) => { args.queries.map(async (q) => {
try { try {
const items = await client.serp.live({ const items = await client.serp.live({
keyword: q.keyword, keyword: q.keyword,
...resolveMarket(q, context.project), ...resolveMarket(q, context.project),
depth,
}); });
// Trim noise — return only essentials per item. // Trim noise — return only essentials per item.
const trimmed = items.slice(0, 20).map((item) => ({ const trimmed = items.slice(0, depth).map((item) => ({
type: item.type, type: item.type,
rank: item.rank_absolute ?? item.rank_group ?? null, rank: item.rank_absolute ?? item.rank_group ?? null,
title: item.title ?? null, title: item.title ?? null,

View File

@ -42,6 +42,7 @@ vi.mock("@/server/lib/dataforseo", async () => {
createDataforseoClient: mocks.createDataforseoClient, createDataforseoClient: mocks.createDataforseoClient,
fetchBusinessDataTaskResult: mocks.fetchBusinessDataTaskResult, fetchBusinessDataTaskResult: mocks.fetchBusinessDataTaskResult,
normalizeBacklinksTarget: targets.normalizeBacklinksTarget, normalizeBacklinksTarget: targets.normalizeBacklinksTarget,
SERP_ANALYSIS_DEPTH: 20,
}; };
}); });
vi.mock("@/server/features/projects/services/ProjectService", () => ({ vi.mock("@/server/features/projects/services/ProjectService", () => ({
@ -387,4 +388,31 @@ describe("MCP tool text output (service-backed tools)", () => {
"1 | example.com | Best SEO Tools | https://example.com/best", "1 | example.com | Best SEO Tools | https://example.com/best",
); );
}); });
it("get_serp_results crawls and returns rows to the requested depth", async () => {
const live = vi.fn().mockResolvedValue(
Array.from({ length: 40 }, (_, index) => ({
type: "organic",
rank_absolute: index + 1,
title: `Result ${index + 1}`,
url: `https://example.com/${index + 1}`,
domain: "example.com",
description: "desc",
})),
);
mocks.createDataforseoClient.mockReturnValue({ serp: { live } });
const result = await getSerpResultsTool.handler(
{
projectId: "project_1",
queries: [{ keyword: "seo tools" }],
depth: 30,
},
toolContext,
);
expect(live).toHaveBeenCalledWith(expect.objectContaining({ depth: 30 }));
// Rows are trimmed to the depth that was crawled, not the fixed top 20.
expect(textContent(result)).toContain('"seo tools" (30 results)');
});
}); });

View File

@ -176,6 +176,10 @@ export const serpAnalysisSchema = z.object({
keyword: z.string().min(1), keyword: z.string().min(1),
locationCode: z.number().int().positive().optional(), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(), languageCode: z.string().min(2).max(8).optional(),
// Only the two depths the app offers: the default top-20 snapshot, and the
// full 100 the SERP panel buys when a user pages past the loaded results.
// Each 10 of depth is another crawled Google page (~2.5 credits).
depth: z.union([z.literal(20), z.literal(100)]).default(20),
}); });
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */