124 lines
3.7 KiB
TypeScript
124 lines
3.7 KiB
TypeScript
import { z } from "zod";
|
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
|
import { createDataforseoClient } from "@/server/lib/dataforseo";
|
|
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
|
|
import { normalizeDomainInput } from "@/server/lib/domainUtils";
|
|
import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper";
|
|
import {
|
|
buildKeywordFilters,
|
|
buildOrderBy,
|
|
type DomainKeywordsSortMode,
|
|
type DomainKeywordsSortOrder,
|
|
} from "@/server/features/domain/services/domainKeywordFilters";
|
|
import type { DomainKeywordsFilters } from "@/types/schemas/domain";
|
|
|
|
const DOMAIN_KEYWORDS_PAGE_TTL_SECONDS = 12 * 60 * 60;
|
|
|
|
const domainKeywordsPageResultSchema = z.object({
|
|
domain: z.string(),
|
|
page: z.number(),
|
|
pageSize: z.number(),
|
|
totalCount: z.number().nullable(),
|
|
hasMore: z.boolean(),
|
|
keywords: z.array(
|
|
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(),
|
|
}),
|
|
),
|
|
fetchedAt: z.string(),
|
|
});
|
|
|
|
type DomainKeywordsPageResult = z.infer<typeof domainKeywordsPageResultSchema>;
|
|
|
|
export async function getKeywordsPage(
|
|
input: {
|
|
projectId: string;
|
|
domain: string;
|
|
includeSubdomains: boolean;
|
|
locationCode: number;
|
|
languageCode: string;
|
|
page: number;
|
|
pageSize: number;
|
|
sortMode: DomainKeywordsSortMode;
|
|
sortOrder: DomainKeywordsSortOrder;
|
|
filters: DomainKeywordsFilters;
|
|
search?: string;
|
|
},
|
|
billingCustomer: BillingCustomerContext,
|
|
): Promise<DomainKeywordsPageResult> {
|
|
const domain = normalizeDomainInput(input.domain, input.includeSubdomains);
|
|
const offset = (input.page - 1) * input.pageSize;
|
|
const orderBy = buildOrderBy(input.sortMode, input.sortOrder);
|
|
const filters = buildKeywordFilters(input.filters, input.search);
|
|
|
|
const cacheKey = await buildCacheKey("domain:keywords-page", {
|
|
organizationId: billingCustomer.organizationId,
|
|
projectId: input.projectId,
|
|
domain,
|
|
includeSubdomains: input.includeSubdomains,
|
|
locationCode: input.locationCode,
|
|
languageCode: input.languageCode,
|
|
page: input.page,
|
|
pageSize: input.pageSize,
|
|
sortMode: input.sortMode,
|
|
sortOrder: input.sortOrder,
|
|
filters: input.filters,
|
|
search: input.search,
|
|
});
|
|
|
|
const cachedRaw = await getCached(cacheKey);
|
|
const cached = domainKeywordsPageResultSchema.safeParse(cachedRaw);
|
|
if (cached.success) {
|
|
return cached.data;
|
|
}
|
|
|
|
const dataforseo = createDataforseoClient(billingCustomer);
|
|
const response = await dataforseo.domain.rankedKeywords({
|
|
target: domain,
|
|
locationCode: input.locationCode,
|
|
languageCode: input.languageCode,
|
|
limit: input.pageSize,
|
|
offset,
|
|
orderBy,
|
|
filters: filters.length > 0 ? filters : undefined,
|
|
});
|
|
|
|
const keywords = response.items
|
|
.map((item) => mapKeywordItem(item))
|
|
.filter(
|
|
(item): item is NonNullable<ReturnType<typeof mapKeywordItem>> =>
|
|
item != null,
|
|
);
|
|
|
|
const totalCount = response.totalCount;
|
|
const hasMore =
|
|
totalCount != null
|
|
? offset + keywords.length < totalCount
|
|
: keywords.length === input.pageSize;
|
|
|
|
const result: DomainKeywordsPageResult = {
|
|
domain,
|
|
page: input.page,
|
|
pageSize: input.pageSize,
|
|
totalCount,
|
|
hasMore,
|
|
keywords,
|
|
fetchedAt: new Date().toISOString(),
|
|
};
|
|
|
|
void setCached(cacheKey, result, DOMAIN_KEYWORDS_PAGE_TTL_SECONDS).catch(
|
|
(error) => {
|
|
console.error("domain.keywords-page.cache-write failed:", error);
|
|
},
|
|
);
|
|
|
|
return result;
|
|
}
|