metatron-open-seo/src/server/lib/dataforseoClient.ts

416 lines
11 KiB
TypeScript

import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_CREDITS_PER_USD,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
SEO_DATA_COST_MARKUP,
roundUsdForBilling,
} from "@/shared/billing";
import { autumn } from "@/server/billing/autumn";
import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import {
fetchKeywordIdeasRaw,
fetchKeywordOverviewRaw,
fetchKeywordSuggestionsRaw,
fetchRelatedKeywordsRaw,
fetchDomainRankOverviewRaw,
fetchRankedKeywordsRaw,
fetchRelevantPagesRaw,
fetchLiveSerpItemsRaw,
fetchRankCheckSerpRaw,
type LabsKeywordDataItem,
type SerpLiveItem,
} from "@/server/lib/dataforseo";
import {
fetchLlmAggregatedMetricsRaw,
fetchLlmMentionsSearchRaw,
fetchLlmResponseRaw,
fetchLlmTopPagesRaw,
type LlmAggregatedMetricsInput,
type LlmMentionsSearchInput,
type LlmResponsesInput,
type LlmTopPagesInput,
} from "@/server/lib/dataforseoLlm";
import { fetchDataforseoLighthouseResultRaw } from "@/server/lib/dataforseoLighthouse";
import type { LighthouseStrategy } from "@/server/lib/dataforseoLighthousePayload";
import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload";
import {
fetchBacklinksHistoryRaw,
fetchBacklinksRowsRaw,
fetchBacklinksSummaryRaw,
fetchDomainPagesSummaryRaw,
fetchReferringDomainsRaw,
type BacklinksListRequest,
type BacklinksRequest,
type BacklinksTimeseriesRequest,
} from "@/server/lib/dataforseoBacklinks";
import {
type DataforseoApiResponse,
type DataforseoApiCallCost,
} from "@/server/lib/dataforseoCost";
import { AppError } from "@/server/lib/errors";
import { captureServerEvent } from "@/server/lib/posthog";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
type CreditFeature =
| "keyword_research"
| "domain_overview"
| "backlinks"
| "site_audit"
| "rank_tracking"
| "ai_search";
/**
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
* to a product feature for analytics. path[1] is the API module; for dataforseo_labs,
* path[3] distinguishes keyword vs domain endpoints.
*/
export function mapDataforseoPathToCreditFeature(
path: string[],
): CreditFeature {
const module = path[1];
switch (module) {
case "on_page":
return "site_audit";
case "backlinks":
return "backlinks";
case "serp":
return "keyword_research";
case "ai_optimization":
return "ai_search";
case "dataforseo_labs": {
const endpoint = path[3] ?? "";
if (
endpoint.startsWith("domain_") ||
endpoint === "ranked_keywords" ||
endpoint === "relevant_pages"
) {
return "domain_overview";
}
return "keyword_research";
}
default:
return "site_audit";
}
}
export function createDataforseoClient(customer: BillingCustomerContext) {
return {
backlinks: {
summary(input: BacklinksRequest) {
return meterDataforseoCall(customer, () =>
fetchBacklinksSummaryRaw(input),
);
},
rows(input: BacklinksListRequest) {
return meterDataforseoCall(customer, () =>
fetchBacklinksRowsRaw(input),
);
},
referringDomains(input: BacklinksListRequest) {
return meterDataforseoCall(customer, () =>
fetchReferringDomainsRaw(input),
);
},
domainPages(input: BacklinksListRequest) {
return meterDataforseoCall(customer, () =>
fetchDomainPagesSummaryRaw(input),
);
},
history(input: BacklinksTimeseriesRequest) {
return meterDataforseoCall(customer, () =>
fetchBacklinksHistoryRaw(input),
);
},
},
keywords: {
related(input: {
keyword: string;
locationCode: number;
languageCode: string;
limit: number;
depth?: number;
}) {
return meterDataforseoCall(customer, () =>
fetchRelatedKeywordsRaw(
input.keyword,
input.locationCode,
input.languageCode,
input.limit,
input.depth,
),
);
},
suggestions(input: {
keyword: string;
locationCode: number;
languageCode: string;
limit: number;
}) {
return meterDataforseoCall(customer, () =>
fetchKeywordSuggestionsRaw(
input.keyword,
input.locationCode,
input.languageCode,
input.limit,
),
);
},
ideas(input: {
keyword: string;
locationCode: number;
languageCode: string;
limit: number;
}) {
return meterDataforseoCall(customer, () =>
fetchKeywordIdeasRaw(
input.keyword,
input.locationCode,
input.languageCode,
input.limit,
),
);
},
},
domain: {
rankOverview(input: {
target: string;
locationCode: number;
languageCode: string;
}) {
return meterDataforseoCall(customer, () =>
fetchDomainRankOverviewRaw(
input.target,
input.locationCode,
input.languageCode,
),
);
},
rankedKeywords(input: {
target: string;
locationCode: number;
languageCode: string;
limit: number;
offset?: number;
orderBy?: string[];
filters?: unknown[];
}) {
return meterDataforseoCall(customer, () =>
fetchRankedKeywordsRaw(input),
);
},
relevantPages(input: {
target: string;
locationCode: number;
languageCode: string;
limit: number;
offset?: number;
orderBy?: string[];
filters?: unknown[];
}) {
return meterDataforseoCall(customer, () =>
fetchRelevantPagesRaw(input),
);
},
},
labs: {
keywordOverview(input: {
keywords: string[];
locationCode: number;
languageCode: string;
}) {
return meterDataforseoCall(
customer,
() =>
fetchKeywordOverviewRaw(
input.keywords,
input.locationCode,
input.languageCode,
),
"rank_tracking",
);
},
},
serp: {
live(input: {
keyword: string;
locationCode: number;
languageCode: string;
}) {
return meterDataforseoCall(customer, () =>
fetchLiveSerpItemsRaw(
input.keyword,
input.locationCode,
input.languageCode,
),
);
},
rankCheck(input: {
keyword: string;
keywordId: string;
locationCode: number;
languageCode: string;
device: "desktop" | "mobile";
targetDomain: string;
depth: number;
}) {
return meterDataforseoCall(
customer,
() => fetchRankCheckSerpRaw(input),
"rank_tracking",
);
},
},
lighthouse: {
live(input: { url: string; strategy: LighthouseStrategy }) {
return meterDataforseoCall<StoredLighthousePayload>(customer, () =>
fetchDataforseoLighthouseResultRaw(input),
);
},
},
aiSearch: {
mentionsSearch(input: LlmMentionsSearchInput) {
return meterDataforseoCall(customer, () =>
fetchLlmMentionsSearchRaw(input),
);
},
aggregatedMetrics(input: LlmAggregatedMetricsInput) {
return meterDataforseoCall(customer, () =>
fetchLlmAggregatedMetricsRaw(input),
);
},
topPages(input: LlmTopPagesInput) {
return meterDataforseoCall(customer, () => fetchLlmTopPagesRaw(input));
},
llmResponse(input: LlmResponsesInput) {
return meterDataforseoCall(customer, () => fetchLlmResponseRaw(input));
},
},
} as const;
}
async function meterDataforseoCall<T>(
customer: BillingCustomerContext,
execute: () => Promise<DataforseoApiResponse<T>>,
creditFeature?: CreditFeature,
): Promise<T> {
const isHostedMode = await isHostedServerAuthMode();
if (!isHostedMode) {
const result = await execute();
return result.data;
}
const billingCustomer = await getOrCreateOrganizationCustomer(customer);
const { monthlyRemaining } = await assertSeoDataBalanceAvailable(
billingCustomer.id,
);
const result = await execute();
await trackDataforseoCost({
customer,
customerId: billingCustomer.id,
billing: result.billing,
monthlyRemaining,
creditFeature,
});
return result.data;
}
async function assertSeoDataBalanceAvailable(customerId: string) {
const [monthlyCheck, topupCheck] = await Promise.all([
autumn.check({
customerId,
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
}),
autumn.check({
customerId,
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
}),
]);
const monthlyRemaining = monthlyCheck.balance?.remaining ?? 0;
const topupRemaining = topupCheck.balance?.remaining ?? 0;
if (monthlyRemaining + topupRemaining <= 0) {
throw new AppError("INSUFFICIENT_CREDITS");
}
return { monthlyRemaining };
}
async function trackDataforseoCost(args: {
customer: BillingCustomerContext;
customerId: string;
billing: DataforseoApiCallCost;
monthlyRemaining: number;
creditFeature?: CreditFeature;
}) {
const totalCostUsd = roundUsdForBilling(
args.billing.costUsd * SEO_DATA_COST_MARKUP,
);
const totalCostCredits = Math.ceil(
totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD,
);
const monthlyDeduct = Math.min(args.monthlyRemaining, totalCostCredits);
const topupDeduct = totalCostCredits - monthlyDeduct;
const properties = {
provider: "dataforseo",
currency: "USD",
paths: [args.billing.path.join("/")],
totalCostUsd,
totalCostCredits,
fromCache: false,
};
if (monthlyDeduct > 0) {
await autumn.track({
customerId: args.customerId,
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: monthlyDeduct,
properties: {
...properties,
balanceFeatureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
},
});
}
if (topupDeduct > 0) {
await autumn.track({
customerId: args.customerId,
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
value: topupDeduct,
properties: {
...properties,
balanceFeatureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
},
});
}
if (totalCostCredits > 0) {
await captureServerEvent({
distinctId: args.customer.userId,
event: "usage:credits_consume",
organizationId: args.customer.organizationId,
properties: {
project_id: args.customer.projectId,
credit_feature:
args.creditFeature ??
mapDataforseoPathToCreditFeature(args.billing.path),
monthly_credits: monthlyDeduct,
topup_credits: topupDeduct,
total_credits: totalCostCredits,
cost_usd: totalCostUsd,
},
});
}
}
export type { LabsKeywordDataItem, SerpLiveItem };