SERP analysis: depth 20 by default, load top 100 on demand; classify DataForSEO timeouts (#523)
This commit is contained in:
parent
a1c6eb6b11
commit
022a7f1944
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
124
src/server/features/keywords/services/research/serp.test.ts
Normal file
124
src/server/features/keywords/services/research/serp.test.ts
Normal 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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user