← back to Open Seo
scripts/seed-rank-tracking.ts
526 lines
/**
* Seed the local D1 database with synthetic rank-tracking history so the new
* trends / data-exploration UI has something to show. Fully offline — no
* DataForSEO key or network needed.
*
* What it creates:
* - A both-devices config with ~20 keywords (volume / KD / CPC populated).
* - ~16 weekly backdated check runs, each with desktop + mobile snapshots.
* - Positions follow per-keyword trends (climbers, fallers, volatile, new,
* lost) so the line charts, scorecards, and "Not in top N" band all have
* realistic data — including keywords that drop out of the tracked depth.
*
* Usage:
* pnpm db:migrate:local # once — creates the local D1
* pnpm seed:rank-tracking # seed demo data
* pnpm seed:rank-tracking --domain=acme.com --runs=20 --keywords=30
* pnpm seed:rank-tracking --projectId=<existing-project-uuid>
*
* Then view it:
* env AUTH_MODE=local_noauth pnpm dev # then open Rank Tracking
*
* With no --projectId, it bootstraps the local_noauth user/org/Default project
* (the same identity `AUTH_MODE=local_noauth` uses) so the data is immediately
* viewable. Re-running resets the demo config for the domain.
*/
import process from "node:process";
import { getPlatformProxy } from "wrangler";
import { drizzle } from "drizzle-orm/d1";
import { and, eq } from "drizzle-orm";
import * as schema from "../src/db/schema";
import { parseArgs } from "./cli-utils";
const LOCAL_ADMIN_USER_ID = "local-admin";
const LOCAL_ADMIN_EMAIL = "admin@localhost";
const LOCAL_ORG_ID = `delegated-${LOCAL_ADMIN_USER_ID}`;
const LOCATION_CODE = 2840; // United States
const SERP_DEPTH = 20; // positions beyond this are stored null ("not in top 20")
type SeedDb = ReturnType<typeof drizzle<typeof schema>>;
type BatchStatement = Parameters<SeedDb["batch"]>[0][number];
async function main() {
const args = parseArgs(process.argv.slice(2));
const domain = normalizeDomain(args.domain) ?? "acme-demo.com";
const runs = clampInt(args.runs, 16, 2, 52);
const keywordCount = clampInt(args.keywords, 20, 1, KEYWORDS.length);
console.log("Setting up local D1 connection...");
const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>();
const db = drizzle(env.DB, { schema });
try {
const projectId = await resolveProject(db, args.projectId);
console.log(`Using project ${projectId}`);
// Reset any previous demo config for this domain (cascades runs/snapshots/
// keywords) so re-running is clean.
const removed = await db
.delete(schema.rankTrackingConfigs)
.where(
and(
eq(schema.rankTrackingConfigs.projectId, projectId),
eq(schema.rankTrackingConfigs.domain, domain),
),
)
.returning({ id: schema.rankTrackingConfigs.id });
if (removed.length > 0) {
console.log(`Reset existing config for ${domain}.`);
}
const keywords = KEYWORDS.slice(0, keywordCount);
const runDates = buildRunDates(runs);
const configId = crypto.randomUUID();
const newest = runDates[runDates.length - 1];
await db.insert(schema.rankTrackingConfigs).values({
id: configId,
projectId,
domain,
locationCode: LOCATION_CODE,
languageCode: "en",
devices: "both",
serpDepth: SERP_DEPTH,
scheduleInterval: "weekly",
isActive: true,
lastCheckedAt: dbTimestamp(newest),
createdAt: dbTimestamp(runDates[0]),
});
const keywordRows = keywords.map((k) => ({
id: crypto.randomUUID(),
configId,
keyword: k.keyword,
searchVolume: k.volume,
keywordDifficulty: k.kd,
cpc: k.cpc,
metricsFetchedAt: dbTimestamp(newest),
}));
await batched(db, keywordRows, (row) =>
db.insert(schema.rankTrackingKeywords).values(row),
);
console.log(`Inserted ${keywordRows.length} keywords.`);
// One completed run per date; each run snapshots every keyword on both
// devices.
const runRows = runDates.map((date) => ({
id: crypto.randomUUID(),
date,
}));
await batched(db, runRows, (run) =>
db.insert(schema.rankCheckRuns).values({
id: run.id,
configId,
projectId,
status: "completed" as const,
keywordsTotal: keywordRows.length,
keywordsChecked: keywordRows.length,
startedAt: dbTimestamp(run.date),
completedAt: dbTimestamp(run.date),
}),
);
const snapshotValues: (typeof schema.rankSnapshots.$inferInsert)[] = [];
runRows.forEach((run, runIndex) => {
keywordRows.forEach((kw, kwIndex) => {
const profile = KEYWORDS[kwIndex].profile;
const rng = makeRng(kwIndex * 1000 + runIndex);
const desktopRank = rankFor(profile, runIndex, runs, rng);
const mobileRank =
desktopRank === null ? null : desktopRank + 1 + (rng() - 0.5) * 1.2;
const checkedAt = dbTimestamp(run.date);
const path = `/${slugify(kw.keyword)}`;
snapshotValues.push(
snapshot(run.id, kw, "desktop", desktopRank, domain, path, checkedAt),
snapshot(run.id, kw, "mobile", mobileRank, domain, path, checkedAt),
);
});
});
await batched(db, snapshotValues, (row) =>
db.insert(schema.rankSnapshots).values(row),
);
console.log(
`Inserted ${runRows.length} runs and ${snapshotValues.length} snapshots.`,
);
const start = runDates[0].toISOString().slice(0, 10);
const end = newest.toISOString().slice(0, 10);
console.log(
`\nDone. Seeded "${domain}" — ${keywordRows.length} keywords, ${runs} weekly checks (${start} → ${end}), desktop + mobile.`,
);
if (!args.projectId) {
console.log(
"\nView it:\n env AUTH_MODE=local_noauth pnpm dev\n → open Rank Tracking (the demo lives in the Default project).",
);
}
} finally {
await dispose();
}
}
// ---------------------------------------------------------------------------
// Project / local_noauth bootstrap
// ---------------------------------------------------------------------------
async function resolveProject(
db: SeedDb,
projectIdArg: string | undefined,
): Promise<string> {
if (projectIdArg) {
const existing = await db.query.projects.findFirst({
where: eq(schema.projects.id, projectIdArg),
});
if (!existing) {
exit(`Project ${projectIdArg} not found in local DB.`);
}
return projectIdArg;
}
// Bootstrap the same user/org/Default project that AUTH_MODE=local_noauth
// resolves, so the seeded data is viewable without signing up.
await db
.insert(schema.user)
.values({
id: LOCAL_ADMIN_USER_ID,
name: "admin",
email: LOCAL_ADMIN_EMAIL,
emailVerified: true,
})
.onConflictDoNothing({ target: schema.user.id });
await db
.insert(schema.organization)
.values({
id: LOCAL_ORG_ID,
name: "admin workspace",
slug: `delegated-admin-${toHex(LOCAL_ADMIN_USER_ID)}`,
createdAt: new Date(),
})
.onConflictDoNothing({ target: schema.organization.id });
const existingDefault = await db.query.projects.findFirst({
where: and(
eq(schema.projects.organizationId, LOCAL_ORG_ID),
eq(schema.projects.name, "Default"),
),
});
if (existingDefault) return existingDefault.id;
const projectId = crypto.randomUUID();
await db.insert(schema.projects).values({
id: projectId,
organizationId: LOCAL_ORG_ID,
name: "Default",
domain: null,
});
console.log("Bootstrapped local_noauth Default project.");
return projectId;
}
// ---------------------------------------------------------------------------
// Synthetic positions
// ---------------------------------------------------------------------------
type Profile =
| "climber"
| "faller"
| "leader"
| "volatile"
| "steady_mid"
| "newcomer"
| "lost";
/** Continuous "true" desktop rank for a keyword at run `i` (0 = oldest). null =
* not present (either not in the tracked depth yet, or dropped out). */
function rankFor(
profile: Profile,
i: number,
runs: number,
rng: () => number,
): number | null {
const t = runs <= 1 ? 1 : i / (runs - 1); // 0..1 over the window
const noise = rng() - 0.5;
switch (profile) {
case "climber":
return 18 - 16 * t + noise * 1.5; // 18 → 2
case "faller":
return 3 + 22 * t + noise * 1.5; // 3 → 25 (drops out late)
case "leader":
return 2 + noise * 0.8; // hovers 1–3
case "volatile":
return 9 + Math.sin(i * 1.25) * 5 + noise * 3;
case "steady_mid":
return 12 + noise * 1.2; // ~11–13
case "newcomer":
return t < 0.4 ? null : 16 - 26 * (t - 0.4) + noise * 1.5; // appears, climbs
case "lost":
return t > 0.75 ? null : 7 + noise * 1.5; // ranks, then disappears
}
}
/** Round a continuous rank and drop it to null when it falls past the depth. */
function toStored(rank: number | null): number | null {
if (rank === null) return null;
const r = Math.max(1, Math.round(rank));
return r > SERP_DEPTH ? null : r;
}
function snapshot(
runId: string,
kw: { id: string; keyword: string },
device: "desktop" | "mobile",
rank: number | null,
domain: string,
path: string,
checkedAt: string,
): typeof schema.rankSnapshots.$inferInsert {
const position = toStored(rank);
return {
runId,
trackingKeywordId: kw.id,
keyword: kw.keyword,
device,
position,
url: position === null ? null : `https://${domain}${path}`,
serpFeatures: null,
checkedAt,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Weekly dates, oldest first, all at noon UTC (so local-time rendering can't
* shift a point across a day boundary). */
function buildRunDates(runs: number): Date[] {
const dates: Date[] = [];
const base = new Date();
base.setUTCHours(12, 0, 0, 0);
for (let weeksAgo = runs - 1; weeksAgo >= 0; weeksAgo -= 1) {
const d = new Date(base);
d.setUTCDate(d.getUTCDate() - weeksAgo * 7);
dates.push(d);
}
return dates;
}
/** SQLite current_timestamp format (UTC): "YYYY-MM-DD HH:MM:SS". */
function dbTimestamp(d: Date): string {
return d.toISOString().slice(0, 19).replace("T", " ");
}
/** Small seeded PRNG (mulberry32) so re-runs produce the same data. */
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
async function batched<T>(
db: SeedDb,
items: T[],
buildStatement: (item: T) => BatchStatement,
): Promise<void> {
const SIZE = 80; // statements per D1 batch transaction
for (let i = 0; i < items.length; i += SIZE) {
const chunk = items.slice(i, i + SIZE).map(buildStatement);
const [first, ...rest] = chunk;
if (!first) continue;
await db.batch([first, ...rest]);
}
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
function toHex(value: string): string {
return Array.from(new TextEncoder().encode(value), (b) =>
b.toString(16).padStart(2, "0"),
).join("");
}
function normalizeDomain(raw: string | undefined): string | undefined {
if (!raw) return undefined;
return raw
.trim()
.toLowerCase()
.replace(/^https?:\/\//u, "")
.replace(/\/.*$/u, "")
.replace(/^www\./u, "");
}
function clampInt(
raw: string | undefined,
fallback: number,
min: number,
max: number,
): number {
const n = raw ? Number.parseInt(raw, 10) : NaN;
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function exit(message: string): never {
console.error(message);
process.exit(1);
}
// ---------------------------------------------------------------------------
// Demo keyword set (keyword + metrics + trend profile)
// ---------------------------------------------------------------------------
const KEYWORDS: {
keyword: string;
volume: number;
kd: number;
cpc: number;
profile: Profile;
}[] = [
{
keyword: "seo audit tool",
volume: 18100,
kd: 64,
cpc: 9.4,
profile: "climber",
},
{
keyword: "best rank tracker",
volume: 8100,
kd: 58,
cpc: 7.2,
profile: "leader",
},
{
keyword: "keyword research software",
volume: 12100,
kd: 71,
cpc: 11.8,
profile: "faller",
},
{
keyword: "free backlink checker",
volume: 27100,
kd: 49,
cpc: 4.1,
profile: "volatile",
},
{
keyword: "local seo services",
volume: 6600,
kd: 53,
cpc: 14.2,
profile: "newcomer",
},
{
keyword: "google rank checker",
volume: 9900,
kd: 45,
cpc: 5.6,
profile: "steady_mid",
},
{ keyword: "serp api", volume: 3600, kd: 41, cpc: 6.9, profile: "climber" },
{
keyword: "ai content optimization",
volume: 2400,
kd: 38,
cpc: 8.3,
profile: "newcomer",
},
{
keyword: "technical seo checklist",
volume: 4400,
kd: 36,
cpc: 3.2,
profile: "leader",
},
{
keyword: "competitor keyword analysis",
volume: 2900,
kd: 55,
cpc: 10.1,
profile: "lost",
},
{
keyword: "domain authority checker",
volume: 33100,
kd: 62,
cpc: 4.8,
profile: "volatile",
},
{
keyword: "on page seo tool",
volume: 5400,
kd: 47,
cpc: 7.7,
profile: "climber",
},
{
keyword: "seo for startups",
volume: 1900,
kd: 29,
cpc: 6.4,
profile: "steady_mid",
},
{
keyword: "rank tracking api",
volume: 1300,
kd: 34,
cpc: 8.9,
profile: "newcomer",
},
{
keyword: "content gap analysis",
volume: 2100,
kd: 44,
cpc: 9.1,
profile: "faller",
},
{
keyword: "mobile seo audit",
volume: 1600,
kd: 31,
cpc: 5.0,
profile: "leader",
},
{
keyword: "schema markup generator",
volume: 8800,
kd: 39,
cpc: 3.6,
profile: "volatile",
},
{
keyword: "search intent tool",
volume: 1100,
kd: 27,
cpc: 7.0,
profile: "climber",
},
{
keyword: "seo reporting dashboard",
volume: 2700,
kd: 50,
cpc: 12.5,
profile: "lost",
},
{
keyword: "indie hacker seo",
volume: 720,
kd: 22,
cpc: 4.3,
profile: "newcomer",
},
];
await main();