Scheduled rank checks run through DataForSEO's task queue (#268)
This commit is contained in:
parent
5f03992b78
commit
f8d5ffd285
54
.claude/skills/merge-ready/SKILL.md
Normal file
54
.claude/skills/merge-ready/SKILL.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: merge-ready
|
||||||
|
description: Take a branch from "code exists (or is about to)" to "ready for Ben's final review" — multi-axis subagent review with verified findings, fixes, ci:check, checkpoint commits, and an updated PR. Use whenever the user says a feature/fix/branch should be "merge ready", asks to get changes ready for review, or appends this to a build request ("build X and make it merge-ready").
|
||||||
|
---
|
||||||
|
|
||||||
|
# Merge ready
|
||||||
|
|
||||||
|
Drive the current work to the point where the only remaining step is Ben's own review and merge. The deliverable is a pushed branch with a clean `pnpm ci:check`, checkpoint commits along the way, and an open PR with a high-level description plus review instructions.
|
||||||
|
|
||||||
|
**Never merge the PR. Ben always reviews last.**
|
||||||
|
|
||||||
|
## 0. Figure out the starting point
|
||||||
|
|
||||||
|
This skill composes with feature work — it is not only a review pass:
|
||||||
|
|
||||||
|
- **Invoked alongside a build request** ("build X, make it merge-ready"): implement the feature/fix first, committing as you go, then continue below. The review phases cover _all_ changes on the branch vs `origin/main`, not just the last edit.
|
||||||
|
- **Invoked on existing work** ("make this branch merge-ready"): start directly at step 1. The scope is `git diff origin/main...HEAD` plus anything uncommitted.
|
||||||
|
|
||||||
|
## 1. Sync with main
|
||||||
|
|
||||||
|
- `git fetch origin main`. If the branch is behind, merge `origin/main` in and resolve conflicts (favor main's version for code this branch didn't intentionally change).
|
||||||
|
- **Checkpoint:** commit the merge before starting review, so conflict resolution is auditable separately from review fixes.
|
||||||
|
|
||||||
|
## 2. Multi-axis subagent review
|
||||||
|
|
||||||
|
Spawn independent review subagents **in parallel**, one per axis, each given the branch diff scope (`git diff origin/main...HEAD`) and repo access:
|
||||||
|
|
||||||
|
1. **Unnecessary complexity** — thin wrappers, needless indirection, single-use abstractions, defensive guards for impossible states, dead config. This codebase deliberately stays simple.
|
||||||
|
2. **Security** — authz on new endpoints (org/project scoping), SSRF, injection, secrets handling, anything user-input-shaped reaching D1/R2/external APIs.
|
||||||
|
3. **Billing & metering** — ways a user could trigger DataForSEO/provider spend without being metered, charged-but-failed paths, retry/loop amplification, endpoints with unexpectedly high per-call user cost. Credits are billed via Autumn; uncounted spend is a revenue leak.
|
||||||
|
4. **Library & project idioms** — TanStack (Router/Query/Start) used idiomatically; patterns match how the rest of the codebase already does it (Result-pattern error handling at provider seams, db/schema conventions, existing component patterns). Flag novel patterns where an established one exists.
|
||||||
|
5. **Vibe-coded cruft** — leftover scaffolding, stale comments narrating the edit history, console.logs, TODO-without-owner, copy-pasted near-duplicates, files/exports nothing uses.
|
||||||
|
|
||||||
|
Each reviewer returns findings with file:line, severity (`blocker` / `should-fix` / `nitpick`), and a one-line rationale. Tell reviewers explicitly: this is an early-stage product — do not chase theoretical edge cases; mark anything debatable as `nitpick`.
|
||||||
|
|
||||||
|
## 3. Verify findings — never blindly accept
|
||||||
|
|
||||||
|
For each `blocker` and `should-fix` finding, spawn verification subagents (in parallel) that adversarially check the finding against the actual code and verdict **APPLY / APPLY-MODIFIED / REJECT** with reasoning. Drop rejected findings. Nitpicks don't need verification — they're reported, not necessarily fixed.
|
||||||
|
|
||||||
|
## 4. Fix, check, loop
|
||||||
|
|
||||||
|
- Apply verified `blocker`/`should-fix` fixes. Apply nitpicks only when trivial and clearly right; otherwise list them in the PR for Ben to judge.
|
||||||
|
- **Checkpoint:** commit fixes in logical groups (e.g. one commit per axis or per concern) so the fix history is reviewable on its own.
|
||||||
|
- Run `pnpm ci:check` (prettier, knip, tsc, oxlint). Fix failures and re-run until clean. If a fix was substantial (not formatting/lint), run a quick re-review of just that change.
|
||||||
|
- Loop until ci:check passes and no verified findings remain unaddressed.
|
||||||
|
|
||||||
|
## 5. Push and open/update the PR
|
||||||
|
|
||||||
|
- Push the branch. Open a PR against `main` if one doesn't exist; otherwise update the existing PR's description.
|
||||||
|
- PR description requirements:
|
||||||
|
- **High-level** — what changed and why, written for a human skimming. No file paths, no per-file changelog.
|
||||||
|
- **How to review** — a short ordered guide: what to look at first, what the risky/judgment-call areas are, what was deliberately left out of scope.
|
||||||
|
- **Review notes** — unfixed nitpicks and any REJECT verdicts worth a second opinion, clearly labeled as such.
|
||||||
|
- Report back to Ben: PR link, one-paragraph summary, and anything that still needs his judgment. Do not merge.
|
||||||
@ -27,6 +27,7 @@ export function CheckConfirmModal({
|
|||||||
keywordCount,
|
keywordCount,
|
||||||
devices,
|
devices,
|
||||||
serpDepth,
|
serpDepth,
|
||||||
|
"live",
|
||||||
);
|
);
|
||||||
const dc = devicesCount(devices);
|
const dc = devicesCount(devices);
|
||||||
const totalChecks = keywordCount * dc;
|
const totalChecks = keywordCount * dc;
|
||||||
@ -66,7 +67,7 @@ export function CheckConfirmModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="font-mono font-semibold">${costUsd.toFixed(2)}</p>
|
<p className="font-mono font-semibold">~${costUsd.toFixed(2)}</p>
|
||||||
{isPending && <Loader2 className="size-3 animate-spin ml-auto" />}
|
{isPending && <Loader2 className="size-3 animate-spin ml-auto" />}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -289,10 +289,13 @@ export function RankTrackingConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
|
// Scheduled checks run through the cheaper task queue; manual
|
||||||
|
// configs only ever pay the live price.
|
||||||
const { costUsd: costPerKeyword } = estimateRankCheckCredits(
|
const { costUsd: costPerKeyword } = estimateRankCheckCredits(
|
||||||
1,
|
1,
|
||||||
devices,
|
devices,
|
||||||
serpDepth,
|
serpDepth,
|
||||||
|
schedule === "manual" ? "live" : "queued",
|
||||||
);
|
);
|
||||||
const checksPerMonth = schedule === "daily" ? 30 : 4;
|
const checksPerMonth = schedule === "daily" ? 30 : 4;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -330,10 +330,12 @@ async function estimateCost(configId: string, projectId: string) {
|
|||||||
const config = await getValidatedConfig(configId, projectId);
|
const config = await getValidatedConfig(configId, projectId);
|
||||||
const keywordCount =
|
const keywordCount =
|
||||||
await RankTrackingRepository.getKeywordCountForConfig(configId);
|
await RankTrackingRepository.getKeywordCountForConfig(configId);
|
||||||
|
// Estimates the cost of a manual "check now", which always runs live.
|
||||||
const { costUsd, costCredits } = estimateRankCheckCredits(
|
const { costUsd, costCredits } = estimateRankCheckCredits(
|
||||||
keywordCount,
|
keywordCount,
|
||||||
config.devices,
|
config.devices,
|
||||||
config.serpDepth,
|
config.serpDepth,
|
||||||
|
"live",
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
costUsd,
|
costUsd,
|
||||||
|
|||||||
@ -60,6 +60,7 @@ vi.mock("@/server/lib/dataforseo/labs", () => ({
|
|||||||
vi.mock("@/server/lib/dataforseo/serp", () => ({
|
vi.mock("@/server/lib/dataforseo/serp", () => ({
|
||||||
fetchLiveSerp: vi.fn(),
|
fetchLiveSerp: vi.fn(),
|
||||||
fetchRankCheckSerp: vi.fn(),
|
fetchRankCheckSerp: vi.fn(),
|
||||||
|
postRankCheckTasks: vi.fn(),
|
||||||
fetchLocalSerp: vi.fn(),
|
fetchLocalSerp: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/server/lib/dataforseo/business", () => ({
|
vi.mock("@/server/lib/dataforseo/business", () => ({
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import {
|
|||||||
fetchLiveSerp,
|
fetchLiveSerp,
|
||||||
fetchLocalSerp,
|
fetchLocalSerp,
|
||||||
fetchRankCheckSerp,
|
fetchRankCheckSerp,
|
||||||
|
postRankCheckTasks,
|
||||||
} from "@/server/lib/dataforseo/serp";
|
} from "@/server/lib/dataforseo/serp";
|
||||||
import { fetchLighthouseResult } from "@/server/lib/dataforseo/lighthouse";
|
import { fetchLighthouseResult } from "@/server/lib/dataforseo/lighthouse";
|
||||||
import {
|
import {
|
||||||
@ -117,6 +118,10 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
|||||||
serp: {
|
serp: {
|
||||||
live: meter(customer, fetchLiveSerp),
|
live: meter(customer, fetchLiveSerp),
|
||||||
rankCheck: meter(customer, fetchRankCheckSerp, "rank_tracking"),
|
rankCheck: meter(customer, fetchRankCheckSerp, "rank_tracking"),
|
||||||
|
// Posts up to 100 queued rank check tasks; one metered charge covers the
|
||||||
|
// whole batch (DataForSEO bills task_post at post time, collection is
|
||||||
|
// free).
|
||||||
|
rankCheckTaskPost: meter(customer, postRankCheckTasks, "rank_tracking"),
|
||||||
local: meter(customer, fetchLocalSerp, "local_seo"),
|
local: meter(customer, fetchLocalSerp, "local_seo"),
|
||||||
},
|
},
|
||||||
labs: {
|
labs: {
|
||||||
|
|||||||
@ -82,6 +82,14 @@ export function buildTaskBilling(
|
|||||||
return billing;
|
return billing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DataForSEO's "No Search Results" (40501) — a successful empty result, not a failure. */
|
||||||
|
export function isNoResultsTask(task: DataforseoTaskLike): boolean {
|
||||||
|
return (
|
||||||
|
task.status_code === 40501 ||
|
||||||
|
(task.status_message?.toLowerCase().includes("no search results") ?? false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type AssertOkOptions = {
|
type AssertOkOptions = {
|
||||||
/** Maps a recognised access / billing failure to a product error. */
|
/** Maps a recognised access / billing failure to a product error. */
|
||||||
classify?: DataforseoErrorClassifier;
|
classify?: DataforseoErrorClassifier;
|
||||||
@ -124,11 +132,7 @@ export function assertOk<T extends DataforseoTaskLike>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (task.status_code !== 20000) {
|
if (task.status_code !== 20000) {
|
||||||
const isNoResults =
|
if (treatNoResultsAsEmpty && isNoResultsTask(task)) return task;
|
||||||
task.status_code === 40501 ||
|
|
||||||
(task.status_message?.toLowerCase().includes("no search results") ??
|
|
||||||
false);
|
|
||||||
if (treatNoResultsAsEmpty && isNoResults) return task;
|
|
||||||
|
|
||||||
const message = task.status_message || "DataForSEO task failed";
|
const message = task.status_message || "DataForSEO task failed";
|
||||||
const path = classifyPath ?? (task.path ? `/${task.path.join("/")}` : "");
|
const path = classifyPath ?? (task.path ? `/${task.path.join("/")}` : "");
|
||||||
|
|||||||
@ -18,8 +18,12 @@ export {
|
|||||||
} from "@/server/lib/dataforseo/google-ads";
|
} from "@/server/lib/dataforseo/google-ads";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
fetchRankCheckTaskResult,
|
||||||
|
MAX_TASKS_PER_POST,
|
||||||
type SerpLiveItem,
|
type SerpLiveItem,
|
||||||
type RankCheckResult,
|
type RankCheckResult,
|
||||||
|
type RankCheckTaskInput,
|
||||||
|
type PostedRankCheckTask,
|
||||||
} from "@/server/lib/dataforseo/serp";
|
} from "@/server/lib/dataforseo/serp";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
177
src/server/lib/dataforseo/serp.test.ts
Normal file
177
src/server/lib/dataforseo/serp.test.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/server/lib/runtime-env", () => ({
|
||||||
|
getRequiredEnvValue: vi.fn(async () => "test-api-key"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchRankCheckTaskResult,
|
||||||
|
postRankCheckTasks,
|
||||||
|
} from "@/server/lib/dataforseo/serp";
|
||||||
|
|
||||||
|
function parseDataforseoRequestBody(init: RequestInit | undefined): unknown {
|
||||||
|
const body = init?.body;
|
||||||
|
if (typeof body !== "string") {
|
||||||
|
throw new Error("Expected DataForSEO request body to be a string");
|
||||||
|
}
|
||||||
|
return JSON.parse(body) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("rank check task queue", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts queued tasks, maps ids by tag, and sums cost over all entries", async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
||||||
|
Response.json({
|
||||||
|
status_code: 20000,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: "task-a",
|
||||||
|
status_code: 20100,
|
||||||
|
cost: 0.0006,
|
||||||
|
data: { tag: "kw-1:desktop" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-b",
|
||||||
|
status_code: 20100,
|
||||||
|
cost: 0.0006,
|
||||||
|
data: { tag: "kw-1:mobile" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-c",
|
||||||
|
status_code: 40006,
|
||||||
|
status_message: "Task Limit Exceeded",
|
||||||
|
cost: 0.0006,
|
||||||
|
data: { tag: "kw-2:desktop" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await postRankCheckTasks({
|
||||||
|
tasks: [
|
||||||
|
{ keyword: "alpha", keywordId: "kw-1", device: "desktop" },
|
||||||
|
{ keyword: "alpha", keywordId: "kw-1", device: "mobile" },
|
||||||
|
{ keyword: "beta", keywordId: "kw-2", device: "desktop" },
|
||||||
|
],
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
depth: 20,
|
||||||
|
targetDomain: "example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.map(([url]) =>
|
||||||
|
typeof url === "string" || url instanceof URL
|
||||||
|
? url.toString()
|
||||||
|
: url.url,
|
||||||
|
),
|
||||||
|
).toEqual(["https://api.dataforseo.com/v3/serp/google/organic/task_post"]);
|
||||||
|
|
||||||
|
// Every posted task asks DataForSEO to stop crawling at the target's
|
||||||
|
// organic listing — that is what cuts the actual crawl cost for ranking
|
||||||
|
// domains without false "not ranking" stops on sitelinks/PAA mentions.
|
||||||
|
const stopCrawl = {
|
||||||
|
stop_crawl_on_match: [
|
||||||
|
{ match_value: "example.com", match_type: "with_subdomains" },
|
||||||
|
],
|
||||||
|
find_targets_in: ["organic"],
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
parseDataforseoRequestBody(fetchMock.mock.calls[0]?.[1]),
|
||||||
|
).toMatchObject([stopCrawl, stopCrawl, stopCrawl]);
|
||||||
|
expect(result.data).toEqual([
|
||||||
|
{
|
||||||
|
keyword: "alpha",
|
||||||
|
keywordId: "kw-1",
|
||||||
|
device: "desktop",
|
||||||
|
taskId: "task-a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keyword: "alpha",
|
||||||
|
keywordId: "kw-1",
|
||||||
|
device: "mobile",
|
||||||
|
taskId: "task-b",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// The rejected entry's cost is still metered: a charge is a charge.
|
||||||
|
expect(result.billing.costUsd).toBeCloseTo(0.0018, 10);
|
||||||
|
expect(result.billing.path).toEqual([
|
||||||
|
"v3",
|
||||||
|
"serp",
|
||||||
|
"google",
|
||||||
|
"organic",
|
||||||
|
"task_post",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a queued task still in progress as pending", async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
||||||
|
Response.json({
|
||||||
|
status_code: 20000,
|
||||||
|
tasks: [{ id: "task-a", status_code: 40602 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const outcome = await fetchRankCheckTaskResult({
|
||||||
|
taskId: "task-a",
|
||||||
|
keywordId: "kw-1",
|
||||||
|
keyword: "alpha",
|
||||||
|
targetDomain: "example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcome).toEqual({ status: "pending" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a completed queued task into a rank check result", async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
||||||
|
Response.json({
|
||||||
|
status_code: 20000,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: "task-a",
|
||||||
|
status_code: 20000,
|
||||||
|
cost: 0,
|
||||||
|
path: ["v3", "serp", "google", "organic", "task_get", "advanced"],
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: "organic",
|
||||||
|
rank_group: 3,
|
||||||
|
rank_absolute: 4,
|
||||||
|
domain: "www.example.com",
|
||||||
|
url: "https://www.example.com/page",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const outcome = await fetchRankCheckTaskResult({
|
||||||
|
taskId: "task-a",
|
||||||
|
keywordId: "kw-1",
|
||||||
|
keyword: "alpha",
|
||||||
|
targetDomain: "example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcome).toEqual({
|
||||||
|
status: "completed",
|
||||||
|
result: {
|
||||||
|
keywordId: "kw-1",
|
||||||
|
keyword: "alpha",
|
||||||
|
position: 4,
|
||||||
|
url: "https://www.example.com/page",
|
||||||
|
serpFeatures: ["organic"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,16 +1,45 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
|
SerpApiStopCrawlOnMatchInfo,
|
||||||
SerpGoogleLocalFinderLiveAdvancedRequestInfo,
|
SerpGoogleLocalFinderLiveAdvancedRequestInfo,
|
||||||
SerpGoogleMapsLiveAdvancedRequestInfo,
|
SerpGoogleMapsLiveAdvancedRequestInfo,
|
||||||
SerpGoogleOrganicLiveAdvancedRequestInfo,
|
SerpGoogleOrganicLiveAdvancedRequestInfo,
|
||||||
|
SerpGoogleOrganicTaskPostRequestInfo,
|
||||||
} from "dataforseo-client";
|
} from "dataforseo-client";
|
||||||
import { serpApi } from "@/server/lib/dataforseo/core";
|
import { serpApi } from "@/server/lib/dataforseo/core";
|
||||||
import {
|
import {
|
||||||
assertOk,
|
assertOk,
|
||||||
buildTaskBilling,
|
buildTaskBilling,
|
||||||
|
isNoResultsTask,
|
||||||
parseTaskItems,
|
parseTaskItems,
|
||||||
type DataforseoApiResponse,
|
type DataforseoApiResponse,
|
||||||
} from "@/server/lib/dataforseo/envelope";
|
} from "@/server/lib/dataforseo/envelope";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
|
||||||
|
/** DataForSEO bills SERPs in pages of 10; depth outside 10-100 is rejected. */
|
||||||
|
function clampSerpDepth(depth: number): number {
|
||||||
|
return Math.min(100, Math.max(10, depth));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop crawling SERP pages once the target domain is found — DataForSEO only
|
||||||
|
* bills the pages crawled, so a page-1 ranking at depth 20 costs one page
|
||||||
|
* instead of two. Matching is restricted to organic results and uses
|
||||||
|
* with_subdomains, mirroring buildRankCheckResult exactly: without
|
||||||
|
* find_targets_in, a sitelink or PAA mention could stop the crawl before the
|
||||||
|
* domain's organic listing and record a false "not ranking".
|
||||||
|
*/
|
||||||
|
function stopCrawlOnTarget(targetDomain: string) {
|
||||||
|
return {
|
||||||
|
stop_crawl_on_match: [
|
||||||
|
new SerpApiStopCrawlOnMatchInfo({
|
||||||
|
match_value: targetDomain,
|
||||||
|
match_type: "with_subdomains",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
find_targets_in: ["organic"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Kept as a hand-written schema: the SDK's BaseSerpApiElementItem type omits
|
// Kept as a hand-written schema: the SDK's BaseSerpApiElementItem type omits
|
||||||
// etv / estimated_paid_traffic_cost / backlinks_info / rank_changes, which we
|
// etv / estimated_paid_traffic_cost / backlinks_info / rank_changes, which we
|
||||||
@ -85,6 +114,28 @@ export interface RankCheckResult {
|
|||||||
serpFeatures: string[];
|
serpFeatures: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRankCheckResult(
|
||||||
|
input: { keywordId: string; keyword: string; targetDomain: string },
|
||||||
|
items: SerpLiveItem[],
|
||||||
|
): RankCheckResult {
|
||||||
|
const target = input.targetDomain.toLowerCase();
|
||||||
|
const organicMatch = items.find((item) => {
|
||||||
|
if (item.type !== "organic" || item.domain == null) return false;
|
||||||
|
const domain = item.domain.toLowerCase();
|
||||||
|
return domain === target || domain.endsWith(`.${target}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
keywordId: input.keywordId,
|
||||||
|
keyword: input.keyword,
|
||||||
|
position: organicMatch
|
||||||
|
? (organicMatch.rank_absolute ?? organicMatch.rank_group ?? null)
|
||||||
|
: null,
|
||||||
|
url: organicMatch?.url ?? null,
|
||||||
|
serpFeatures: [...new Set(items.map((item) => item.type).filter(Boolean))],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchRankCheckSerp(input: {
|
export async function fetchRankCheckSerp(input: {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
keywordId: string;
|
keywordId: string;
|
||||||
@ -94,7 +145,7 @@ export async function fetchRankCheckSerp(input: {
|
|||||||
targetDomain: string;
|
targetDomain: string;
|
||||||
depth: number;
|
depth: number;
|
||||||
}): Promise<DataforseoApiResponse<RankCheckResult>> {
|
}): Promise<DataforseoApiResponse<RankCheckResult>> {
|
||||||
const depth = Math.min(100, Math.max(10, input.depth));
|
const depth = clampSerpDepth(input.depth);
|
||||||
const response = await serpApi().googleOrganicLiveAdvanced([
|
const response = await serpApi().googleOrganicLiveAdvanced([
|
||||||
new SerpGoogleOrganicLiveAdvancedRequestInfo({
|
new SerpGoogleOrganicLiveAdvancedRequestInfo({
|
||||||
keyword: input.keyword,
|
keyword: input.keyword,
|
||||||
@ -103,6 +154,7 @@ export async function fetchRankCheckSerp(input: {
|
|||||||
device: input.device,
|
device: input.device,
|
||||||
os: input.device === "desktop" ? "windows" : "android",
|
os: input.device === "desktop" ? "windows" : "android",
|
||||||
depth,
|
depth,
|
||||||
|
...stopCrawlOnTarget(input.targetDomain),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -115,29 +167,169 @@ export async function fetchRankCheckSerp(input: {
|
|||||||
serpSnapshotItemSchema,
|
serpSnapshotItemSchema,
|
||||||
);
|
);
|
||||||
|
|
||||||
const target = input.targetDomain.toLowerCase();
|
|
||||||
const organicMatch = items.find((item) => {
|
|
||||||
if (item.type !== "organic" || item.domain == null) return false;
|
|
||||||
const domain = item.domain.toLowerCase();
|
|
||||||
return domain === target || domain.endsWith(`.${target}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: buildRankCheckResult(input, items),
|
||||||
keywordId: input.keywordId,
|
|
||||||
keyword: input.keyword,
|
|
||||||
position: organicMatch
|
|
||||||
? (organicMatch.rank_absolute ?? organicMatch.rank_group ?? null)
|
|
||||||
: null,
|
|
||||||
url: organicMatch?.url ?? null,
|
|
||||||
serpFeatures: [
|
|
||||||
...new Set(items.map((item) => item.type).filter(Boolean)),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
billing: buildTaskBilling(task),
|
billing: buildTaskBilling(task),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task-queue rank checks (scheduled runs). DataForSEO's standard queue costs
|
||||||
|
// ~30% of the live endpoint; tasks complete in ~5 minutes on average. The flow
|
||||||
|
// is task_post (charged) -> poll task_get (free) -> live fallback for
|
||||||
|
// stragglers, orchestrated by the rank check workflow.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Max tasks DataForSEO accepts in a single task_post request. */
|
||||||
|
export const MAX_TASKS_PER_POST = 100;
|
||||||
|
|
||||||
|
export interface RankCheckTaskInput {
|
||||||
|
keyword: string;
|
||||||
|
keywordId: string;
|
||||||
|
device: "desktop" | "mobile";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostedRankCheckTask extends RankCheckTaskInput {
|
||||||
|
taskId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postRankCheckTasks(input: {
|
||||||
|
tasks: RankCheckTaskInput[];
|
||||||
|
locationCode: number;
|
||||||
|
languageCode: string;
|
||||||
|
depth: number;
|
||||||
|
targetDomain: string;
|
||||||
|
}): Promise<DataforseoApiResponse<PostedRankCheckTask[]>> {
|
||||||
|
if (input.tasks.length === 0 || input.tasks.length > MAX_TASKS_PER_POST) {
|
||||||
|
throw new AppError(
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
`task_post accepts 1-${MAX_TASKS_PER_POST} tasks, got ${input.tasks.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const depth = clampSerpDepth(input.depth);
|
||||||
|
const response = await serpApi().googleOrganicTaskPost(
|
||||||
|
input.tasks.map(
|
||||||
|
(task) =>
|
||||||
|
new SerpGoogleOrganicTaskPostRequestInfo({
|
||||||
|
keyword: task.keyword,
|
||||||
|
location_code: input.locationCode,
|
||||||
|
language_code: input.languageCode,
|
||||||
|
device: task.device,
|
||||||
|
os: task.device === "desktop" ? "windows" : "android",
|
||||||
|
depth,
|
||||||
|
// Queued tasks are billed provisionally at full depth at post time;
|
||||||
|
// task_get later reports the reduced actual cost when the crawl
|
||||||
|
// stopped early. We meter customers on the post-time amount —
|
||||||
|
// collection-time metering is a possible future optimization.
|
||||||
|
...stopCrawlOnTarget(input.targetDomain),
|
||||||
|
// Echoed back on the response entry and task_get; used to map a
|
||||||
|
// DataForSEO task id back to our keyword without relying on order.
|
||||||
|
tag: `${task.keywordId}:${task.device}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response || response.status_code !== 20000) {
|
||||||
|
throw new AppError(
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
response?.status_message || "DataForSEO task_post failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One response entry per submitted task; accepted entries have status 20100
|
||||||
|
// "Task Created" and their own cost (charged at post time). Cost is summed
|
||||||
|
// over every entry — accepted or not — so anything DataForSEO charged is
|
||||||
|
// metered. Rejected entries get no posted task; the workflow falls back to
|
||||||
|
// the live endpoint for any keyword/device pair missing from the result.
|
||||||
|
const byTag = new Map(
|
||||||
|
input.tasks.map((task) => [`${task.keywordId}:${task.device}`, task]),
|
||||||
|
);
|
||||||
|
const posted: PostedRankCheckTask[] = [];
|
||||||
|
let costUsd = 0;
|
||||||
|
for (const entry of response.tasks ?? []) {
|
||||||
|
costUsd += entry.cost ?? 0;
|
||||||
|
const tag: unknown = entry.data?.tag;
|
||||||
|
const task = typeof tag === "string" ? byTag.get(tag) : undefined;
|
||||||
|
if (entry.status_code !== 20100 || !entry.id || !task) {
|
||||||
|
console.warn(
|
||||||
|
`dataforseo.task_post.rejected-entry (${entry.status_code}): ${entry.status_message}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
posted.push({ ...task, taskId: entry.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: posted,
|
||||||
|
billing: {
|
||||||
|
path: ["v3", "serp", "google", "organic", "task_post"],
|
||||||
|
costUsd,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type RankCheckTaskOutcome =
|
||||||
|
| { status: "pending" }
|
||||||
|
| { status: "failed"; message: string }
|
||||||
|
| { status: "completed"; result: RankCheckResult };
|
||||||
|
|
||||||
|
// Task lifecycle codes meaning "not done yet": Task Created / Task Handed /
|
||||||
|
// Task In Queue.
|
||||||
|
const TASK_IN_PROGRESS_STATUS_CODES = new Set([20100, 40601, 40602]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect one queued task's result. Deliberately not metered and not wrapped
|
||||||
|
* in the billing envelope: collection is free (the task was charged at
|
||||||
|
* task_post), and the task_get response carries the task's settled cost
|
||||||
|
* (reduced when stop_crawl_on_match ended the crawl early) — running it
|
||||||
|
* through the metering seam would charge the customer twice.
|
||||||
|
*/
|
||||||
|
export async function fetchRankCheckTaskResult(input: {
|
||||||
|
taskId: string;
|
||||||
|
keywordId: string;
|
||||||
|
keyword: string;
|
||||||
|
targetDomain: string;
|
||||||
|
}): Promise<RankCheckTaskOutcome> {
|
||||||
|
const response = await serpApi().googleOrganicTaskGetAdvanced(input.taskId);
|
||||||
|
const task = response?.tasks?.[0];
|
||||||
|
if (!response || response.status_code !== 20000 || !task) {
|
||||||
|
throw new AppError(
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
response?.status_message || "DataForSEO task_get failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
task.status_code !== undefined &&
|
||||||
|
TASK_IN_PROGRESS_STATUS_CODES.has(task.status_code)
|
||||||
|
) {
|
||||||
|
return { status: "pending" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.status_code !== 20000) {
|
||||||
|
// "No Search Results" is valid for obscure/new keywords — same treatment
|
||||||
|
// as the live path's treatNoResultsAsEmpty.
|
||||||
|
if (!isNoResultsTask(task)) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
message:
|
||||||
|
task.status_message || `DataForSEO task failed (${task.status_code})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "completed",
|
||||||
|
result: buildRankCheckResult(input, []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = parseTaskItems(
|
||||||
|
"google-organic-task-get-advanced",
|
||||||
|
task,
|
||||||
|
serpSnapshotItemSchema,
|
||||||
|
);
|
||||||
|
return { status: "completed", result: buildRankCheckResult(input, items) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchLocalSerp(input: {
|
export async function fetchLocalSerp(input: {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
locationCoordinate?: string;
|
locationCoordinate?: string;
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import { NonRetryableError } from "cloudflare:workflows";
|
|||||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
||||||
import { failRunIfActive } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
|
import { failRunIfActive } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
|
||||||
import { runLiveCheck } from "@/server/workflows/rankCheckPaths";
|
import {
|
||||||
|
runLiveCheck,
|
||||||
|
runQueuedCheck,
|
||||||
|
type QueuedCheckStats,
|
||||||
|
} from "@/server/workflows/rankCheckPaths";
|
||||||
import { createDataforseoClient } from "@/server/lib/dataforseo";
|
import { createDataforseoClient } from "@/server/lib/dataforseo";
|
||||||
import { captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
@ -44,6 +48,7 @@ async function prepareRankCheckKeywords(input: {
|
|||||||
billingCustomer: BillingCustomerContext;
|
billingCustomer: BillingCustomerContext;
|
||||||
devices: RankCheckParams["devices"];
|
devices: RankCheckParams["devices"];
|
||||||
serpDepth: number;
|
serpDepth: number;
|
||||||
|
trigger: RankCheckParams["trigger"];
|
||||||
keywordIds?: string[];
|
keywordIds?: string[];
|
||||||
}) {
|
}) {
|
||||||
// If stale-cleanup marked our run failed before we got here, bail out
|
// If stale-cleanup marked our run failed before we got here, bail out
|
||||||
@ -72,12 +77,15 @@ async function prepareRankCheckKeywords(input: {
|
|||||||
throw new AppError("INTERNAL_ERROR", "No keywords to track");
|
throw new AppError("INTERNAL_ERROR", "No keywords to track");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the user has enough credits for the full check before starting
|
// Verify the user has enough credits for the full check before starting.
|
||||||
|
// Scheduled checks go through the cheaper task queue, so estimate at queued
|
||||||
|
// pricing — a live-price estimate would skip checks the user can afford.
|
||||||
if (await isHostedServerAuthMode()) {
|
if (await isHostedServerAuthMode()) {
|
||||||
const { costCredits } = estimateRankCheckCredits(
|
const { costCredits } = estimateRankCheckCredits(
|
||||||
trackingKeywords.length,
|
trackingKeywords.length,
|
||||||
input.devices,
|
input.devices,
|
||||||
input.serpDepth,
|
input.serpDepth,
|
||||||
|
input.trigger === "scheduled" ? "queued" : "live",
|
||||||
);
|
);
|
||||||
const [monthlyCheck, topupCheck] = await Promise.all([
|
const [monthlyCheck, topupCheck] = await Promise.all([
|
||||||
autumn.check({
|
autumn.check({
|
||||||
@ -119,6 +127,7 @@ async function finalizeRankCheckRun(input: {
|
|||||||
billingCustomer: BillingCustomerContext;
|
billingCustomer: BillingCustomerContext;
|
||||||
trigger: RankCheckParams["trigger"];
|
trigger: RankCheckParams["trigger"];
|
||||||
batchError: string | null;
|
batchError: string | null;
|
||||||
|
queueStats: QueuedCheckStats | null;
|
||||||
}) {
|
}) {
|
||||||
// If stale-cleanup already marked our run failed, don't overwrite that
|
// If stale-cleanup already marked our run failed, don't overwrite that
|
||||||
// decision with a completed status — a replacement run may already be
|
// decision with a completed status — a replacement run may already be
|
||||||
@ -168,6 +177,19 @@ async function finalizeRankCheckRun(input: {
|
|||||||
lastSkipReason: null,
|
lastSkipReason: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// One-line summary per run so fallback rates are visible in Workers Logs.
|
||||||
|
// Keys match the PostHog event properties for log/event correlation.
|
||||||
|
const queueSummary = input.queueStats
|
||||||
|
? ` queue_tasks=${input.queueStats.queueTasks} queue_collected=${input.queueStats.queueCollected} fallback_tasks=${input.queueStats.fallbackTasks} fallback_checked=${input.queueStats.fallbackChecked}`
|
||||||
|
: "";
|
||||||
|
// Error text can echo vendor/user content — keep it one line and bounded.
|
||||||
|
const errorSummary = errorMessage
|
||||||
|
? ` error="${errorMessage.replace(/\s+/g, " ").slice(0, 200)}"`
|
||||||
|
: "";
|
||||||
|
console.log(
|
||||||
|
`[rank-check] ${input.runId} completed org=${input.billingCustomer.organizationId} project=${input.projectId} trigger=${input.trigger} keywords=${keywordsChecked}/${keywordsTotal}${queueSummary}${errorSummary}`,
|
||||||
|
);
|
||||||
|
|
||||||
await captureServerEvent({
|
await captureServerEvent({
|
||||||
distinctId: input.billingCustomer.userId,
|
distinctId: input.billingCustomer.userId,
|
||||||
event: "rank_tracking:check_complete",
|
event: "rank_tracking:check_complete",
|
||||||
@ -177,6 +199,14 @@ async function finalizeRankCheckRun(input: {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
trigger: input.trigger,
|
trigger: input.trigger,
|
||||||
keywords_checked: keywordsChecked,
|
keywords_checked: keywordsChecked,
|
||||||
|
...(input.queueStats
|
||||||
|
? {
|
||||||
|
queue_tasks: input.queueStats.queueTasks,
|
||||||
|
queue_collected: input.queueStats.queueCollected,
|
||||||
|
fallback_tasks: input.queueStats.fallbackTasks,
|
||||||
|
fallback_checked: input.queueStats.fallbackChecked,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -267,6 +297,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
|||||||
billingCustomer,
|
billingCustomer,
|
||||||
devices,
|
devices,
|
||||||
serpDepth,
|
serpDepth,
|
||||||
|
trigger,
|
||||||
keywordIds,
|
keywordIds,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -276,9 +307,10 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
|||||||
console.log(`[rank-check] ${runId} loaded ${keywords.length} keywords`);
|
console.log(`[rank-check] ${runId} loaded ${keywords.length} keywords`);
|
||||||
|
|
||||||
let batchError: string | null = null;
|
let batchError: string | null = null;
|
||||||
|
let queueStats: QueuedCheckStats | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runLiveCheck(step, {
|
const checkContext = {
|
||||||
client,
|
client,
|
||||||
keywords,
|
keywords,
|
||||||
devices,
|
devices,
|
||||||
@ -287,7 +319,14 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
|||||||
locationCode,
|
locationCode,
|
||||||
languageCode,
|
languageCode,
|
||||||
runId,
|
runId,
|
||||||
});
|
};
|
||||||
|
// Scheduled checks use DataForSEO's task queue (~30% of live cost);
|
||||||
|
// manual checks stay on the live endpoint for instant results.
|
||||||
|
if (trigger === "scheduled") {
|
||||||
|
queueStats = await runQueuedCheck(step, checkContext);
|
||||||
|
} else {
|
||||||
|
await runLiveCheck(step, checkContext);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Batch failure — snapshots for completed batches are already
|
// Batch failure — snapshots for completed batches are already
|
||||||
// persisted incrementally. Continue to finalization.
|
// persisted incrementally. Continue to finalization.
|
||||||
@ -303,6 +342,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
|||||||
billingCustomer,
|
billingCustomer,
|
||||||
trigger,
|
trigger,
|
||||||
batchError,
|
batchError,
|
||||||
|
queueStats,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
import type { WorkflowStep } from "cloudflare:workers";
|
import type { WorkflowStep } from "cloudflare:workers";
|
||||||
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
||||||
import type { createDataforseoClient } from "@/server/lib/dataforseo";
|
import {
|
||||||
import type { RankCheckResult } from "@/server/lib/dataforseo";
|
fetchRankCheckTaskResult,
|
||||||
|
MAX_TASKS_PER_POST,
|
||||||
|
} from "@/server/lib/dataforseo";
|
||||||
|
import type {
|
||||||
|
createDataforseoClient,
|
||||||
|
PostedRankCheckTask,
|
||||||
|
RankCheckResult,
|
||||||
|
RankCheckTaskInput,
|
||||||
|
} from "@/server/lib/dataforseo";
|
||||||
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
||||||
import { KEYWORDS_PER_BATCH } from "@/shared/rank-tracking";
|
import { KEYWORDS_PER_BATCH } from "@/shared/rank-tracking";
|
||||||
|
|
||||||
@ -42,6 +50,71 @@ interface CheckContext {
|
|||||||
runId: string;
|
runId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Expand keywords into one task input per keyword/device pair. */
|
||||||
|
function expandToTaskInputs(
|
||||||
|
keywords: KeywordEntry[],
|
||||||
|
devices: RankTrackingConfig["devices"],
|
||||||
|
): RankCheckTaskInput[] {
|
||||||
|
const deviceList: Array<"desktop" | "mobile"> =
|
||||||
|
devices === "both" ? ["desktop", "mobile"] : [devices];
|
||||||
|
return keywords.flatMap((kw) =>
|
||||||
|
deviceList.map((device) => ({
|
||||||
|
keyword: kw.keyword,
|
||||||
|
keywordId: kw.id,
|
||||||
|
device,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step bodies. Each runs inside a single step.do: inputs are its parameters,
|
||||||
|
// the return value is what the workflow engine persists and replays. They must
|
||||||
|
// not touch any mutable state outside their arguments.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check keyword/device pairs against the live endpoint and persist snapshots.
|
||||||
|
* Per-call failures are logged and skipped (the metered client already charged
|
||||||
|
* or refused each call individually). Returns the snapshot count written.
|
||||||
|
*/
|
||||||
|
async function checkBatchLive(
|
||||||
|
ctx: CheckContext,
|
||||||
|
tasks: RankCheckTaskInput[],
|
||||||
|
): Promise<number> {
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
tasks.map((task) =>
|
||||||
|
ctx.client.serp
|
||||||
|
.rankCheck({
|
||||||
|
keyword: task.keyword,
|
||||||
|
keywordId: task.keywordId,
|
||||||
|
locationCode: ctx.locationCode,
|
||||||
|
languageCode: ctx.languageCode,
|
||||||
|
device: task.device,
|
||||||
|
targetDomain: ctx.domain,
|
||||||
|
depth: ctx.serpDepth,
|
||||||
|
})
|
||||||
|
.then((r) => ({ ...r, device: task.device })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const results: RankCheckResultWithDevice[] = [];
|
||||||
|
for (const outcome of settled) {
|
||||||
|
if (outcome.status === "fulfilled") {
|
||||||
|
results.push(outcome.value);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`[rank-check] ${ctx.runId} live call failed:`,
|
||||||
|
outcome.reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.length > 0) {
|
||||||
|
await RankTrackingRepository.insertSnapshots(
|
||||||
|
mapResultsToSnapshotRows(ctx.runId, results),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return results.length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check keywords via Live API, parallel devices per keyword, real-time progress.
|
* Check keywords via Live API, parallel devices per keyword, real-time progress.
|
||||||
* Snapshots are written incrementally after each batch so partial results
|
* Snapshots are written incrementally after each batch so partial results
|
||||||
@ -52,53 +125,256 @@ export async function runLiveCheck(
|
|||||||
step: WorkflowStep,
|
step: WorkflowStep,
|
||||||
ctx: CheckContext,
|
ctx: CheckContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const deviceList: Array<"desktop" | "mobile"> =
|
|
||||||
ctx.devices === "both" ? ["desktop", "mobile"] : [ctx.devices];
|
|
||||||
let checked = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < ctx.keywords.length; i += KEYWORDS_PER_BATCH) {
|
for (let i = 0; i < ctx.keywords.length; i += KEYWORDS_PER_BATCH) {
|
||||||
const batch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH);
|
const keywordBatch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH);
|
||||||
|
const batchTasks = expandToTaskInputs(keywordBatch, ctx.devices);
|
||||||
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
|
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
|
||||||
|
const keywordsChecked = i + keywordBatch.length;
|
||||||
|
|
||||||
await step.do(
|
await step.do(
|
||||||
`live-batch-${batchIndex}`,
|
`live-batch-${batchIndex}`,
|
||||||
SINGLE_ATTEMPT_STEP_CONFIG,
|
SINGLE_ATTEMPT_STEP_CONFIG,
|
||||||
async () => {
|
async () => {
|
||||||
const promises = batch.flatMap((kw) =>
|
const written = await checkBatchLive(ctx, batchTasks);
|
||||||
deviceList.map((device) =>
|
// Progress for the UI; finalize recounts from the DB anyway.
|
||||||
ctx.client.serp
|
|
||||||
.rankCheck({
|
|
||||||
keyword: kw.keyword,
|
|
||||||
keywordId: kw.id,
|
|
||||||
locationCode: ctx.locationCode,
|
|
||||||
languageCode: ctx.languageCode,
|
|
||||||
device,
|
|
||||||
targetDomain: ctx.domain,
|
|
||||||
depth: ctx.serpDepth,
|
|
||||||
})
|
|
||||||
.then((r) => ({ ...r, device })),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const settled = await Promise.allSettled(promises);
|
|
||||||
const results: RankCheckResultWithDevice[] = [];
|
|
||||||
for (const outcome of settled) {
|
|
||||||
if (outcome.status === "fulfilled") {
|
|
||||||
results.push(outcome.value);
|
|
||||||
} else {
|
|
||||||
console.error("Rank check call failed:", outcome.reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checked += batch.length;
|
|
||||||
await RankTrackingRepository.updateRun(ctx.runId, {
|
await RankTrackingRepository.updateRun(ctx.runId, {
|
||||||
keywordsChecked: checked,
|
keywordsChecked,
|
||||||
});
|
});
|
||||||
|
return written;
|
||||||
if (results.length > 0) {
|
|
||||||
await RankTrackingRepository.insertSnapshots(
|
|
||||||
mapResultsToSnapshotRows(ctx.runId, results),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poll cadence for queued tasks. Standard-priority tasks complete in ~5
|
||||||
|
// minutes on average, so the first check waits 4 minutes; cumulative waits are
|
||||||
|
// 4 / 6 / 8 / 10 / 12 / 15 minutes, after which stragglers fall back to the
|
||||||
|
// live endpoint.
|
||||||
|
const QUEUED_POLL_INTERVALS = [
|
||||||
|
"4 minutes",
|
||||||
|
"2 minutes",
|
||||||
|
"2 minutes",
|
||||||
|
"2 minutes",
|
||||||
|
"2 minutes",
|
||||||
|
"3 minutes",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Concurrent task_get requests within a collect step. */
|
||||||
|
const TASK_GET_CONCURRENCY = 25;
|
||||||
|
|
||||||
|
/** Max task_get calls per collect round (per-invocation subrequest budget). */
|
||||||
|
const TASK_GETS_PER_COLLECT = 500;
|
||||||
|
|
||||||
|
// Collect steps may issue hundreds of task_get calls, so they get more room
|
||||||
|
// than SINGLE_ATTEMPT_STEP_CONFIG's 2-minute timeout. Unlike the metered
|
||||||
|
// steps, retrying is safe and free: task_get isn't charged and snapshot
|
||||||
|
// inserts are onConflictDoNothing.
|
||||||
|
const COLLECT_STEP_CONFIG = {
|
||||||
|
retries: { limit: 2, delay: "10 seconds" as const },
|
||||||
|
timeout: "5 minutes" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CollectRoundOutcome {
|
||||||
|
/** Snapshots written this round. */
|
||||||
|
collected: number;
|
||||||
|
/** Tasks still in DataForSEO's queue — poll again next round. */
|
||||||
|
stillPending: PostedRankCheckTask[];
|
||||||
|
/** Tasks DataForSEO failed — route to the live fallback. */
|
||||||
|
failed: PostedRankCheckTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch results for queued tasks (one free task_get each), persist completed
|
||||||
|
* snapshots, and update run progress. Transient task_get failures stay
|
||||||
|
* pending for the next round.
|
||||||
|
*/
|
||||||
|
async function collectQueuedRound(
|
||||||
|
ctx: CheckContext,
|
||||||
|
tasks: PostedRankCheckTask[],
|
||||||
|
): Promise<CollectRoundOutcome> {
|
||||||
|
const completed: RankCheckResultWithDevice[] = [];
|
||||||
|
const stillPending: PostedRankCheckTask[] = [];
|
||||||
|
const failed: PostedRankCheckTask[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < tasks.length; i += TASK_GET_CONCURRENCY) {
|
||||||
|
const chunk = tasks.slice(i, i + TASK_GET_CONCURRENCY);
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
chunk.map((task) =>
|
||||||
|
fetchRankCheckTaskResult({
|
||||||
|
taskId: task.taskId,
|
||||||
|
keywordId: task.keywordId,
|
||||||
|
keyword: task.keyword,
|
||||||
|
targetDomain: ctx.domain,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
settled.forEach((result, index) => {
|
||||||
|
const task = chunk[index];
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
// Transient fetch failure — try again next round.
|
||||||
|
console.warn(
|
||||||
|
`[rank-check] ${ctx.runId} task_get failed:`,
|
||||||
|
result.reason,
|
||||||
|
);
|
||||||
|
stillPending.push(task);
|
||||||
|
} else if (result.value.status === "pending") {
|
||||||
|
stillPending.push(task);
|
||||||
|
} else if (result.value.status === "failed") {
|
||||||
|
console.warn(
|
||||||
|
`[rank-check] ${ctx.runId} task ${task.taskId} failed: ${result.value.message}`,
|
||||||
|
);
|
||||||
|
failed.push(task);
|
||||||
|
} else {
|
||||||
|
completed.push({ ...result.value.result, device: task.device });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed.length > 0) {
|
||||||
|
await RankTrackingRepository.insertSnapshots(
|
||||||
|
mapResultsToSnapshotRows(ctx.runId, completed),
|
||||||
|
);
|
||||||
|
// Progress for the UI; finalize recounts from the DB anyway.
|
||||||
|
const snapshots = await RankTrackingRepository.getSnapshotsForRun(
|
||||||
|
ctx.runId,
|
||||||
|
);
|
||||||
|
await RankTrackingRepository.updateRun(ctx.runId, {
|
||||||
|
keywordsChecked: new Set(snapshots.map((s) => s.trackingKeywordId)).size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { collected: completed.length, stillPending, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-run accounting for the queued path, in keyword/device task units. */
|
||||||
|
export interface QueuedCheckStats {
|
||||||
|
/** Tasks accepted into DataForSEO's queue. */
|
||||||
|
queueTasks: number;
|
||||||
|
/** Task results collected from the queue within the polling window. */
|
||||||
|
queueCollected: number;
|
||||||
|
/** Tasks routed to the live fallback (rejected, failed, or timed out). */
|
||||||
|
fallbackTasks: number;
|
||||||
|
/** Fallback tasks that produced a snapshot. */
|
||||||
|
fallbackChecked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check keywords via DataForSEO's standard task queue (~30% of live cost).
|
||||||
|
* Posts every keyword/device pair as a queued task, then polls task_get for
|
||||||
|
* ~15 minutes, writing snapshots incrementally as tasks complete. Anything
|
||||||
|
* still unfinished after the polling window — plus tasks DataForSEO rejected
|
||||||
|
* or failed — gets one shot at the live endpoint so a run never hangs on a
|
||||||
|
* stuck queue. Billing happens at task_post (and per live-fallback call).
|
||||||
|
*/
|
||||||
|
export async function runQueuedCheck(
|
||||||
|
step: WorkflowStep,
|
||||||
|
ctx: CheckContext,
|
||||||
|
): Promise<QueuedCheckStats> {
|
||||||
|
const taskInputs = expandToTaskInputs(ctx.keywords, ctx.devices);
|
||||||
|
|
||||||
|
// Post all tasks to the queue, <=100 per request, one metered step each.
|
||||||
|
// A failed chunk must not abort the run — earlier chunks were already
|
||||||
|
// charged at DataForSEO, so their results have to be collected. The failed
|
||||||
|
// chunk's pairs go to the live fallback instead.
|
||||||
|
let pending: PostedRankCheckTask[] = [];
|
||||||
|
const fallback: RankCheckTaskInput[] = [];
|
||||||
|
for (let i = 0; i < taskInputs.length; i += MAX_TASKS_PER_POST) {
|
||||||
|
const chunk = taskInputs.slice(i, i + MAX_TASKS_PER_POST);
|
||||||
|
const postIndex = Math.floor(i / MAX_TASKS_PER_POST);
|
||||||
|
let posted: PostedRankCheckTask[];
|
||||||
|
try {
|
||||||
|
posted = await step.do(
|
||||||
|
`post-tasks-${postIndex}`,
|
||||||
|
SINGLE_ATTEMPT_STEP_CONFIG,
|
||||||
|
async () =>
|
||||||
|
ctx.client.serp.rankCheckTaskPost({
|
||||||
|
tasks: chunk,
|
||||||
|
locationCode: ctx.locationCode,
|
||||||
|
languageCode: ctx.languageCode,
|
||||||
|
depth: ctx.serpDepth,
|
||||||
|
targetDomain: ctx.domain,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[rank-check] ${ctx.runId} post-tasks-${postIndex} failed:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
fallback.push(...chunk);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending.push(...posted);
|
||||||
|
if (posted.length < chunk.length) {
|
||||||
|
const acceptedKeys = new Set(
|
||||||
|
posted.map((t) => `${t.keywordId}:${t.device}`),
|
||||||
|
);
|
||||||
|
fallback.push(
|
||||||
|
...chunk.filter((t) => !acceptedKeys.has(`${t.keywordId}:${t.device}`)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: QueuedCheckStats = {
|
||||||
|
queueTasks: pending.length,
|
||||||
|
queueCollected: 0,
|
||||||
|
fallbackTasks: 0,
|
||||||
|
fallbackChecked: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Poll until everything is collected or the ~15 minute window closes. A
|
||||||
|
// collect failure (past its retries) leaves that round's tasks pending for
|
||||||
|
// the next round — or the live fallback — instead of failing the run; the
|
||||||
|
// posted tasks are already paid for.
|
||||||
|
for (
|
||||||
|
let round = 0;
|
||||||
|
round < QUEUED_POLL_INTERVALS.length && pending.length > 0;
|
||||||
|
round++
|
||||||
|
) {
|
||||||
|
await step.sleep(`wait-${round}`, QUEUED_POLL_INTERVALS[round]);
|
||||||
|
|
||||||
|
// Cap task_gets per round so one collect step stays well inside the
|
||||||
|
// per-invocation subrequest limit at the 1000-keyword config ceiling.
|
||||||
|
const batch = pending.slice(0, TASK_GETS_PER_COLLECT);
|
||||||
|
const overflow = pending.slice(TASK_GETS_PER_COLLECT);
|
||||||
|
|
||||||
|
let outcome: CollectRoundOutcome;
|
||||||
|
try {
|
||||||
|
outcome = await step.do(`collect-${round}`, COLLECT_STEP_CONFIG, () =>
|
||||||
|
collectQueuedRound(ctx, batch),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[rank-check] ${ctx.runId} collect-${round} failed:`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.queueCollected += outcome.collected;
|
||||||
|
pending = [...outcome.stillPending, ...overflow];
|
||||||
|
fallback.push(...outcome.failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live fallback: queued tasks that never finished, failed, or were rejected
|
||||||
|
// at post time. A straggler is double-billed (customer was metered the
|
||||||
|
// queued post cost and now the live call too — fractions of a cent).
|
||||||
|
// Progress isn't updated here; finalize recounts keywordsChecked from the
|
||||||
|
// DB.
|
||||||
|
const stragglers: RankCheckTaskInput[] = [...fallback, ...pending];
|
||||||
|
stats.fallbackTasks = stragglers.length;
|
||||||
|
if (stragglers.length === 0) return stats;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[rank-check] ${ctx.runId} live fallback for ${stragglers.length} task(s)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < stragglers.length; i += KEYWORDS_PER_BATCH) {
|
||||||
|
const batch = stragglers.slice(i, i + KEYWORDS_PER_BATCH);
|
||||||
|
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
|
||||||
|
|
||||||
|
stats.fallbackChecked += await step.do(
|
||||||
|
`fallback-batch-${batchIndex}`,
|
||||||
|
SINGLE_ATTEMPT_STEP_CONFIG,
|
||||||
|
() => checkBatchLive(ctx, batch),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|||||||
@ -10,10 +10,22 @@ import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** DataForSEO Live API: cost of first page (10 results) */
|
/** DataForSEO Live API: cost of first page (10 results) */
|
||||||
const BASE_PAGE_COST_USD = 0.002;
|
const LIVE_BASE_PAGE_COST_USD = 0.002;
|
||||||
|
|
||||||
/** DataForSEO Live API: cost of each additional page (75% of base) */
|
/** DataForSEO Live API: cost of each additional page (75% of base) */
|
||||||
const EXTRA_PAGE_COST_USD = 0.0015;
|
const LIVE_EXTRA_PAGE_COST_USD = 0.0015;
|
||||||
|
|
||||||
|
/** DataForSEO task queue (standard priority): cost of first page (10 results) */
|
||||||
|
const QUEUED_BASE_PAGE_COST_USD = 0.0006;
|
||||||
|
|
||||||
|
/** DataForSEO task queue (standard priority): cost of each additional page (75% of base) */
|
||||||
|
const QUEUED_EXTRA_PAGE_COST_USD = 0.00045;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a rank check reaches DataForSEO: "live" is the instant endpoint used for
|
||||||
|
* manual checks; "queued" is the cheaper task queue used for scheduled checks.
|
||||||
|
*/
|
||||||
|
type RankCheckMethod = "live" | "queued";
|
||||||
|
|
||||||
/** How many keywords are checked per batch */
|
/** How many keywords are checked per batch */
|
||||||
export const KEYWORDS_PER_BATCH = 10;
|
export const KEYWORDS_PER_BATCH = 10;
|
||||||
@ -32,9 +44,11 @@ export const MAX_CONFIGS_PER_PROJECT = 20;
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** DataForSEO cost for a single SERP request at the given depth. */
|
/** DataForSEO cost for a single SERP request at the given depth. */
|
||||||
function costPerSerpAtDepth(depth: number): number {
|
function costPerSerpAtDepth(depth: number, method: RankCheckMethod): number {
|
||||||
const pages = depth / 10;
|
const pages = depth / 10;
|
||||||
return BASE_PAGE_COST_USD + (pages - 1) * EXTRA_PAGE_COST_USD;
|
return method === "queued"
|
||||||
|
? QUEUED_BASE_PAGE_COST_USD + (pages - 1) * QUEUED_EXTRA_PAGE_COST_USD
|
||||||
|
: LIVE_BASE_PAGE_COST_USD + (pages - 1) * LIVE_EXTRA_PAGE_COST_USD;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function depthToPages(depth: number): number {
|
export function depthToPages(depth: number): number {
|
||||||
@ -49,10 +63,11 @@ export function estimateRankCheckCredits(
|
|||||||
keywordCount: number,
|
keywordCount: number,
|
||||||
devices: RankTrackingConfig["devices"],
|
devices: RankTrackingConfig["devices"],
|
||||||
depth: number,
|
depth: number,
|
||||||
|
method: RankCheckMethod,
|
||||||
) {
|
) {
|
||||||
const totalChecks = keywordCount * devicesCount(devices);
|
const totalChecks = keywordCount * devicesCount(devices);
|
||||||
const costUsd = roundUsdForBilling(
|
const costUsd = roundUsdForBilling(
|
||||||
totalChecks * costPerSerpAtDepth(depth) * SEO_DATA_COST_MARKUP,
|
totalChecks * costPerSerpAtDepth(depth, method) * SEO_DATA_COST_MARKUP,
|
||||||
);
|
);
|
||||||
const costCredits = Math.ceil(costUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD);
|
const costCredits = Math.ceil(costUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD);
|
||||||
return { costUsd, costCredits };
|
return { costUsd, costCredits };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user