seo 0.17.2 → 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +84 -84
- package/dist/cli.js.map +1 -1
- package/dist/{dist-Ck71nZiP.js → dist-BNTdDj1i.js} +1 -1
- package/dist/{dist-CW8OlPkK.js → dist-CSPI6SOD.js} +37 -37
- package/dist/dist-CSPI6SOD.js.map +1 -0
- package/dist/{esm-BR8_7rhM.js → esm-HKeFfXOD.js} +2 -2
- package/dist/{esm-BR8_7rhM.js.map → esm-HKeFfXOD.js.map} +1 -1
- package/dist/index.d.ts +96 -42
- package/dist/index.js +191 -15
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +191 -14
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/dist/dist-CW8OlPkK.js.map +0 -1
package/dist/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-
|
|
2
|
+
import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-HKeFfXOD.js";
|
|
3
3
|
import { createHash, randomUUID, sign } from "node:crypto";
|
|
4
4
|
import { ZodOptional, z } from "zod";
|
|
5
5
|
import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
|
|
@@ -48041,6 +48041,46 @@ async function saveResearch(store, key, saved) {
|
|
|
48041
48041
|
if (!store) return;
|
|
48042
48042
|
await store.put(key, JSON.stringify(saved), RESEARCH_TTL_SECONDS).catch(() => void 0);
|
|
48043
48043
|
}
|
|
48044
|
+
// packages/core/dist/commands/research-installed.js
|
|
48045
|
+
async function installedSerpResearch(provider, input, serp) {
|
|
48046
|
+
if (!serp) throw new CommandError("INVALID_INPUT", `${provider} is not a built-in provider. Here the providers are dataforseo and ahrefs; installed provider packages run only in the seo CLI and the local MCP server (seo mcp serve).`);
|
|
48047
|
+
if (input.keywords.length === 0) throw new CommandError("INVALID_INPUT", "serp needs keywords.");
|
|
48048
|
+
const keywords = input.keywords.slice(0, serpKeywords(input));
|
|
48049
|
+
const language = dataForSeoMarket(input.country, null).language_code;
|
|
48050
|
+
const missed = [];
|
|
48051
|
+
const warnings = [];
|
|
48052
|
+
let costMicros = 0;
|
|
48053
|
+
let partial = false;
|
|
48054
|
+
let depth = serpDepth(input);
|
|
48055
|
+
const rows = await eachKeyword(keywords, missed, async (keyword) => {
|
|
48056
|
+
const got = await serp({
|
|
48057
|
+
provider,
|
|
48058
|
+
keyword,
|
|
48059
|
+
country: input.country,
|
|
48060
|
+
language,
|
|
48061
|
+
device: input.device ?? "desktop",
|
|
48062
|
+
depth: serpDepth(input)
|
|
48063
|
+
});
|
|
48064
|
+
depth = Math.min(depth, got.depth);
|
|
48065
|
+
costMicros = costMicros === null || got.costMicros === null ? null : costMicros + got.costMicros;
|
|
48066
|
+
partial ||= got.partial;
|
|
48067
|
+
warnings.push(...got.warnings.map((warning) => `${keyword}: ${warning}`));
|
|
48068
|
+
return got.rows.map((row) => ({
|
|
48069
|
+
keyword,
|
|
48070
|
+
...row
|
|
48071
|
+
}));
|
|
48072
|
+
});
|
|
48073
|
+
const answered = new Set(rows.map((row) => row.keyword));
|
|
48074
|
+
if (depth < serpDepth(input)) warnings.unshift(`The package reads at most ${depth} results a keyword, so this read ${depth} of the ${serpDepth(input)} asked for.`);
|
|
48075
|
+
return {
|
|
48076
|
+
rows,
|
|
48077
|
+
costUsd: costMicros === null ? null : Number((costMicros / 1e6).toFixed(6)),
|
|
48078
|
+
partial,
|
|
48079
|
+
missed,
|
|
48080
|
+
noData: keywords.filter((keyword) => !missed.includes(keyword) && !answered.has(keyword)),
|
|
48081
|
+
warnings
|
|
48082
|
+
};
|
|
48083
|
+
}
|
|
48044
48084
|
// packages/core/dist/commands/research.js
|
|
48045
48085
|
const RESEARCH_ACTIONS = [
|
|
48046
48086
|
"keyword_metrics",
|
|
@@ -48057,7 +48097,7 @@ const RESEARCH_ACTIONS = [
|
|
|
48057
48097
|
"index_check"
|
|
48058
48098
|
];
|
|
48059
48099
|
const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
|
|
48060
|
-
const RESEARCH_HELP = `provider is optional: without it the call uses the saved key, DataForSEO when both are saved. The target is a domain (the whole site), a path ending in / such as example.com/blog/ (every URL under it), or one page; scope overrides that. keyword_metrics (keywords: volume, difficulty, CPC), keyword_ideas (up to 5 seed keywords: searches that contain each seed, by volume), ranked_keywords (target: keywords a site, subfolder or page ranks for, with the provider's traffic total for the whole target), top_pages (target: the pages with the most estimated organic traffic), serp (keywords, up to 5: the ranking pages with their organic position beside DataForSEO's rank_absolute, which also counts AI Overviews and other blocks, plus the AI Overview's sources; device desktop or mobile, DataForSEO only; each with its domain's authority: DataForSEO domain_rank 0 to 100, Ahrefs domain_rating; depth sets results per keyword, default 20; rows run position by position across the keywords), index_check (DataForSEO only; target: one page URL; keywords: up to 2 exact phrases from the page, default its H1 or title. Searches Google for site:<url> and each "<phrase>" on desktop and mobile, retries failed searches, treats Google's fallback to unrelated sites as no match, and says indexed, not found or unknown with each search's evidence; about $0.01 a page), serp_overlap (2 to 10 keywords: for each pair, how many of their top 10 pages and sites are the same, to decide one page or several; results without a real page are counted, not compared), competitors (target: domains that share keywords), backlinks (target: referring domains), pages_by_backlinks (target: the pages with the most referring domains), link_gap (target and 2 to 5 competitors: referring domains that link to two or more competitors and not the target, highest authority first, from each site's top 1,000 referring domains; costs one backlinks call per site), ai_answers (DataForSEO only; target: the site. Without keywords it takes the site’s top 10 chat-style and long questions from Search Console’s AI-shaped queries itself. With up to 10 questions in keywords it asks those instead, and the target can be any domain, such as a competitor's. Each answer row also lists the other sites it cites, and the notes rank them. Answers come from DataForSEO's queue: answers not ready after about 40 seconds are returned as tasks, already paid for; call again with the same target and those tasks to read them at no cost. It asks ChatGPT search, Google AI Mode and Google AI Overviews each one and says whether the answer cites or mentions the site. DataForSEO’s queue prices are about $0.0072 a question across the three engines, so about $0.07 a run: tell the person before running it). Rows with a domain, and the target, get a free Ahrefs domain_rating column. Numbers are provider estimates, not Search Console data.`;
|
|
48100
|
+
const RESEARCH_HELP = `provider is optional: without it the call uses the saved key, DataForSEO when both are saved. In the CLI and the local MCP server, serp can also use a provider package installed on this machine that supplies live search results: pass its id as provider. The target is a domain (the whole site), a path ending in / such as example.com/blog/ (every URL under it), or one page; scope overrides that. keyword_metrics (keywords: volume, difficulty, CPC), keyword_ideas (up to 5 seed keywords: searches that contain each seed, by volume), ranked_keywords (target: keywords a site, subfolder or page ranks for, with the provider's traffic total for the whole target), top_pages (target: the pages with the most estimated organic traffic), serp (keywords, up to 5: the ranking pages with their organic position beside DataForSEO's rank_absolute, which also counts AI Overviews and other blocks, plus the AI Overview's sources; device desktop or mobile, DataForSEO only; each with its domain's authority: DataForSEO domain_rank 0 to 100, Ahrefs domain_rating; depth sets results per keyword, default 20; rows run position by position across the keywords), index_check (DataForSEO only; target: one page URL; keywords: up to 2 exact phrases from the page, default its H1 or title. Searches Google for site:<url> and each "<phrase>" on desktop and mobile, retries failed searches, treats Google's fallback to unrelated sites as no match, and says indexed, not found or unknown with each search's evidence; about $0.01 a page), serp_overlap (2 to 10 keywords: for each pair, how many of their top 10 pages and sites are the same, to decide one page or several; results without a real page are counted, not compared), competitors (target: domains that share keywords), backlinks (target: referring domains), pages_by_backlinks (target: the pages with the most referring domains), link_gap (target and 2 to 5 competitors: referring domains that link to two or more competitors and not the target, highest authority first, from each site's top 1,000 referring domains; costs one backlinks call per site), ai_answers (DataForSEO only; target: the site. Without keywords it takes the site’s top 10 chat-style and long questions from Search Console’s AI-shaped queries itself. With up to 10 questions in keywords it asks those instead, and the target can be any domain, such as a competitor's. Each answer row also lists the other sites it cites, and the notes rank them. Answers come from DataForSEO's queue: answers not ready after about 40 seconds are returned as tasks, already paid for; call again with the same target and those tasks to read them at no cost. It asks ChatGPT search, Google AI Mode and Google AI Overviews each one and says whether the answer cites or mentions the site. DataForSEO’s queue prices are about $0.0072 a question across the three engines, so about $0.07 a run: tell the person before running it). Rows with a domain, and the target, get a free Ahrefs domain_rating column. Numbers are provider estimates, not Search Console data.`;
|
|
48061
48101
|
const GENERIC_PLATFORMS = [
|
|
48062
48102
|
"youtube.com",
|
|
48063
48103
|
"facebook.com",
|
|
@@ -48119,6 +48159,8 @@ async function runResearch(input, keys, options) {
|
|
|
48119
48159
|
const offset = input.offset ?? 0;
|
|
48120
48160
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
48121
48161
|
const provider = chooseProvider(input.provider, keys);
|
|
48162
|
+
const builtIn = RESEARCH_PROVIDERS.includes(provider) ? provider : null;
|
|
48163
|
+
if (!builtIn && input.action !== "serp") throw new CommandError("INVALID_INPUT", `An installed provider answers serp only; ${input.action} needs a built-in provider.`);
|
|
48122
48164
|
if (input.action === "index_check") {
|
|
48123
48165
|
if (provider === "ahrefs") throw new CommandError("INVALID_INPUT", "index_check uses DataForSEO: it runs live Google searches, which Ahrefs does not offer.");
|
|
48124
48166
|
return runIndexCheck({
|
|
@@ -48151,20 +48193,23 @@ async function runResearch(input, keys, options) {
|
|
|
48151
48193
|
const store = options.store ?? workersResearchStore();
|
|
48152
48194
|
const key = await researchKey([
|
|
48153
48195
|
provider,
|
|
48154
|
-
|
|
48196
|
+
builtIn ? keys.get(KEY_HEADER[builtIn]) : null,
|
|
48155
48197
|
request
|
|
48156
48198
|
]);
|
|
48157
48199
|
const saved = await readResearch(store, key, limit);
|
|
48158
|
-
const result = saved?.result ?? await research(
|
|
48200
|
+
const result = saved?.result ?? (builtIn ? await research(builtIn, keys, {
|
|
48159
48201
|
...request,
|
|
48160
48202
|
limit
|
|
48161
|
-
})
|
|
48203
|
+
}) : await installedSerpResearch(provider, {
|
|
48204
|
+
...request,
|
|
48205
|
+
limit
|
|
48206
|
+
}, options.installedSerp));
|
|
48162
48207
|
if (!saved) await saveResearch(store, key, {
|
|
48163
48208
|
limit,
|
|
48164
48209
|
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
48165
48210
|
result
|
|
48166
48211
|
});
|
|
48167
|
-
const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared, aiOverviews } = result;
|
|
48212
|
+
const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared, aiOverviews, warnings } = result;
|
|
48168
48213
|
const costUsd = saved ? 0 : paid;
|
|
48169
48214
|
const order = new Map(request.keywords.map((word, index) => [word, index]));
|
|
48170
48215
|
const found = input.action === "serp" && request.keywords.length > 1 ? [...fetched].sort((a, b) => Number(a.position ?? 0) - Number(b.position ?? 0) || (order.get(String(a.keyword)) ?? 0) - (order.get(String(b.keyword)) ?? 0)) : fetched;
|
|
@@ -48193,18 +48238,19 @@ async function runResearch(input, keys, options) {
|
|
|
48193
48238
|
notes: [
|
|
48194
48239
|
`${provider} estimates, in the provider’s own units.`,
|
|
48195
48240
|
...compared ? [`Compared: ${compared.withPage} of ${compared.results} top results had a real page, so shared_pages counts only those; ${compared.results - compared.withPage} came back as Google redirect links and could not be compared by page. ${compared.withSite} had a known site for shared_sites. Unique pages and sites are counted per keyword.${compared.withPage < compared.results / 2 && provider === "dataforseo" ? " With this few pages, shared_pages cannot settle one page or several; provider ahrefs returns real pages." : ""}`] : [],
|
|
48196
|
-
...VOLUME_ACTIONS.includes(input.action) ? [`Volume model: ${VOLUME_MODEL[
|
|
48241
|
+
...VOLUME_ACTIONS.includes(input.action) && builtIn ? [`Volume model: ${VOLUME_MODEL[builtIn]} ${VOLUMES_DIFFER}`] : [],
|
|
48197
48242
|
...input.action === "serp" || input.action === "ranked_keywords" ? [positionNote(provider)] : [],
|
|
48198
48243
|
...aiOverviews?.length ? [aiOverviewNote(aiOverviews, request.keywords.length)] : input.action === "serp" && provider === "dataforseo" && rows.length ? ["No AI Overview on these results pages."] : [],
|
|
48199
48244
|
...perKeyword ? [`Results per keyword: ${Object.entries(perKeyword).map(([word, count]) => `${word} ${count}`).join(", ")}. Rows run position by position across the keywords.`] : [],
|
|
48200
|
-
...siteTotal ? [siteTotalNote(
|
|
48245
|
+
...siteTotal && builtIn ? [siteTotalNote(builtIn, siteTotal, rows.length)] : [],
|
|
48201
48246
|
...available !== void 0 && available > found.length ? [`Capped: ${provider} has ${available.toLocaleString("en-US")} rows for this; this read the first ${found.length.toLocaleString("en-US")} in its order.`] : [],
|
|
48202
48247
|
...!found.length && scope ? [emptyNote(provider, input.action)] : [],
|
|
48203
48248
|
...input.action === "serp" && provider === "dataforseo" ? [rankFailed ? "domain_rank is missing: the DataForSEO rank call failed, so it is unknown, not zero." : "domain_rank is DataForSEO's own authority score for the domain, 0 to 100. It is not Ahrefs' Domain Rating."] : [],
|
|
48204
48249
|
...input.action === "serp" && !drKey && provider === "dataforseo" ? ["No domain_rating column: Domain Rating by Ahrefs needs an Ahrefs key (or the hosted MCP at https://seoskill.dev/mcp, which adds it)."] : [],
|
|
48205
48250
|
...store && rated.rows.length > 50 ? ["The result is kept for a day, so the next pages, and the same rows in full, cost nothing more."] : [],
|
|
48206
|
-
...saved ? [`Read from the result saved at ${saved.savedAt}, at no new cost${paid ? ` (it cost $${paid} then)` : ""}. Results are kept for a day.`] : costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
|
|
48207
|
-
...partial ? [
|
|
48251
|
+
...saved ? [`Read from the result saved at ${saved.savedAt}, at no new cost${paid ? ` (it cost $${paid} then)` : ""}. Results are kept for a day.`] : costUsd !== null ? [builtIn ? `DataForSEO cost: $${costUsd}.` : `Cost the ${provider} package reports: $${costUsd}.`] : [],
|
|
48252
|
+
...partial ? [`Partial: ${builtIn ? "DataForSEO" : provider} could not fetch some pages of results, so rows may be missing.`] : [],
|
|
48253
|
+
...warnings?.length ? [`${provider} warnings: ${warnings.join(" ")}`] : [],
|
|
48208
48254
|
...missed.length ? [`Partial: ${provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
|
|
48209
48255
|
...noData.length ? [noDataNote(provider, input.action, noData)] : [],
|
|
48210
48256
|
...byDr ? [rows.length > GAP_RATED ? `Sorted by Domain Rating for the top ${GAP_RATED} rows by DataForSEO rank; the ${rows.length - GAP_RATED} rows after them follow by rank, with no Domain Rating.` : "Sorted by Domain Rating, then DataForSEO rank."] : [],
|
|
@@ -48222,9 +48268,9 @@ async function runResearch(input, keys, options) {
|
|
|
48222
48268
|
action: input.action,
|
|
48223
48269
|
target: target || null,
|
|
48224
48270
|
scope,
|
|
48225
|
-
siteTotal: siteTotal ? {
|
|
48271
|
+
siteTotal: siteTotal && builtIn ? {
|
|
48226
48272
|
...siteTotal,
|
|
48227
|
-
model: TRAFFIC_MODEL[
|
|
48273
|
+
model: TRAFFIC_MODEL[builtIn]
|
|
48228
48274
|
} : null,
|
|
48229
48275
|
available: available ?? null,
|
|
48230
48276
|
country,
|
|
@@ -48239,6 +48285,7 @@ async function runResearch(input, keys, options) {
|
|
|
48239
48285
|
partial,
|
|
48240
48286
|
missedKeywords: missed,
|
|
48241
48287
|
noDataKeywords: noData,
|
|
48288
|
+
...warnings?.length ? { warnings } : {},
|
|
48242
48289
|
...input.action === "link_gap" ? {
|
|
48243
48290
|
competitors: input.competitors ?? [],
|
|
48244
48291
|
cutAt: floor ?? null,
|
|
@@ -48255,6 +48302,7 @@ async function runResearch(input, keys, options) {
|
|
|
48255
48302
|
};
|
|
48256
48303
|
}
|
|
48257
48304
|
function positionNote(provider) {
|
|
48305
|
+
if (provider !== "dataforseo" && provider !== "ahrefs") return `position is the organic position the ${provider} package reports (its rankGroup); rank_absolute is its rank among every block on the page, and type is featured_snippet for a featured snippet, which has no organic position.`;
|
|
48258
48306
|
return provider === "dataforseo" ? "position is the organic position: the nth organic link (DataForSEO rank_group). rank_absolute counts every block on the page from the top, AI Overviews, People Also Ask, videos and images included, so it is higher. A featured snippet has type featured_snippet and no organic position." : "position is the organic position: the nth row Ahrefs marks organic. ahrefs_position is Ahrefs' own position field, and type lists what each row is.";
|
|
48259
48307
|
}
|
|
48260
48308
|
function aiOverviewNote(overviews, keywords) {
|
|
@@ -49271,7 +49319,7 @@ const SHARED_TOOLS = [
|
|
|
49271
49319
|
title: "Keyword, competitor, backlink and AI answer data",
|
|
49272
49320
|
description: `Data from the person’s own DataForSEO or Ahrefs account, which costs them credits. It uses the key the person saved on their seoskill.dev account (or on this machine for the CLI). Actions: ${RESEARCH_HELP}`,
|
|
49273
49321
|
input: z.object({
|
|
49274
|
-
provider: z.
|
|
49322
|
+
provider: z.string().max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional().describe(`${RESEARCH_PROVIDERS.join(" or ")}. Optional: without it, the saved key; DataForSEO when both are saved. For serp in the CLI and the local MCP server it can also be the id of an installed provider package with live search results.`),
|
|
49275
49323
|
action: z.enum(RESEARCH_ACTIONS),
|
|
49276
49324
|
keywords: z.array(z.string().min(1)).max(100).default([]),
|
|
49277
49325
|
target: z.string().default("").describe("Domain (the whole site), a path ending in / for every URL under it, such as example.com/blog/, or one page."),
|
|
@@ -49288,7 +49336,7 @@ const SHARED_TOOLS = [
|
|
|
49288
49336
|
run: async (surface, input) => {
|
|
49289
49337
|
const more = (offset) => `seo_research with the same input, detail "rows", offset ${offset}`;
|
|
49290
49338
|
if (input.action === "ai_answers") {
|
|
49291
|
-
if (input.provider
|
|
49339
|
+
if (input.provider && input.provider !== "dataforseo") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
|
|
49292
49340
|
return runAiAnswers({
|
|
49293
49341
|
site: input.keywords.length || input.tasks.length ? input.target : await surface.resolveSite(input.target),
|
|
49294
49342
|
country: input.country,
|
|
@@ -49303,6 +49351,7 @@ const SHARED_TOOLS = [
|
|
|
49303
49351
|
return runResearch(input, await surface.keys(), {
|
|
49304
49352
|
drKey: surface.drKey,
|
|
49305
49353
|
store: surface.researchStore,
|
|
49354
|
+
installedSerp: surface.installedSerp,
|
|
49306
49355
|
call: more
|
|
49307
49356
|
});
|
|
49308
49357
|
}
|
|
@@ -50265,6 +50314,133 @@ z.object({ packages: z.record(z.string(), z.object({
|
|
|
50265
50314
|
version: providerExactVersionSchema.optional(),
|
|
50266
50315
|
integrity: providerIntegritySchema.optional()
|
|
50267
50316
|
}).passthrough()) }).passthrough();
|
|
50317
|
+
// packages/core/dist/provider-extensions/serp.js
|
|
50318
|
+
const nullableText = z.string().max(5e4).nullable();
|
|
50319
|
+
const serpSnapshotResultSchema = z.object({
|
|
50320
|
+
observedAt: z.string().max(100),
|
|
50321
|
+
effectiveKeyword: z.string().trim().min(1).max(1e3),
|
|
50322
|
+
searchEngineDomain: z.string().max(253).nullable(),
|
|
50323
|
+
checkUrl: z.url().max(8192).nullable(),
|
|
50324
|
+
resultCount: z.number().int().nonnegative().safe().nullable(),
|
|
50325
|
+
pagesCount: z.number().int().nonnegative().max(100).safe().nullable(),
|
|
50326
|
+
features: z.array(z.string().trim().min(1).max(100)).max(100),
|
|
50327
|
+
organicResults: z.array(z.object({
|
|
50328
|
+
rankGroup: z.number().int().positive().safe(),
|
|
50329
|
+
rankAbsolute: z.number().int().positive().safe(),
|
|
50330
|
+
page: z.number().int().positive().max(100).safe(),
|
|
50331
|
+
domain: z.string().trim().min(1).max(253),
|
|
50332
|
+
url: z.url().max(8192),
|
|
50333
|
+
title: z.string().max(1e4).nullable(),
|
|
50334
|
+
description: nullableText,
|
|
50335
|
+
isFeaturedSnippet: z.boolean().nullable()
|
|
50336
|
+
}).strict()).max(100),
|
|
50337
|
+
localPack: z.object({
|
|
50338
|
+
present: z.boolean(),
|
|
50339
|
+
returnedRows: z.number().int().nonnegative().max(100).safe(),
|
|
50340
|
+
retainedRows: z.number().int().nonnegative().max(100).safe(),
|
|
50341
|
+
invalidRows: z.number().int().nonnegative().max(100).safe(),
|
|
50342
|
+
results: z.array(z.unknown()).max(100)
|
|
50343
|
+
}).strict(),
|
|
50344
|
+
coverage: z.object({
|
|
50345
|
+
returnedRows: z.number().int().nonnegative().max(1e4).safe(),
|
|
50346
|
+
retainedRows: z.number().int().nonnegative().max(100).safe(),
|
|
50347
|
+
invalidRows: z.number().int().nonnegative().max(1e4).safe(),
|
|
50348
|
+
providerTotalRows: z.number().int().nonnegative().safe().nullable(),
|
|
50349
|
+
completeness: z.enum([
|
|
50350
|
+
"complete",
|
|
50351
|
+
"partial",
|
|
50352
|
+
"capped"
|
|
50353
|
+
]),
|
|
50354
|
+
nextCursor: z.string().max(500).nullable()
|
|
50355
|
+
}).strict(),
|
|
50356
|
+
request: z.object({
|
|
50357
|
+
endpoint: z.string().trim().min(1).max(500),
|
|
50358
|
+
filters: z.record(z.string().min(1).max(100), z.union([
|
|
50359
|
+
z.string().max(1e3),
|
|
50360
|
+
z.number().finite(),
|
|
50361
|
+
z.boolean()
|
|
50362
|
+
])),
|
|
50363
|
+
sort: z.array(z.string().min(1).max(200)).max(20)
|
|
50364
|
+
}).strict(),
|
|
50365
|
+
cost: z.object({
|
|
50366
|
+
estimatedMicros: z.number().int().nonnegative().safe().nullable(),
|
|
50367
|
+
actualMicros: z.number().int().nonnegative().safe().nullable(),
|
|
50368
|
+
taskIds: z.array(z.string().min(1).max(500)).max(20),
|
|
50369
|
+
native: z.object({
|
|
50370
|
+
unit: z.string().trim().min(1).max(100),
|
|
50371
|
+
estimatedUnits: z.number().finite().nonnegative().nullable(),
|
|
50372
|
+
actualUnits: z.number().finite().nonnegative().nullable(),
|
|
50373
|
+
remainingBefore: z.number().finite().nonnegative().nullable()
|
|
50374
|
+
}).strict().optional()
|
|
50375
|
+
}).strict(),
|
|
50376
|
+
qualityWarnings: z.array(z.object({
|
|
50377
|
+
code: z.string().trim().min(1).max(100),
|
|
50378
|
+
message: z.string().trim().min(1).max(500),
|
|
50379
|
+
field: z.string().max(200).optional(),
|
|
50380
|
+
row: z.number().int().positive().safe().optional()
|
|
50381
|
+
}).strict()).max(50)
|
|
50382
|
+
}).strict();
|
|
50383
|
+
const same = (list, value) => !list || list.some((item) => item.toLowerCase() === value.toLowerCase());
|
|
50384
|
+
function supports(capability, market) {
|
|
50385
|
+
return capability.markets.some((entry) => same(entry.searchEngines, market.searchEngine) && same(entry.countryCodes, market.countryCode) && same(entry.languageCodes, market.languageCode) && same(entry.devices, market.device ?? "desktop"));
|
|
50386
|
+
}
|
|
50387
|
+
const installedProviderSerp = async (input) => {
|
|
50388
|
+
const loaded = await loadProviderExtensions();
|
|
50389
|
+
const provider = loaded.registry.get(input.provider);
|
|
50390
|
+
if (!provider) throw new CommandError("INVALID_INPUT", `No provider ${input.provider} is installed. The built-in providers are dataforseo and ahrefs; \`seo providers list\` shows installed packages.`);
|
|
50391
|
+
const capability = loaded.registry.capability(provider.id, "serp-snapshot");
|
|
50392
|
+
if (!capability) throw new CommandError("INVALID_INPUT", `${provider.displayName} has no live search results (serp-snapshot), so it cannot answer serp. \`seo providers describe ${provider.id}\` lists its own actions.`);
|
|
50393
|
+
const market = {
|
|
50394
|
+
searchEngine: "google",
|
|
50395
|
+
countryCode: input.country.toUpperCase(),
|
|
50396
|
+
languageCode: input.language,
|
|
50397
|
+
device: input.device
|
|
50398
|
+
};
|
|
50399
|
+
if (!supports(capability, market)) throw new CommandError("INVALID_INPUT", `${provider.displayName} has no ${input.device} Google results for country ${input.country} in language ${input.language}.`);
|
|
50400
|
+
let account;
|
|
50401
|
+
let credentials;
|
|
50402
|
+
try {
|
|
50403
|
+
account = normalizeProviderExtensionAccount(provider, readProviderExtensionAccount(provider.id));
|
|
50404
|
+
credentials = await readProviderExtensionCredentials({
|
|
50405
|
+
providerId: provider.id,
|
|
50406
|
+
account,
|
|
50407
|
+
fields: provider.connection.fields
|
|
50408
|
+
});
|
|
50409
|
+
} catch (error) {
|
|
50410
|
+
throw new CommandError("NO_KEY", `${error instanceof Error ? error.message : String(error)} Connect it with \`seo providers connect ${provider.id}\`.`);
|
|
50411
|
+
}
|
|
50412
|
+
const depth = Math.min(input.depth, capability.maxDepth);
|
|
50413
|
+
let raw;
|
|
50414
|
+
try {
|
|
50415
|
+
raw = await capability.run({
|
|
50416
|
+
account,
|
|
50417
|
+
credentials,
|
|
50418
|
+
keyword: input.keyword,
|
|
50419
|
+
market,
|
|
50420
|
+
depth
|
|
50421
|
+
}, runtimeForProvider(provider.id, { maxRequests: capability.maxRequests }));
|
|
50422
|
+
} catch (error) {
|
|
50423
|
+
throw new CommandError("PROVIDER_ERROR", safeProviderAdapterError(provider.id, error, credentials).message);
|
|
50424
|
+
}
|
|
50425
|
+
const parsed = serpSnapshotResultSchema.safeParse(raw);
|
|
50426
|
+
if (!parsed.success) throw new CommandError("PROVIDER_ERROR", `${provider.displayName} returned a results page that does not match the serp-snapshot shape.`);
|
|
50427
|
+
const result = parsed.data;
|
|
50428
|
+
const warnings = result.qualityWarnings.map((warning) => warning.message);
|
|
50429
|
+
return {
|
|
50430
|
+
rows: [...result.organicResults].sort((a, b) => a.rankAbsolute - b.rankAbsolute || (a.url < b.url ? -1 : 1)).map((item) => ({
|
|
50431
|
+
position: item.isFeaturedSnippet ? null : item.rankGroup,
|
|
50432
|
+
rank_absolute: item.rankAbsolute,
|
|
50433
|
+
type: item.isFeaturedSnippet ? "featured_snippet" : "organic",
|
|
50434
|
+
url: item.url,
|
|
50435
|
+
domain: item.domain,
|
|
50436
|
+
title: item.title
|
|
50437
|
+
})),
|
|
50438
|
+
costMicros: result.cost.actualMicros,
|
|
50439
|
+
partial: result.coverage.completeness === "partial",
|
|
50440
|
+
warnings,
|
|
50441
|
+
depth
|
|
50442
|
+
};
|
|
50443
|
+
};
|
|
50268
50444
|
z.object({ limits_and_usage: z.object({
|
|
50269
50445
|
api_key_expiration_date: z.string().trim().min(1).max(100),
|
|
50270
50446
|
subscription: z.string().trim().min(1).max(200),
|
|
@@ -53054,6 +53230,7 @@ function registerSharedTools(server, options = {}) {
|
|
|
53054
53230
|
refreshClicky: localRefreshClicky,
|
|
53055
53231
|
drKey: null,
|
|
53056
53232
|
researchStore: localResearchStore(),
|
|
53233
|
+
installedSerp: installedProviderSerp,
|
|
53057
53234
|
accounts: localAccounts,
|
|
53058
53235
|
signIn: LOCAL_SIGN_IN.signIn,
|
|
53059
53236
|
connect: "run `seo login` to use your seoskill.dev account (Google, Bing and keys in one place), or `seo auth login`, `seo providers bing connect` and `seo providers dataforseo connect` or `seo providers ahrefs connect` on this machine only.",
|