feat: add paginated get_backlinks_profile MCP tool (#38)
* feat: add paginated get_backlinks_profile MCP tool Exposes detailed per-link backlink rows over MCP, reusing BacklinksService paginated row fetching. Closes #36. * fix(test): use type-narrowing text assertion to satisfy oxlint expect.stringContaining inside toMatchObject tripped typescript-eslint(no-unsafe-assignment), breaking ci:check. Match the .toContain() pattern used elsewhere in the MCP tool tests. * refactor(mcp): simplify get_backlinks_profile handler + fix cost estimate - Drop the redundant backlinksRowsPageRequestSchema.parse re-validation in the handler; the MCP SDK already validates args against inputSchema. Build the service request straight from args, removing the duplicated defaults that could silently diverge. - Preserve the target length cap by adding .max(2048) to the input schema (previously enforced only via the re-parse). - Correct the credit estimate in the tool description: measured cost is ~30 credits/page, not the ~200-500 copied from get_backlinks_overview. - Minor: type the statuses accumulator. * fix(mcp): correct get_backlinks_overview credit estimate Measured real cost: summary (~26 credits) + history for domain scope (~29 credits) = ~50 per domain, ~25 for a single page. The previous ~200-500 figure was a ~5-10x overestimate (same one get_backlinks_profile inherited). --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Ben Senescu <bensenescu@gmail.com>
This commit is contained in:
parent
200a6e8b61
commit
1dabbc88a6
@ -74,6 +74,11 @@ const toolCategories: ToolCategory[] = [
|
|||||||
title: "Get backlinks overview",
|
title: "Get backlinks overview",
|
||||||
description: "Check backlink and referring-domain stats.",
|
description: "Check backlink and referring-domain stats.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "get_backlinks_profile",
|
||||||
|
title: "Get backlinks profile",
|
||||||
|
description: "Fetch paginated link-level backlink rows.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -19,6 +19,17 @@ const mcpMetaOutputSchema = z
|
|||||||
// shape, so it validates both plain rows and typed instances.
|
// shape, so it validates both plain rows and typed instances.
|
||||||
export const looseObjectOutputSchema = z.object({}).passthrough();
|
export const looseObjectOutputSchema = z.object({}).passthrough();
|
||||||
|
|
||||||
|
export const backlinksProfileOutputSchema = z
|
||||||
|
.object({
|
||||||
|
rows: z.array(looseObjectOutputSchema),
|
||||||
|
totalCount: z.number().nullable(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
page: z.number(),
|
||||||
|
pageSize: z.number(),
|
||||||
|
fetchedAt: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
export const optionalMetaOutputSchema = {
|
export const optionalMetaOutputSchema = {
|
||||||
meta: mcpMetaOutputSchema.optional(),
|
meta: mcpMetaOutputSchema.optional(),
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation";
|
import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation";
|
||||||
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
|
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
|
||||||
|
import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile";
|
||||||
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
|
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
|
||||||
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
|
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
|
||||||
import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
|
import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
|
||||||
@ -101,6 +102,15 @@ export function registerOpenSeoMcpTools(server: McpServer) {
|
|||||||
getBacklinksOverviewTool.handler,
|
getBacklinksOverviewTool.handler,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
server.registerTool(
|
||||||
|
getBacklinksProfileTool.name,
|
||||||
|
getBacklinksProfileTool.config,
|
||||||
|
instrumentMcpToolHandler(
|
||||||
|
getBacklinksProfileTool.name,
|
||||||
|
getBacklinksProfileTool.config.outputSchema,
|
||||||
|
getBacklinksProfileTool.handler,
|
||||||
|
),
|
||||||
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getSerpResultsTool.name,
|
getSerpResultsTool.name,
|
||||||
getSerpResultsTool.config,
|
getSerpResultsTool.config,
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export const getBacklinksOverviewTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Get backlinks overview",
|
title: "Get backlinks overview",
|
||||||
description:
|
description:
|
||||||
"Returns a backlinks profile summary (total backlinks, referring domains, top referring domains). Charges credits (~200-500 typical). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.",
|
"Returns a backlinks profile summary (total backlinks, referring domains, top referring domains). Charges credits (~50 typical for a domain, ~25 for a single page). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.",
|
||||||
inputSchema,
|
inputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
overview: looseObjectOutputSchema,
|
overview: looseObjectOutputSchema,
|
||||||
|
|||||||
170
src/server/mcp/tools/get-backlinks-profile.ts
Normal file
170
src/server/mcp/tools/get-backlinks-profile.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
|
||||||
|
import { buildProjectMeta } from "@/server/mcp/context";
|
||||||
|
import { mcpResponse } from "@/server/mcp/formatters";
|
||||||
|
import {
|
||||||
|
backlinksProfileOutputSchema,
|
||||||
|
optionalMetaOutputSchema,
|
||||||
|
} from "@/server/mcp/output-schemas";
|
||||||
|
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
||||||
|
import { projectIdSchema } from "@/server/mcp/schemas";
|
||||||
|
import {
|
||||||
|
BACKLINKS_DEFAULT_SORT,
|
||||||
|
BACKLINKS_PAGE_SIZES,
|
||||||
|
DEFAULT_BACKLINKS_PAGE_SIZE,
|
||||||
|
backlinksRowsFiltersSchema,
|
||||||
|
backlinksRowsModeSchema,
|
||||||
|
backlinksRowsSortFieldSchema,
|
||||||
|
backlinksSortOrderSchema,
|
||||||
|
backlinksTargetScopeSchema,
|
||||||
|
} from "@/types/schemas/backlinks";
|
||||||
|
|
||||||
|
const inputSchema = {
|
||||||
|
projectId: projectIdSchema,
|
||||||
|
target: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(2048)
|
||||||
|
.describe(
|
||||||
|
"Domain or URL to analyze (e.g. 'example.com' or 'https://example.com/blog').",
|
||||||
|
),
|
||||||
|
scope: backlinksTargetScopeSchema
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"'domain' analyzes the whole domain; 'page' analyzes a specific URL. Defaults to 'domain'.",
|
||||||
|
),
|
||||||
|
page: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(1)
|
||||||
|
.describe("1-indexed results page. Defaults to 1."),
|
||||||
|
pageSize: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.refine((value) =>
|
||||||
|
(BACKLINKS_PAGE_SIZES as readonly number[]).includes(value),
|
||||||
|
)
|
||||||
|
.default(DEFAULT_BACKLINKS_PAGE_SIZE)
|
||||||
|
.describe("Rows per page. Allowed values: 50, 100, or 200."),
|
||||||
|
sortField: backlinksRowsSortFieldSchema
|
||||||
|
.default(BACKLINKS_DEFAULT_SORT.backlinks.field)
|
||||||
|
.describe("Backlink row sort field."),
|
||||||
|
sortOrder: backlinksSortOrderSchema
|
||||||
|
.default(BACKLINKS_DEFAULT_SORT.backlinks.order)
|
||||||
|
.describe("Sort direction."),
|
||||||
|
filters: backlinksRowsFiltersSchema
|
||||||
|
.default({})
|
||||||
|
.describe(
|
||||||
|
"Backlink row filters: include/exclude source URL terms, authority/spam ranges, dofollow/nofollow, lost/broken visibility, or exact domainFrom.",
|
||||||
|
),
|
||||||
|
mode: backlinksRowsModeSchema
|
||||||
|
.default("one_per_domain")
|
||||||
|
.describe(
|
||||||
|
"DataForSEO backlink grouping: one_per_domain returns each referring domain's strongest link; as_is returns individual backlink rows.",
|
||||||
|
),
|
||||||
|
hideSpam: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Filter out spammy backlinks. Defaults to true."),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
|
||||||
|
|
||||||
|
function formatMetric(value: unknown) {
|
||||||
|
return typeof value === "number" || typeof value === "string" ? value : "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLinkType(value: boolean | null | undefined) {
|
||||||
|
if (value === true) return "dofollow";
|
||||||
|
if (value === false) return "nofollow";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(row: {
|
||||||
|
isLost?: boolean | null;
|
||||||
|
isBroken?: boolean | null;
|
||||||
|
}) {
|
||||||
|
const statuses: string[] = [];
|
||||||
|
if (row.isLost) statuses.push("lost");
|
||||||
|
if (row.isBroken) statuses.push("broken");
|
||||||
|
return statuses.length > 0 ? statuses.join(", ") : "live";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBacklinkRow(row: {
|
||||||
|
domainFrom?: string | null;
|
||||||
|
urlFrom?: string | null;
|
||||||
|
urlTo?: string | null;
|
||||||
|
anchor?: string | null;
|
||||||
|
isDofollow?: boolean | null;
|
||||||
|
rank?: number | null;
|
||||||
|
domainFromRank?: number | null;
|
||||||
|
spamScore?: number | null;
|
||||||
|
isLost?: boolean | null;
|
||||||
|
isBroken?: boolean | null;
|
||||||
|
}) {
|
||||||
|
const source = row.urlFrom ?? row.domainFrom ?? "?";
|
||||||
|
return `- ${source} -> ${row.urlTo ?? "?"} anchor:"${row.anchor ?? ""}" ${formatLinkType(row.isDofollow)} rank:${formatMetric(row.rank)} domainRank:${formatMetric(row.domainFromRank)} spam:${formatMetric(row.spamScore)} status:${formatStatus(row)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBacklinksProfileTool = {
|
||||||
|
name: "get_backlinks_profile",
|
||||||
|
config: {
|
||||||
|
title: "Get backlinks profile",
|
||||||
|
description:
|
||||||
|
"Returns one bounded page of detailed backlink rows for a domain or page: linking URLs, target URLs, anchors, dofollow/nofollow, authority/spam signals, and lost/broken status. Supports filters, sorting, one_per_domain/as_is mode, and pagination. Charges credits (~30 per page typical). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.",
|
||||||
|
inputSchema,
|
||||||
|
outputSchema: {
|
||||||
|
backlinks: backlinksProfileOutputSchema,
|
||||||
|
...optionalMetaOutputSchema,
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
openWorldHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: withMcpProjectAuth(async (args: Args, context) => {
|
||||||
|
// The MCP SDK already validated args against inputSchema (which mirrors
|
||||||
|
// backlinksRowsPageRequestSchema), so pass them straight through.
|
||||||
|
const request = {
|
||||||
|
target: args.target,
|
||||||
|
scope: args.scope,
|
||||||
|
page: args.page,
|
||||||
|
pageSize: args.pageSize,
|
||||||
|
sortField: args.sortField,
|
||||||
|
sortOrder: args.sortOrder,
|
||||||
|
filters: args.filters,
|
||||||
|
mode: args.mode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const backlinks = await BacklinksService.profileBacklinksPage(
|
||||||
|
request,
|
||||||
|
context.billing,
|
||||||
|
{ hideSpam: args.hideSpam ?? true },
|
||||||
|
);
|
||||||
|
const shownRows = backlinks.rows.slice(0, 10);
|
||||||
|
const text = [
|
||||||
|
`Backlinks profile for ${request.target} (${request.scope ?? "domain"}):`,
|
||||||
|
`- page: ${backlinks.page}`,
|
||||||
|
`- page size: ${backlinks.pageSize}`,
|
||||||
|
`- rows returned: ${backlinks.rows.length}`,
|
||||||
|
`- total backlinks: ${formatMetric(backlinks.totalCount)}`,
|
||||||
|
`- has more: ${backlinks.hasMore ? "yes" : "no"}`,
|
||||||
|
"",
|
||||||
|
`Backlink rows (${shownRows.length} shown in text, ${backlinks.rows.length} in structured content):`,
|
||||||
|
...shownRows.map(formatBacklinkRow),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return mcpResponse({
|
||||||
|
text,
|
||||||
|
meta: buildProjectMeta(
|
||||||
|
context,
|
||||||
|
args.projectId,
|
||||||
|
`/p/${args.projectId}/backlinks`,
|
||||||
|
{ target: request.target, scope: request.scope },
|
||||||
|
),
|
||||||
|
structuredContent: { backlinks },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
@ -2,12 +2,32 @@ import {
|
|||||||
normalizeObjectSchema,
|
normalizeObjectSchema,
|
||||||
safeParseAsync,
|
safeParseAsync,
|
||||||
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||||
|
import type { ToolExtra } from "@/server/mcp/context";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getProjectForOrganization: vi.fn(),
|
||||||
|
profileBacklinksPage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("cloudflare:workers", () => ({
|
vi.mock("cloudflare:workers", () => ({
|
||||||
env: {},
|
env: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
||||||
|
ProjectService: {
|
||||||
|
getProjectForOrganization: mocks.getProjectForOrganization,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/features/backlinks/services/BacklinksService", () => ({
|
||||||
|
BacklinksService: {
|
||||||
|
profileBacklinksPage: mocks.profileBacklinksPage,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// A class instance reproduces what the DataForSEO SDK hands the tools: an
|
// A class instance reproduces what the DataForSEO SDK hands the tools: an
|
||||||
// object whose prototype is not Object.prototype (e.g.
|
// object whose prototype is not Object.prototype (e.g.
|
||||||
// DataforseoLabsSerpCompetitorsLiveItem). Zod 4's z.record() rejects those
|
// DataforseoLabsSerpCompetitorsLiveItem). Zod 4's z.record() rejects those
|
||||||
@ -21,6 +41,67 @@ class ProviderRow {
|
|||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authContext = {
|
||||||
|
userId: "user_123",
|
||||||
|
userEmail: "team@example.com",
|
||||||
|
organizationId: "org_123",
|
||||||
|
clientId: "client_123",
|
||||||
|
scopes: ["mcp"],
|
||||||
|
audience: "open-seo",
|
||||||
|
subject: "user_123",
|
||||||
|
baseUrl: "https://app.example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const authExtra: ToolExtra = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
requestId: 1,
|
||||||
|
sendNotification: vi.fn(),
|
||||||
|
sendRequest: vi.fn(),
|
||||||
|
authInfo: {
|
||||||
|
token: "token",
|
||||||
|
clientId: "client_123",
|
||||||
|
scopes: ["mcp"],
|
||||||
|
resource: new URL("https://app.example.com/mcp"),
|
||||||
|
extra: {
|
||||||
|
[MCP_AUTH_CONTEXT_PROP]: authContext,
|
||||||
|
},
|
||||||
|
} satisfies AuthInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const backlinkPage = {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
domainFrom: "source.example",
|
||||||
|
urlFrom: "https://source.example/post",
|
||||||
|
urlTo: "https://example.com/",
|
||||||
|
anchor: "Example",
|
||||||
|
itemType: "content",
|
||||||
|
isDofollow: true,
|
||||||
|
relAttributes: ["noopener"],
|
||||||
|
rank: 77,
|
||||||
|
domainFromRank: 65,
|
||||||
|
pageFromRank: 54,
|
||||||
|
spamScore: 3,
|
||||||
|
firstSeen: "2026-01-01",
|
||||||
|
lastSeen: "2026-03-01",
|
||||||
|
isLost: false,
|
||||||
|
isBroken: false,
|
||||||
|
linksCount: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalCount: 450,
|
||||||
|
hasMore: true,
|
||||||
|
page: 2,
|
||||||
|
pageSize: 50,
|
||||||
|
fetchedAt: "2026-06-25T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.getProjectForOrganization.mockReset();
|
||||||
|
mocks.profileBacklinksPage.mockReset();
|
||||||
|
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_123" });
|
||||||
|
});
|
||||||
|
|
||||||
describe("DataForSEO research tool output schemas", () => {
|
describe("DataForSEO research tool output schemas", () => {
|
||||||
// Every tool that streams provider rows straight to structuredContent.
|
// Every tool that streams provider rows straight to structuredContent.
|
||||||
it.each([
|
it.each([
|
||||||
@ -50,4 +131,143 @@ describe("DataForSEO research tool output schemas", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("get_backlinks_profile accepts a paginated backlinks profile payload", async () => {
|
||||||
|
const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
|
||||||
|
const schema = normalizeObjectSchema(
|
||||||
|
getBacklinksProfileTool.config.outputSchema,
|
||||||
|
);
|
||||||
|
if (!schema) throw new Error("output schema did not normalize");
|
||||||
|
|
||||||
|
const result = await safeParseAsync(schema, {
|
||||||
|
backlinks: backlinkPage,
|
||||||
|
meta: {
|
||||||
|
organizationId: "org_123",
|
||||||
|
projectId: "project_123",
|
||||||
|
url: "https://app.example.com/p/project_123/backlinks",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("get_backlinks_profile MCP tool", () => {
|
||||||
|
it("returns paginated backlink rows and honors filters, sorting, and mode", async () => {
|
||||||
|
mocks.profileBacklinksPage.mockResolvedValue(backlinkPage);
|
||||||
|
const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
|
||||||
|
|
||||||
|
const result = await getBacklinksProfileTool.handler(
|
||||||
|
{
|
||||||
|
projectId: "project_123",
|
||||||
|
target: "example.com",
|
||||||
|
scope: "domain",
|
||||||
|
page: 2,
|
||||||
|
pageSize: 50,
|
||||||
|
sortField: "spamScore",
|
||||||
|
sortOrder: "asc",
|
||||||
|
filters: {
|
||||||
|
include: "blog",
|
||||||
|
linkType: "nofollow",
|
||||||
|
hideLost: true,
|
||||||
|
},
|
||||||
|
mode: "as_is",
|
||||||
|
hideSpam: false,
|
||||||
|
},
|
||||||
|
authExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.profileBacklinksPage).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
target: "example.com",
|
||||||
|
scope: "domain",
|
||||||
|
page: 2,
|
||||||
|
pageSize: 50,
|
||||||
|
sortField: "spamScore",
|
||||||
|
sortOrder: "asc",
|
||||||
|
filters: {
|
||||||
|
include: "blog",
|
||||||
|
linkType: "nofollow",
|
||||||
|
hideLost: true,
|
||||||
|
},
|
||||||
|
mode: "as_is",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: "user_123",
|
||||||
|
userEmail: "team@example.com",
|
||||||
|
organizationId: "org_123",
|
||||||
|
projectId: "project_123",
|
||||||
|
},
|
||||||
|
{ hideSpam: false },
|
||||||
|
);
|
||||||
|
expect(result.structuredContent?.backlinks).toEqual(backlinkPage);
|
||||||
|
const first = result.content[0];
|
||||||
|
expect(first.type === "text" && first.text).toContain("- has more: yes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through final-page pagination state", async () => {
|
||||||
|
const finalPage = {
|
||||||
|
...backlinkPage,
|
||||||
|
totalCount: 51,
|
||||||
|
hasMore: false,
|
||||||
|
page: 2,
|
||||||
|
};
|
||||||
|
mocks.profileBacklinksPage.mockResolvedValue(finalPage);
|
||||||
|
const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
|
||||||
|
|
||||||
|
const result = await getBacklinksProfileTool.handler(
|
||||||
|
{
|
||||||
|
projectId: "project_123",
|
||||||
|
target: "example.com",
|
||||||
|
scope: "domain",
|
||||||
|
page: 2,
|
||||||
|
pageSize: 50,
|
||||||
|
sortField: "rank",
|
||||||
|
sortOrder: "desc",
|
||||||
|
filters: {},
|
||||||
|
mode: "one_per_domain",
|
||||||
|
hideSpam: true,
|
||||||
|
},
|
||||||
|
authExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.structuredContent?.backlinks).toMatchObject({
|
||||||
|
totalCount: 51,
|
||||||
|
hasMore: false,
|
||||||
|
page: 2,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves Backlinks API access and credit errors", async () => {
|
||||||
|
const { AppError } = await import("@/server/lib/errors");
|
||||||
|
const error = new AppError(
|
||||||
|
"BACKLINKS_BILLING_ISSUE",
|
||||||
|
"The connected DataForSEO account has a billing or balance issue",
|
||||||
|
);
|
||||||
|
mocks.profileBacklinksPage.mockRejectedValue(error);
|
||||||
|
const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
getBacklinksProfileTool.handler(
|
||||||
|
{
|
||||||
|
projectId: "project_123",
|
||||||
|
target: "example.com",
|
||||||
|
scope: "domain",
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
sortField: "rank",
|
||||||
|
sortOrder: "desc",
|
||||||
|
filters: {},
|
||||||
|
mode: "one_per_domain",
|
||||||
|
hideSpam: true,
|
||||||
|
},
|
||||||
|
authExtra,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "BACKLINKS_BILLING_ISSUE",
|
||||||
|
message:
|
||||||
|
"The connected DataForSEO account has a billing or balance issue",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export const backlinksRowsFiltersSchema = z.object({
|
|||||||
* each referring domain's strongest link (the default, denoised view);
|
* each referring domain's strongest link (the default, denoised view);
|
||||||
* `as_is` returns every individual backlink.
|
* `as_is` returns every individual backlink.
|
||||||
*/
|
*/
|
||||||
const backlinksRowsModeSchema = z.enum(["one_per_domain", "as_is"]);
|
export const backlinksRowsModeSchema = z.enum(["one_per_domain", "as_is"]);
|
||||||
|
|
||||||
export const referringDomainsFiltersSchema = z.object({
|
export const referringDomainsFiltersSchema = z.object({
|
||||||
include: z.string().optional(),
|
include: z.string().optional(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user