← back to Open Seo

scripts/brand-lookup-cost-profile.ts

234 lines

import process from "node:process";
import {
  fetchLlmAggregatedMetrics,
  fetchLlmCrossAggregatedMetrics,
  fetchLlmMentionsSearch,
  fetchLlmTopPages,
} from "@/server/lib/dataforseo/ai";
import {
  buildLlmTarget,
  CHATGPT_LANGUAGE_CODE,
  CHATGPT_LOCATION_CODE,
  type LlmPlatform,
} from "@/server/lib/dataforseo/shared";
import { applyBillingMarkupUsd } from "@/shared/billing";
import { resolveCompetitorGroups } from "@/server/features/ai-search/services/shareOfVoice";
import { parseCompetitorList } from "@/types/schemas/ai-search";
import { loadLocalEnv, parseArgs } from "./cli-utils";

loadLocalEnv();

const args = parseArgs(process.argv.slice(2));

await main();

/**
 * Confirm what a single Brand Lookup actually costs against DataForSEO.
 * Mirrors `backlinks-cost-profile.ts` but reports per-call USD cost so we can
 * verify the on-screen "Est. $X" against reality.
 */
async function main() {
  if (process.env.CI === "true" && args.allowCi !== "true") {
    printUsageAndExit(
      "Refusing to run live billing checks in CI without --allowCi=true.",
    );
  }

  if (args.confirmLive !== "true") {
    printUsageAndExit(
      "This command makes live, billable DataForSEO requests. Re-run with --confirmLive=true.",
    );
  }

  if (!process.env.DATAFORSEO_API_KEY) {
    printUsageAndExit("Missing DATAFORSEO_API_KEY.");
  }

  const target = args.target;
  if (!target) {
    printUsageAndExit("Missing --target.");
  }

  const targetType = parseTargetType(args.targetType);
  const userLocationCode = parsePositiveInteger(args.locationCode, 2840);
  const userLanguageCode = args.languageCode ?? "en";
  const repeat = parsePositiveInteger(args.repeat, 1);
  // Optional comma-separated competitors — adds the Share of Voice
  // cross_aggregated_metrics call per platform, mirroring the service.
  const competitors = parseCompetitorList(args.competitors ?? "");
  const competitorGroups = resolveCompetitorGroups(target, competitors);

  const llmTarget = buildLlmTarget({ type: targetType, value: target });
  const crossGroups = [
    { key: target, target: llmTarget },
    ...competitorGroups.map((competitor) => {
      const detected = competitor.detected;
      return {
        key: competitor.label,
        target: buildLlmTarget({ type: detected.type, value: detected.value }),
      };
    }),
  ];
  const platforms: LlmPlatform[] = ["chat_gpt", "google"];

  const allRuns: RunSummary[] = [];

  for (let runIndex = 0; runIndex < repeat; runIndex += 1) {
    const calls: CallRecord[] = [];

    for (const platform of platforms) {
      // ChatGPT data is only indexed for US/en, mirroring the production
      // brandLookup service.
      const locationCode =
        platform === "chat_gpt" ? CHATGPT_LOCATION_CODE : userLocationCode;
      const languageCode =
        platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : userLanguageCode;

      const aggregated = await fetchLlmAggregatedMetrics({
        target: llmTarget,
        platform,
        locationCode,
        languageCode,
        internalListLimit: 20,
      });
      calls.push(toRecord(platform, "aggregated_metrics", aggregated.billing));

      const topPages = await fetchLlmTopPages({
        target: llmTarget,
        platform,
        locationCode,
        languageCode,
        itemsListLimit: 10,
      });
      calls.push(toRecord(platform, "top_pages", topPages.billing));

      // Prompt rows provide examples for the cited-source table.
      const mentions = await fetchLlmMentionsSearch({
        target: llmTarget,
        platform,
        locationCode,
        languageCode,
        limit: 100,
      });
      calls.push(toRecord(platform, "mentions_search", mentions.billing));

      if (competitorGroups.length > 0) {
        const cross = await fetchLlmCrossAggregatedMetrics({
          groups: crossGroups,
          platform,
          locationCode,
          languageCode,
        });
        calls.push(
          toRecord(platform, "cross_aggregated_metrics", cross.billing),
        );
      }
    }

    const totalRawUsd = sum(calls.map((c) => c.rawUsd));
    const crossRawUsd = sum(
      calls
        .filter((c) => c.endpoint === "cross_aggregated_metrics")
        .map((c) => c.rawUsd),
    );
    allRuns.push({
      run: runIndex + 1,
      calls,
      // Split out so the base "Est. $X" and the "+$Y to compare competitors"
      // UI constants can each be checked against reality.
      baseRawUsd: round(totalRawUsd - crossRawUsd),
      crossRawUsd: round(crossRawUsd),
      totalRawUsd: round(totalRawUsd),
      totalBilledUsd: applyBillingMarkupUsd(totalRawUsd),
    });
  }

  const aggregateRawUsd = sum(allRuns.map((r) => r.totalRawUsd));
  const aggregateBilledUsd = applyBillingMarkupUsd(aggregateRawUsd);

  console.log(
    JSON.stringify(
      {
        input: {
          target,
          targetType,
          userLocationCode,
          userLanguageCode,
          competitors: competitorGroups.map((group) => group.label),
          repeat,
        },
        runs: allRuns,
        aggregate: {
          totalRawUsd: round(aggregateRawUsd),
          totalBilledUsd: aggregateBilledUsd,
          avgRawPerLookupUsd: round(aggregateRawUsd / allRuns.length),
          avgBilledPerLookupUsd: round(aggregateBilledUsd / allRuns.length),
        },
      },
      null,
      2,
    ),
  );
}

type CallRecord = {
  platform: LlmPlatform;
  endpoint: string;
  path: string;
  rawUsd: number;
  billedUsd: number;
};

type RunSummary = {
  run: number;
  calls: CallRecord[];
  baseRawUsd: number;
  crossRawUsd: number;
  totalRawUsd: number;
  totalBilledUsd: number;
};

function toRecord(
  platform: LlmPlatform,
  endpoint: string,
  billing: { costUsd: number; path: string[] },
): CallRecord {
  return {
    platform,
    endpoint,
    path: billing.path.join("/"),
    rawUsd: round(billing.costUsd),
    billedUsd: applyBillingMarkupUsd(billing.costUsd),
  };
}

function parseTargetType(value: string | undefined): "domain" | "keyword" {
  if (!value || value === "domain") return "domain";
  if (value === "keyword") return "keyword";
  printUsageAndExit(
    `Invalid --targetType: ${value}. Expected domain or keyword.`,
  );
}

function parsePositiveInteger(value: string | undefined, fallback: number) {
  if (!value) return fallback;
  const parsed = Number.parseInt(value, 10);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

function sum(values: number[]): number {
  return values.reduce((total, value) => total + value, 0);
}

function round(value: number): number {
  return Math.round(value * 1_000_000) / 1_000_000;
}

function printUsageAndExit(message: string): never {
  console.error(message);
  console.error(
    "Usage: pnpm billing:brand-lookup --target=example.com --confirmLive=true [--targetType=domain|keyword] [--competitors=a.com,b.com] [--locationCode=2840] [--languageCode=en] [--repeat=1] [--allowCi=true]",
  );
  process.exit(1);
}