397 lines
12 KiB
TypeScript
397 lines
12 KiB
TypeScript
import { z } from "zod";
|
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
|
import type { CreditFeature } from "@/shared/billing-credit-features";
|
|
import {
|
|
createDataforseoClient,
|
|
normalizeBacklinksTarget,
|
|
type BacklinksHistoryItem,
|
|
type BacklinksItem,
|
|
type BacklinksSummaryItem,
|
|
type DomainPageSummaryItem,
|
|
type ReferringDomainItem,
|
|
} from "@/server/lib/dataforseo";
|
|
import type {
|
|
BacklinksLookupInput,
|
|
BacklinksRowsPageInput,
|
|
BacklinksSpamFilterOptions,
|
|
ReferringDomainsPageInput,
|
|
TopPagesPageInput,
|
|
} from "@/types/schemas/backlinks";
|
|
|
|
import {
|
|
backlinksOverviewSchema,
|
|
backlinksRowsPageResultSchema,
|
|
referringDomainsPageResultSchema,
|
|
topPagesPageResultSchema,
|
|
type BacklinksOverviewResult,
|
|
type BacklinksRowsPageResult,
|
|
type ReferringDomainsPageResult,
|
|
type TopPagesPageResult,
|
|
} from "@/server/features/backlinks/services/backlinksOverviewSchema";
|
|
import {
|
|
buildBacklinksRowsApiFilters,
|
|
buildBacklinksRowsOrderBy,
|
|
buildReferringDomainsApiFilters,
|
|
buildReferringDomainsOrderBy,
|
|
buildTopPagesApiFilters,
|
|
buildTopPagesOrderBy,
|
|
} from "@/server/features/backlinks/services/backlinksApiFilters";
|
|
|
|
// The page-request schemas carry projectId for the web middleware; the
|
|
// service layer is organization-scoped and never reads it.
|
|
export type BacklinksRowsPageServiceInput = Omit<
|
|
BacklinksRowsPageInput,
|
|
"projectId"
|
|
>;
|
|
export type ReferringDomainsPageServiceInput = Omit<
|
|
ReferringDomainsPageInput,
|
|
"projectId"
|
|
>;
|
|
export type TopPagesPageServiceInput = Omit<TopPagesPageInput, "projectId">;
|
|
|
|
const BACKLINKS_OVERVIEW_TTL_SECONDS = 6 * 60 * 60;
|
|
const BACKLINKS_TAB_TTL_SECONDS = 6 * 60 * 60;
|
|
|
|
const backlinksOverviewCacheSchema = z.object({
|
|
overview: backlinksOverviewSchema,
|
|
});
|
|
|
|
export type BacklinksCache = {
|
|
get(key: string): Promise<unknown>;
|
|
set(key: string, data: unknown, ttlSeconds: number): Promise<void>;
|
|
};
|
|
|
|
type BacklinksOverviewProfile = {
|
|
overview: BacklinksOverviewResult;
|
|
};
|
|
|
|
type BacklinksDateRange = {
|
|
dateFrom: string;
|
|
dateTo: string;
|
|
};
|
|
|
|
export async function profileBacklinksOverview(
|
|
cache: BacklinksCache,
|
|
cacheKey: string,
|
|
input: BacklinksLookupInput,
|
|
billingCustomer: BillingCustomerContext,
|
|
creditFeature?: CreditFeature,
|
|
): Promise<BacklinksOverviewProfile> {
|
|
const cached = backlinksOverviewCacheSchema.safeParse(
|
|
await cache.get(cacheKey),
|
|
);
|
|
if (cached.success) {
|
|
return {
|
|
overview: cached.data.overview,
|
|
};
|
|
}
|
|
|
|
const dataforseo = createDataforseoClient(billingCustomer);
|
|
|
|
const now = new Date();
|
|
const normalizedTarget = normalizeBacklinksTarget(input.target, {
|
|
scope: input.scope,
|
|
});
|
|
const dateRange = buildBacklinksDateRange(now);
|
|
|
|
const [summary, history] = await Promise.all([
|
|
dataforseo.backlinks.summary({
|
|
target: normalizedTarget.apiTarget,
|
|
creditFeature,
|
|
}),
|
|
normalizedTarget.scope === "domain"
|
|
? dataforseo.backlinks.history({
|
|
target: normalizedTarget.apiTarget,
|
|
...dateRange,
|
|
creditFeature,
|
|
})
|
|
: Promise.resolve([]),
|
|
]);
|
|
|
|
const overview = buildOverviewResult({
|
|
normalizedTarget,
|
|
now,
|
|
summary,
|
|
history,
|
|
});
|
|
await cacheValue(
|
|
cache,
|
|
cacheKey,
|
|
{ overview },
|
|
BACKLINKS_OVERVIEW_TTL_SECONDS,
|
|
);
|
|
|
|
return { overview };
|
|
}
|
|
|
|
export async function profileBacklinksRowsPage(
|
|
cache: BacklinksCache,
|
|
cacheKey: string,
|
|
input: BacklinksRowsPageServiceInput,
|
|
billingCustomer: BillingCustomerContext,
|
|
spamOptions?: BacklinksSpamFilterOptions,
|
|
): Promise<BacklinksRowsPageResult> {
|
|
const cached = backlinksRowsPageResultSchema.safeParse(
|
|
await cache.get(cacheKey),
|
|
);
|
|
if (cached.success) {
|
|
return cached.data;
|
|
}
|
|
|
|
const dataforseo = createDataforseoClient(billingCustomer);
|
|
const offset = (input.page - 1) * input.pageSize;
|
|
const filters = buildBacklinksRowsApiFilters(input.filters);
|
|
|
|
const response = await dataforseo.backlinks.rows({
|
|
target: normalizeBacklinksTarget(input.target, { scope: input.scope })
|
|
.apiTarget,
|
|
limit: input.pageSize,
|
|
offset,
|
|
orderBy: buildBacklinksRowsOrderBy(input.sortField, input.sortOrder),
|
|
filters: filters.length > 0 ? filters : undefined,
|
|
mode: input.mode,
|
|
...spamOptions,
|
|
});
|
|
|
|
const result = buildPageResult(input, offset, {
|
|
rows: mapBacklinksRows(response.items),
|
|
totalCount: response.totalCount,
|
|
});
|
|
await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS);
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function profileReferringDomainsPage(
|
|
cache: BacklinksCache,
|
|
cacheKey: string,
|
|
input: ReferringDomainsPageServiceInput,
|
|
billingCustomer: BillingCustomerContext,
|
|
spamOptions?: BacklinksSpamFilterOptions,
|
|
): Promise<ReferringDomainsPageResult> {
|
|
const cached = referringDomainsPageResultSchema.safeParse(
|
|
await cache.get(cacheKey),
|
|
);
|
|
if (cached.success) {
|
|
return cached.data;
|
|
}
|
|
|
|
const dataforseo = createDataforseoClient(billingCustomer);
|
|
const offset = (input.page - 1) * input.pageSize;
|
|
const filters = buildReferringDomainsApiFilters(input.filters);
|
|
|
|
const response = await dataforseo.backlinks.referringDomains({
|
|
target: normalizeBacklinksTarget(input.target, { scope: input.scope })
|
|
.apiTarget,
|
|
limit: input.pageSize,
|
|
offset,
|
|
orderBy: buildReferringDomainsOrderBy(input.sortField, input.sortOrder),
|
|
filters: filters.length > 0 ? filters : undefined,
|
|
...spamOptions,
|
|
});
|
|
|
|
const result = buildPageResult(input, offset, {
|
|
rows: mapReferringDomainsRows(response.items),
|
|
totalCount: response.totalCount,
|
|
});
|
|
await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS);
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function profileTopPagesPage(
|
|
cache: BacklinksCache,
|
|
cacheKey: string,
|
|
input: TopPagesPageServiceInput,
|
|
billingCustomer: BillingCustomerContext,
|
|
): Promise<TopPagesPageResult> {
|
|
const cached = topPagesPageResultSchema.safeParse(await cache.get(cacheKey));
|
|
if (cached.success) {
|
|
return cached.data;
|
|
}
|
|
|
|
const dataforseo = createDataforseoClient(billingCustomer);
|
|
const offset = (input.page - 1) * input.pageSize;
|
|
const filters = buildTopPagesApiFilters(input.filters);
|
|
|
|
const response = await dataforseo.backlinks.domainPages({
|
|
target: normalizeBacklinksTarget(input.target, { scope: input.scope })
|
|
.apiTarget,
|
|
limit: input.pageSize,
|
|
offset,
|
|
orderBy: buildTopPagesOrderBy(input.sortField, input.sortOrder),
|
|
filters: filters.length > 0 ? filters : undefined,
|
|
});
|
|
|
|
const result = buildPageResult(input, offset, {
|
|
rows: mapTopPagesRows(response.items),
|
|
totalCount: response.totalCount,
|
|
});
|
|
await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS);
|
|
|
|
return result;
|
|
}
|
|
|
|
function buildPageResult<TRow>(
|
|
input: { page: number; pageSize: number },
|
|
offset: number,
|
|
data: { rows: TRow[]; totalCount: number | null },
|
|
) {
|
|
const hasMore =
|
|
data.totalCount != null
|
|
? offset + data.rows.length < data.totalCount
|
|
: data.rows.length === input.pageSize;
|
|
|
|
return {
|
|
rows: data.rows,
|
|
totalCount: data.totalCount,
|
|
hasMore,
|
|
page: input.page,
|
|
pageSize: input.pageSize,
|
|
fetchedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function buildBacklinksDateRange(now: Date): BacklinksDateRange {
|
|
const todayUtc = new Date(
|
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
|
);
|
|
const dateToUtc = new Date(todayUtc);
|
|
dateToUtc.setUTCDate(dateToUtc.getUTCDate() - 1);
|
|
|
|
const dateFromUtc = new Date(dateToUtc);
|
|
dateFromUtc.setUTCFullYear(dateFromUtc.getUTCFullYear() - 1);
|
|
|
|
return {
|
|
dateFrom: dateFromUtc.toISOString().slice(0, 10),
|
|
dateTo: dateToUtc.toISOString().slice(0, 10),
|
|
};
|
|
}
|
|
|
|
function buildOverviewResult(args: {
|
|
normalizedTarget: ReturnType<typeof normalizeBacklinksTarget>;
|
|
now: Date;
|
|
summary: BacklinksSummaryItem;
|
|
history: BacklinksHistoryItem[];
|
|
}): BacklinksOverviewResult {
|
|
const historyRows = args.history
|
|
.map((item) => ({
|
|
date: normalizeHistoryDate(item.date),
|
|
backlinks: item.backlinks ?? null,
|
|
referringDomains: item.referring_domains ?? null,
|
|
rank: item.rank ?? null,
|
|
newBacklinks: item.new_backlinks ?? null,
|
|
lostBacklinks: item.lost_backlinks ?? null,
|
|
newReferringDomains:
|
|
item.new_referring_domains ?? item.new_reffering_domains ?? null,
|
|
lostReferringDomains:
|
|
item.lost_referring_domains ?? item.lost_reffering_domains ?? null,
|
|
}))
|
|
.filter(
|
|
(
|
|
item,
|
|
): item is typeof item & {
|
|
date: string;
|
|
} => item.date !== null,
|
|
);
|
|
|
|
return {
|
|
target: args.normalizedTarget.apiTarget,
|
|
displayTarget: args.normalizedTarget.displayTarget,
|
|
scope: args.normalizedTarget.scope,
|
|
summary: {
|
|
rank: args.summary.rank ?? null,
|
|
backlinks: args.summary.backlinks ?? null,
|
|
referringPages: args.summary.referring_pages ?? null,
|
|
referringDomains: args.summary.referring_domains ?? null,
|
|
brokenBacklinks: args.summary.broken_backlinks ?? null,
|
|
brokenPages: args.summary.broken_pages ?? null,
|
|
backlinksSpamScore: args.summary.backlinks_spam_score ?? null,
|
|
targetSpamScore: args.summary.info?.target_spam_score ?? null,
|
|
newBacklinks: args.summary.new_backlinks ?? null,
|
|
lostBacklinks: args.summary.lost_backlinks ?? null,
|
|
newReferringDomains:
|
|
args.summary.new_referring_domains ??
|
|
args.summary.new_reffering_domains ??
|
|
null,
|
|
lostReferringDomains:
|
|
args.summary.lost_referring_domains ??
|
|
args.summary.lost_reffering_domains ??
|
|
null,
|
|
},
|
|
trends: historyRows.map((item) => ({
|
|
date: item.date,
|
|
backlinks: item.backlinks,
|
|
referringDomains: item.referringDomains,
|
|
rank: item.rank,
|
|
})),
|
|
newLostTrends: historyRows.map((item) => ({
|
|
date: item.date,
|
|
newBacklinks: item.newBacklinks,
|
|
lostBacklinks: item.lostBacklinks,
|
|
newReferringDomains: item.newReferringDomains,
|
|
lostReferringDomains: item.lostReferringDomains,
|
|
})),
|
|
fetchedAt: args.now.toISOString(),
|
|
};
|
|
}
|
|
|
|
function normalizeHistoryDate(value: string | null | undefined) {
|
|
return value ? value.slice(0, 10) : null;
|
|
}
|
|
|
|
function mapBacklinksRows(rows: BacklinksItem[]) {
|
|
return rows.map((item) => ({
|
|
domainFrom: item.domain_from ?? null,
|
|
urlFrom: item.url_from ?? null,
|
|
urlTo: item.url_to ?? null,
|
|
anchor: item.anchor ?? null,
|
|
itemType: item.item_type ?? null,
|
|
isDofollow: item.dofollow ?? null,
|
|
relAttributes: item.rel_attributes ?? item.attributes ?? [],
|
|
rank: item.rank ?? null,
|
|
domainFromRank: item.domain_from_rank ?? null,
|
|
pageFromRank: item.page_from_rank ?? null,
|
|
spamScore: item.backlink_spam_score ?? item.backlinks_spam_score ?? null,
|
|
firstSeen: item.first_seen ?? null,
|
|
lastSeen: item.lost_date ?? item.last_visited ?? null,
|
|
isLost: item.is_lost ?? Boolean(item.lost_date),
|
|
isBroken: item.is_broken ?? false,
|
|
linksCount: item.links_count ?? null,
|
|
}));
|
|
}
|
|
|
|
function mapReferringDomainsRows(rows: ReferringDomainItem[]) {
|
|
return rows.map((item) => ({
|
|
domain: item.domain ?? null,
|
|
backlinks: item.backlinks ?? null,
|
|
referringPages: item.referring_pages ?? null,
|
|
rank: item.rank ?? null,
|
|
spamScore: item.backlinks_spam_score ?? null,
|
|
firstSeen: item.first_seen ?? null,
|
|
brokenBacklinks: item.broken_backlinks ?? null,
|
|
brokenPages: item.broken_pages ?? null,
|
|
}));
|
|
}
|
|
|
|
function mapTopPagesRows(rows: DomainPageSummaryItem[]) {
|
|
return rows.map((item) => ({
|
|
page: item.page ?? item.url ?? null,
|
|
backlinks: item.backlinks ?? null,
|
|
referringDomains: item.referring_domains ?? null,
|
|
rank: item.rank ?? null,
|
|
brokenBacklinks: item.broken_backlinks ?? null,
|
|
}));
|
|
}
|
|
|
|
async function cacheValue(
|
|
cache: BacklinksCache,
|
|
key: string,
|
|
data: unknown,
|
|
ttlSeconds: number,
|
|
) {
|
|
await cache.set(key, data, ttlSeconds).catch((error: unknown) => {
|
|
console.error("backlinks.cache-write failed:", error);
|
|
});
|
|
}
|