204 lines
5.6 KiB
TypeScript
204 lines
5.6 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, toRelativePath } from "@/server/lib/domainUtils";
|
|
import type { RelevantPagesItem } from "@/server/lib/dataforseo";
|
|
import type { DomainKeywordsFilters } from "@/types/schemas/domain";
|
|
|
|
const DOMAIN_PAGES_PAGE_TTL_SECONDS = 12 * 60 * 60;
|
|
|
|
type DomainPagesSortMode = "traffic" | "keywords";
|
|
type DomainPagesSortOrder = "asc" | "desc";
|
|
|
|
const SORT_FIELD_BY_MODE: Record<DomainPagesSortMode, string> = {
|
|
traffic: "metrics.organic.etv",
|
|
keywords: "metrics.organic.count",
|
|
};
|
|
|
|
const domainPagesPageResultSchema = z.object({
|
|
domain: z.string(),
|
|
page: z.number(),
|
|
pageSize: z.number(),
|
|
totalCount: z.number().nullable(),
|
|
hasMore: z.boolean(),
|
|
pages: z.array(
|
|
z.object({
|
|
page: z.string(),
|
|
relativePath: z.string().nullable(),
|
|
organicTraffic: z.number().nullable(),
|
|
keywords: z.number().nullable(),
|
|
}),
|
|
),
|
|
fetchedAt: z.string(),
|
|
});
|
|
|
|
type DomainPagesPageResult = z.infer<typeof domainPagesPageResultSchema>;
|
|
|
|
function escapeLikeTerm(term: string): string {
|
|
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
|
|
}
|
|
|
|
function pushAnd(filters: unknown[], expression: unknown[]) {
|
|
if (filters.length > 0) filters.push("and");
|
|
filters.push(expression);
|
|
}
|
|
|
|
function collectNumericRange(
|
|
out: unknown[][],
|
|
field: string,
|
|
min: number | undefined,
|
|
max: number | undefined,
|
|
) {
|
|
if (typeof min === "number" && Number.isFinite(min)) {
|
|
out.push([field, ">=", min]);
|
|
}
|
|
if (typeof max === "number" && Number.isFinite(max)) {
|
|
out.push([field, "<=", max]);
|
|
}
|
|
}
|
|
|
|
function parseTerms(value: string | undefined): string[] {
|
|
if (!value) return [];
|
|
return value
|
|
.toLowerCase()
|
|
.split(/[,+]/)
|
|
.map((term) => term.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function buildPageFilters(
|
|
filters: DomainKeywordsFilters,
|
|
searchTerm?: string,
|
|
): unknown[] {
|
|
const conditions: unknown[][] = [];
|
|
|
|
for (const term of parseTerms(filters.include)) {
|
|
conditions.push(["page_address", "ilike", `%${escapeLikeTerm(term)}%`]);
|
|
}
|
|
for (const term of parseTerms(filters.exclude)) {
|
|
conditions.push(["page_address", "not_ilike", `%${escapeLikeTerm(term)}%`]);
|
|
}
|
|
|
|
collectNumericRange(
|
|
conditions,
|
|
"metrics.organic.etv",
|
|
filters.minTraffic,
|
|
filters.maxTraffic,
|
|
);
|
|
collectNumericRange(
|
|
conditions,
|
|
"metrics.organic.count",
|
|
filters.minVol,
|
|
filters.maxVol,
|
|
);
|
|
|
|
const trimmed = searchTerm?.trim();
|
|
if (trimmed) {
|
|
conditions.push(["page_address", "ilike", `%${escapeLikeTerm(trimmed)}%`]);
|
|
}
|
|
|
|
const expressions: unknown[] = [];
|
|
for (const condition of conditions) pushAnd(expressions, condition);
|
|
return expressions;
|
|
}
|
|
|
|
function mapPageItem(item: RelevantPagesItem) {
|
|
const url = item.page_address ?? null;
|
|
if (!url) return null;
|
|
const organic = item.metrics?.organic ?? null;
|
|
const traffic = organic?.etv ?? null;
|
|
const keywords = organic?.count ?? null;
|
|
return {
|
|
page: url,
|
|
relativePath: toRelativePath(url),
|
|
organicTraffic: traffic != null ? Math.round(traffic) : null,
|
|
keywords: keywords != null ? Math.round(keywords) : null,
|
|
};
|
|
}
|
|
|
|
export async function getPagesPage(
|
|
input: {
|
|
projectId: string;
|
|
domain: string;
|
|
includeSubdomains: boolean;
|
|
locationCode: number;
|
|
languageCode: string;
|
|
page: number;
|
|
pageSize: number;
|
|
sortMode: DomainPagesSortMode;
|
|
sortOrder: DomainPagesSortOrder;
|
|
filters: DomainKeywordsFilters;
|
|
search?: string;
|
|
},
|
|
billingCustomer: BillingCustomerContext,
|
|
): Promise<DomainPagesPageResult> {
|
|
const domain = normalizeDomainInput(input.domain, input.includeSubdomains);
|
|
const offset = (input.page - 1) * input.pageSize;
|
|
const orderBy = [`${SORT_FIELD_BY_MODE[input.sortMode]},${input.sortOrder}`];
|
|
const filters = buildPageFilters(input.filters, input.search);
|
|
|
|
const cacheKey = await buildCacheKey("domain:pages-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 = domainPagesPageResultSchema.safeParse(cachedRaw);
|
|
if (cached.success) {
|
|
return cached.data;
|
|
}
|
|
|
|
const dataforseo = createDataforseoClient(billingCustomer);
|
|
const response = await dataforseo.domain.relevantPages({
|
|
target: domain,
|
|
locationCode: input.locationCode,
|
|
languageCode: input.languageCode,
|
|
limit: input.pageSize,
|
|
offset,
|
|
orderBy,
|
|
filters: filters.length > 0 ? filters : undefined,
|
|
});
|
|
|
|
const pages = response.items
|
|
.map(mapPageItem)
|
|
.filter(
|
|
(item): item is NonNullable<ReturnType<typeof mapPageItem>> =>
|
|
item != null,
|
|
);
|
|
|
|
const totalCount = response.totalCount;
|
|
const hasMore =
|
|
totalCount != null
|
|
? offset + pages.length < totalCount
|
|
: pages.length === input.pageSize;
|
|
|
|
const result: DomainPagesPageResult = {
|
|
domain,
|
|
page: input.page,
|
|
pageSize: input.pageSize,
|
|
totalCount,
|
|
hasMore,
|
|
pages,
|
|
fetchedAt: new Date().toISOString(),
|
|
};
|
|
|
|
void setCached(cacheKey, result, DOMAIN_PAGES_PAGE_TTL_SECONDS).catch(
|
|
(error) => {
|
|
console.error("domain.pages-page.cache-write failed:", error);
|
|
},
|
|
);
|
|
|
|
return result;
|
|
}
|