Ben Senescu f75bbaeb37
Enforce type-aware TypeScript linting and remove unsafe assertions (#15)
* Enforce safe TypeScript assertions and validate runtime payloads

* Fix CI knip config and floating promise lint

* Validate DataForSEO payloads with Zod schemas

Replace weak object guards with endpoint-level schema parsing so invalid API shapes fail fast instead of being silently filtered. Align downstream keyword mapping with the stricter validated payload contracts.
2026-03-11 11:56:48 -04:00

248 lines
6.4 KiB
TypeScript

import {
normalizeDomainInput,
toRelativePath,
fetchDomainRankOverviewRaw,
fetchRankedKeywordsRaw,
type DomainRankedKeywordItem,
} from "@/server/lib/dataforseo";
import { sortBy } from "remeda";
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
import { z } from "zod";
/** Domain overview data is refreshed every 12 hours. */
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
type DomainOverviewResult = {
domain: string;
organicTraffic: number | null;
organicKeywords: number | null;
backlinks: number | null;
referringDomains: number | null;
hasData: boolean;
keywords: Array<{
keyword: string;
position: number | null;
searchVolume: number | null;
traffic: number | null;
cpc: number | null;
url: string | null;
relativeUrl: string | null;
keywordDifficulty: number | null;
}>;
pages: Array<{
page: string;
relativePath: string | null;
organicTraffic: number | null;
keywords: number | null;
backlinks: number | null;
}>;
fetchedAt: string;
};
const domainKeywordSchema = z.object({
keyword: z.string(),
position: z.number().nullable(),
searchVolume: z.number().nullable(),
traffic: z.number().nullable(),
cpc: z.number().nullable(),
url: z.string().nullable(),
relativeUrl: z.string().nullable(),
keywordDifficulty: z.number().nullable(),
});
const domainPageSchema = z.object({
page: z.string(),
relativePath: z.string().nullable(),
organicTraffic: z.number().nullable(),
keywords: z.number().nullable(),
backlinks: z.number().nullable(),
});
const domainOverviewSchema = z.object({
domain: z.string(),
organicTraffic: z.number().nullable(),
organicKeywords: z.number().nullable(),
backlinks: z.number().nullable(),
referringDomains: z.number().nullable(),
hasData: z.boolean(),
keywords: z.array(domainKeywordSchema),
pages: z.array(domainPageSchema),
fetchedAt: z.string(),
});
async function getOverview(input: {
domain: string;
includeSubdomains: boolean;
locationCode: number;
languageCode: string;
}): Promise<DomainOverviewResult> {
const domain = normalizeDomainInput(input.domain, input.includeSubdomains);
// --- KV cache check ---
const cacheKey = buildCacheKey("domain:overview", {
domain,
includeSubdomains: input.includeSubdomains,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
const cachedRaw = await getCached(cacheKey);
const cached = domainOverviewSchema.safeParse(cachedRaw);
if (cached.success && cached.data.hasData) {
return cached.data;
}
// --- Fetch fresh from DataForSEO ---
const nowIso = new Date().toISOString();
const [metricsResponse, rankedKeywordsResponse] = await Promise.all([
fetchDomainRankOverviewRaw(domain, input.locationCode, input.languageCode),
fetchRankedKeywordsRaw(
domain,
input.locationCode,
input.languageCode,
200,
["keyword_data.keyword_info.search_volume,desc"],
),
]);
const metrics = metricsResponse[0];
const rankedItems = rankedKeywordsResponse;
const keywords = rankedItems
.map((item) => mapKeywordItem(item))
.filter(
(item): item is NonNullable<ReturnType<typeof mapKeywordItem>> =>
item != null,
);
const pages = derivePages(keywords);
const organicTraffic =
metrics?.metrics?.organic?.etv != null
? Math.round(metrics.metrics.organic.etv)
: null;
const organicKeywords =
metrics?.metrics?.organic?.count != null
? Math.round(metrics.metrics.organic.count)
: null;
const result: DomainOverviewResult = {
domain,
organicTraffic,
organicKeywords,
backlinks: null,
referringDomains: null,
hasData: keywords.length > 0,
keywords,
pages,
fetchedAt: nowIso,
};
// Persist to KV (fire-and-forget; don't block response)
if (result.hasData) {
void setCached(cacheKey, result, DOMAIN_OVERVIEW_TTL_SECONDS).catch(
(error) => {
console.error("domain.overview.cache-write failed:", error);
},
);
}
return result;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mapKeywordItem(item: DomainRankedKeywordItem) {
const keywordData = item.keyword_data;
const keywordInfo = keywordData?.keyword_info;
const keywordProperties = keywordData?.keyword_properties;
const rankedSerpElement = item.ranked_serp_element;
const serpItem = rankedSerpElement?.serp_item;
const keyword = keywordData?.keyword ?? item.keyword;
if (!keyword) return null;
const url = serpItem?.url ?? rankedSerpElement?.url ?? null;
const relativeUrl =
serpItem?.relative_url ??
rankedSerpElement?.relative_url ??
(url ? toRelativePath(url) : null);
const position =
serpItem?.rank_absolute ?? rankedSerpElement?.rank_absolute ?? null;
const traffic = serpItem?.etv ?? rankedSerpElement?.etv ?? null;
const keywordDifficulty =
keywordProperties?.keyword_difficulty ??
keywordInfo?.keyword_difficulty ??
null;
return {
keyword,
position: position != null ? Math.round(position) : null,
searchVolume:
keywordInfo?.search_volume != null
? Math.round(keywordInfo.search_volume)
: null,
traffic: traffic ?? null,
cpc: keywordInfo?.cpc ?? null,
url: url ?? null,
relativeUrl,
keywordDifficulty:
keywordDifficulty != null ? Math.round(keywordDifficulty) : null,
};
}
function derivePages(
keywords: Array<{
url: string | null;
relativeUrl: string | null;
traffic: number | null;
}>,
) {
const grouped = new Map<
string,
{
page: string;
relativePath: string | null;
traffic: number;
keywords: number;
}
>();
for (const keyword of keywords) {
if (!keyword.url) continue;
const existing = grouped.get(keyword.url) ?? {
page: keyword.url,
relativePath: keyword.relativeUrl,
traffic: 0,
keywords: 0,
};
existing.traffic += keyword.traffic ?? 0;
existing.keywords += 1;
grouped.set(keyword.url, existing);
}
return sortBy(Array.from(grouped.values()), [(page) => page.traffic, "desc"])
.slice(0, 100)
.map((page) => ({
page: page.page,
relativePath: page.relativePath,
organicTraffic: page.traffic,
keywords: page.keywords,
backlinks: null,
}));
}
export const DomainService = {
getOverview,
} as const;