105 lines
3.2 KiB
TypeScript
105 lines
3.2 KiB
TypeScript
import type { WorkflowStep } from "cloudflare:workers";
|
|
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
|
import type { createDataforseoClient } from "@/server/lib/dataforseo";
|
|
import type { RankCheckResult } from "@/server/lib/dataforseo";
|
|
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
|
import { KEYWORDS_PER_BATCH } from "@/shared/rank-tracking";
|
|
|
|
const SINGLE_ATTEMPT_STEP_CONFIG = {
|
|
retries: { limit: 0, delay: "1 second" as const },
|
|
timeout: "2 minutes" as const,
|
|
};
|
|
|
|
type KeywordEntry = { id: string; keyword: string };
|
|
type RankCheckResultWithDevice = RankCheckResult & {
|
|
device: "desktop" | "mobile";
|
|
};
|
|
|
|
function mapResultsToSnapshotRows(
|
|
runId: string,
|
|
results: RankCheckResultWithDevice[],
|
|
) {
|
|
return results.map((r) => ({
|
|
runId,
|
|
trackingKeywordId: r.keywordId,
|
|
keyword: r.keyword,
|
|
device: r.device,
|
|
position: r.position,
|
|
url: r.url,
|
|
serpFeatures:
|
|
r.serpFeatures.length > 0 ? JSON.stringify(r.serpFeatures) : null,
|
|
}));
|
|
}
|
|
|
|
interface CheckContext {
|
|
client: ReturnType<typeof createDataforseoClient>;
|
|
keywords: KeywordEntry[];
|
|
devices: RankTrackingConfig["devices"];
|
|
serpDepth: number;
|
|
domain: string;
|
|
locationCode: number;
|
|
languageCode: string;
|
|
runId: string;
|
|
}
|
|
|
|
/**
|
|
* Check keywords via Live API, parallel devices per keyword, real-time progress.
|
|
* Snapshots are written incrementally after each batch so partial results
|
|
* survive batch failures. ~6s per keyword batch.
|
|
* Billing is handled per-call by the metered client.
|
|
*/
|
|
export async function runLiveCheck(
|
|
step: WorkflowStep,
|
|
ctx: CheckContext,
|
|
): 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) {
|
|
const batch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH);
|
|
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
|
|
|
|
await step.do(
|
|
`live-batch-${batchIndex}`,
|
|
SINGLE_ATTEMPT_STEP_CONFIG,
|
|
async () => {
|
|
const promises = batch.flatMap((kw) =>
|
|
deviceList.map((device) =>
|
|
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, {
|
|
keywordsChecked: checked,
|
|
});
|
|
|
|
if (results.length > 0) {
|
|
await RankTrackingRepository.insertSnapshots(
|
|
mapResultsToSnapshotRows(ctx.runId, results),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|