seo 0.13.1 → 0.13.2

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-BIXGSxFJ.js";
2
+ import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-Dmtr8qrP.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";
@@ -39546,25 +39546,64 @@ function need(headers, provider) {
39546
39546
  if (values.some((value) => !value)) throw new CommandError("NO_KEY", `No ${provider} key. Hosted MCP: save it at https://seoskill.dev/account#keys. CLI and local MCP: run \`seo login\` to use the account's keys, or \`seo providers ${provider} connect\`.`);
39547
39547
  return values;
39548
39548
  }
39549
+ const TASK_TIMEOUT_MS = 6e4;
39550
+ const READ_TIMEOUT_MS = 3e4;
39551
+ const RETRY_DELAY_MS = 250;
39552
+ const retryStatus = (status) => status === 429 || status >= 500;
39549
39553
  async function call(provider, request) {
39550
- const response = await fetch(request);
39551
- if (response.ok) return response;
39552
- const text = (await response.text()).slice(0, 300);
39553
- throw new CommandError(response.status === 401 || response.status === 403 ? "NO_KEY" : "PROVIDER_ERROR", `${provider} answered ${response.status}. ${text}`);
39554
+ const read = request.method === "GET";
39555
+ const timeoutMs = read ? READ_TIMEOUT_MS : TASK_TIMEOUT_MS;
39556
+ for (let attempt = 1;; attempt += 1) {
39557
+ const last = attempt === 2;
39558
+ let response;
39559
+ try {
39560
+ response = await fetch(request.clone(), { signal: AbortSignal.timeout(timeoutMs) });
39561
+ } catch (error) {
39562
+ if (read && !last) {
39563
+ await wait(RETRY_DELAY_MS);
39564
+ continue;
39565
+ }
39566
+ throw new CommandError("PROVIDER_ERROR", error.name === "TimeoutError" ? `${provider} did not answer within ${timeoutMs / 1e3} seconds.` : `${provider} could not be reached: ${error.message}`);
39567
+ }
39568
+ if (response.ok) return response;
39569
+ if (retryStatus(response.status) && !last) {
39570
+ await response.body?.cancel();
39571
+ await wait(RETRY_DELAY_MS);
39572
+ continue;
39573
+ }
39574
+ const text = (await response.text()).slice(0, 300);
39575
+ throw new CommandError(response.status === 401 || response.status === 403 ? "NO_KEY" : response.status === 429 ? "RATE_LIMITED" : "PROVIDER_ERROR", `${provider} answered ${response.status}. ${text}`);
39576
+ }
39554
39577
  }
39578
+ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
39555
39579
  function requireInput(input) {
39556
39580
  const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
39557
39581
  if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
39558
39582
  if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
39559
39583
  }
39584
+ async function eachKeyword(keywords, missed, rowsFor) {
39585
+ const rows = [];
39586
+ let failure;
39587
+ for (const keyword of keywords) try {
39588
+ rows.push(...await rowsFor(keyword));
39589
+ } catch (error) {
39590
+ if (error instanceof CommandError && error.code === "NO_KEY") throw error;
39591
+ failure ??= error;
39592
+ missed.push(keyword);
39593
+ }
39594
+ if (missed.length === keywords.length) throw failure;
39595
+ return rows;
39596
+ }
39560
39597
  async function research(provider, headers, input) {
39561
39598
  requireInput(input);
39562
39599
  const keys = need(headers, provider);
39563
39600
  if (provider === "dataforseo") return dataForSeo(keys, input);
39601
+ const missed = [];
39564
39602
  return {
39565
- rows: await ahrefs(keys[0] ?? "", input),
39603
+ rows: await ahrefs(keys[0] ?? "", input, missed),
39566
39604
  costUsd: null,
39567
- partial: false
39605
+ partial: false,
39606
+ missed
39568
39607
  };
39569
39608
  }
39570
39609
  function countryName(code) {
@@ -39630,12 +39669,14 @@ const PARTIAL = 40106;
39630
39669
  async function dataForSeo(keys, input) {
39631
39670
  const spent = {
39632
39671
  cost: 0,
39633
- partial: false
39672
+ partial: false,
39673
+ missed: []
39634
39674
  };
39635
39675
  return {
39636
39676
  rows: await dataForSeoRows(keys, input, spent),
39637
39677
  costUsd: Math.round(spent.cost * 1e4) / 1e4,
39638
- partial: spent.partial
39678
+ partial: spent.partial,
39679
+ missed: spent.missed
39639
39680
  };
39640
39681
  }
39641
39682
  async function dataForSeoRows(keys, input, spent) {
@@ -39677,28 +39718,20 @@ async function dataForSeoRows(keys, input, spent) {
39677
39718
  keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
39678
39719
  main_intent: "keyword_data.search_intent_info.main_intent"
39679
39720
  }));
39680
- if (input.action === "serp") {
39681
- const rows = [];
39682
- for (const keyword of input.keywords.slice(0, 5)) {
39683
- const items = await post("v3/serp/google/organic/live/advanced", {
39684
- keyword,
39685
- depth: Math.min(input.limit, 100)
39686
- });
39687
- for (const item of items) {
39688
- if (item.type !== "organic") continue;
39689
- rows.push({
39690
- keyword,
39691
- ...pick(item, {
39692
- position: "rank_absolute",
39693
- url: "url",
39694
- domain: "domain",
39695
- title: "title"
39696
- })
39697
- });
39698
- }
39699
- }
39700
- return rows;
39701
- }
39721
+ if (input.action === "serp") return eachKeyword(input.keywords.slice(0, 5), spent.missed, async (keyword) => {
39722
+ return (await post("v3/serp/google/organic/live/advanced", {
39723
+ keyword,
39724
+ depth: Math.min(input.limit, 100)
39725
+ })).filter((item) => item.type === "organic").map((item) => ({
39726
+ keyword,
39727
+ ...pick(item, {
39728
+ position: "rank_absolute",
39729
+ url: "url",
39730
+ domain: "domain",
39731
+ title: "title"
39732
+ })
39733
+ }));
39734
+ });
39702
39735
  if (input.action === "backlinks") return (await post("v3/backlinks/referring_domains/live", {
39703
39736
  target: bareTarget(input.target),
39704
39737
  limit: input.limit,
@@ -39727,7 +39760,7 @@ function isPage(target) {
39727
39760
  function bareTarget(target) {
39728
39761
  return target.replace(/^https?:\/\//, "").replace(/\/$/, "");
39729
39762
  }
39730
- async function ahrefs(key, input) {
39763
+ async function ahrefs(key, input, missed) {
39731
39764
  const get = async (path, params) => {
39732
39765
  const url = new URL(`https://api.ahrefs.com/v3/${path}`);
39733
39766
  for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
@@ -39751,22 +39784,17 @@ async function ahrefs(key, input) {
39751
39784
  limit: String(input.limit),
39752
39785
  order_by: "sum_traffic:desc"
39753
39786
  })).keywords ?? [];
39754
- if (input.action === "serp") {
39755
- const rows = [];
39756
- for (const keyword of input.keywords.slice(0, 5)) {
39757
- const data = await get("serp-overview/serp-overview", {
39758
- select: "position,type,url,title,domain_rating,traffic,keywords",
39759
- keyword,
39760
- country: input.country,
39761
- top_positions: String(Math.min(input.limit, 100))
39762
- });
39763
- for (const row of data.positions ?? []) rows.push({
39764
- keyword,
39765
- ...row
39766
- });
39767
- }
39768
- return rows;
39769
- }
39787
+ if (input.action === "serp") return eachKeyword(input.keywords.slice(0, 5), missed, async (keyword) => {
39788
+ return ((await get("serp-overview/serp-overview", {
39789
+ select: "position,type,url,title,domain_rating,traffic,keywords",
39790
+ keyword,
39791
+ country: input.country,
39792
+ top_positions: String(Math.min(input.limit, 100))
39793
+ })).positions ?? []).map((row) => ({
39794
+ keyword,
39795
+ ...row
39796
+ }));
39797
+ });
39770
39798
  if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
39771
39799
  select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
39772
39800
  target: input.target,
@@ -42488,7 +42516,7 @@ async function runResearch(input, keys, options) {
42488
42516
  const offset = input.offset ?? 0;
42489
42517
  const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
42490
42518
  if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
42491
- const { rows: found, costUsd, partial } = await research(input.provider, keys, {
42519
+ const { rows: found, costUsd, partial, missed } = await research(input.provider, keys, {
42492
42520
  action: input.action,
42493
42521
  keywords: input.keywords ?? [],
42494
42522
  target,
@@ -42514,6 +42542,7 @@ async function runResearch(input, keys, options) {
42514
42542
  `${input.provider} estimates, in the provider’s own units.`,
42515
42543
  ...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
42516
42544
  ...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
42545
+ ...missed.length ? [`Partial: ${input.provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
42517
42546
  ...note ? [note] : [],
42518
42547
  ...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
42519
42548
  ...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
@@ -42531,6 +42560,7 @@ async function runResearch(input, keys, options) {
42531
42560
  leftOut: note,
42532
42561
  costUsd,
42533
42562
  partial,
42563
+ missedKeywords: missed,
42534
42564
  total: rated.rows.length,
42535
42565
  offset,
42536
42566
  rows: rated.rows.slice(offset, offset + 50)