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/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-BR8_7rhM.js";
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
- provider === "dataforseo" ? keys.get("x-dataforseo-login") : keys.get("x-ahrefs-key"),
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(provider, keys, {
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[provider]} ${VOLUMES_DIFFER}`] : [],
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(provider, siteTotal, rows.length)] : [],
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 ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
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[provider]
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.enum(RESEARCH_PROVIDERS).optional().describe("Optional. Without it, the saved key; DataForSEO when both are saved."),
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 === "ahrefs") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
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.",