seo 0.4.0 → 0.4.1

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 load, r as __commonJSMin, t as version } from "./package-BfqlCbxN.js";
2
+ import { i as __toESM, n as load, r as __commonJSMin, t as version } from "./package-CeYsRRpB.js";
3
3
  import { createHash, randomUUID, sign } from "node:crypto";
4
4
  import { ZodOptional, z } from "zod";
5
5
  import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -19914,7 +19914,7 @@ const TOPIC_WORDS = /* @__PURE__ */ new Set([
19914
19914
  "free",
19915
19915
  "guide",
19916
19916
  "how",
19917
- "inference",
19917
+ "example",
19918
19918
  "install",
19919
19919
  "integration",
19920
19920
  "local",
@@ -30733,12 +30733,13 @@ async function listSearchUpdates(input = {}) {
30733
30733
  /** The step-change heuristic and its thresholds, returned with the report. */
30734
30734
  const STEP_CHANGE_RULES = {
30735
30735
  method: "best-split",
30736
- description: "Heuristic. The change date is the split of the daily series where the mean before and the mean after differ most, weighted by segment length. It counts as a step change only when it is sudden and sustained. Sudden: the median of up to localDays observed days after the date differs from the median of up to localDays days before it by at least minLocalShiftInStdDevs pooled within-segment standard deviations, so one unusual day is not a step and a slow trend is not a step. Sustained: the mean of every day from the date differs from the mean before it by a z-score of at least minZScore, stricter than the recent-window test because the date is chosen from the data. Otherwise the last recentDays are compared with the days before them.",
30736
+ description: "Heuristic. The change date is the split of the daily series where the mean before and the mean after differ most, weighted by segment length. It counts as a step change only when it is sudden and sustained. Sudden: the median of up to localDays observed days after the date differs from the median of up to localDays days before it by at least minLocalShiftInStdDevs pooled within-segment standard deviations, so one unusual day is not a step and a slow trend is not a step. Sustained: the mean of every day from the date differs from the mean before it by a z-score of at least minZScore, stricter than the recent-window test because the date is chosen from the data. These tests use daily values capped at the capDays and 1 - capDays quantiles, so one spike day cannot hide a lasting step. Otherwise the last recentDays are compared with the days before them.",
30737
30737
  minDaysBefore: 7,
30738
30738
  minDaysAfter: 3,
30739
30739
  localDays: 7,
30740
30740
  minLocalShiftInStdDevs: 2,
30741
- minZScore: 3
30741
+ minZScore: 3,
30742
+ capDays: .05
30742
30743
  };
30743
30744
  /**
30744
30745
  * The dates to check against Google updates (before padding): the change
@@ -30914,10 +30915,18 @@ function sumOfSquaredDeviations(values) {
30914
30915
  const average = mean(values);
30915
30916
  return values.reduce((sum, value) => sum + (value - average) ** 2, 0);
30916
30917
  }
30917
- function findStepChange(values) {
30918
+ function capped(values) {
30919
+ const sorted = [...values].sort((a, b) => a - b);
30920
+ const last = sorted.length - 1;
30921
+ const low = sorted[Math.floor(last * STEP_CHANGE_RULES.capDays)] ?? 0;
30922
+ const high = sorted[Math.ceil(last * (1 - STEP_CHANGE_RULES.capDays))] ?? 0;
30923
+ return values.map((value) => Math.min(high, Math.max(low, value)));
30924
+ }
30925
+ function findStepChange(observed) {
30918
30926
  const { minDaysBefore, minDaysAfter, localDays, minLocalShiftInStdDevs, minZScore } = STEP_CHANGE_RULES;
30919
- const count = values.length;
30927
+ const count = observed.length;
30920
30928
  if (count < minDaysBefore + minDaysAfter) return null;
30929
+ const values = capped(observed);
30921
30930
  const sums = [0];
30922
30931
  for (const value of values) sums.push((sums.at(-1) ?? 0) + value);
30923
30932
  const total = sums[count] ?? 0;
@@ -36213,7 +36222,7 @@ function bingSiteFor(sites, target) {
36213
36222
  };
36214
36223
  const wanted = host(target);
36215
36224
  if (!wanted) return null;
36216
- return sites.filter((site) => site.verified && host(site.url) === wanted).sort((a, b) => Number(b.url.startsWith("https://")) - Number(a.url.startsWith("https://")) || a.url.localeCompare(b.url, "en"))[0]?.url ?? null;
36225
+ return sites.filter((site) => host(site.url) === wanted).sort((a, b) => Number(b.verified) - Number(a.verified) || Number(b.url.startsWith("https://")) - Number(a.url.startsWith("https://")) || a.url.localeCompare(b.url, "en"))[0] ?? null;
36217
36226
  }
36218
36227
  //#endregion
36219
36228
  //#region packages/core/dist/commands/present.js
@@ -36789,6 +36798,8 @@ async function runAnalyticsRows(input, google, options) {
36789
36798
  const BING_LIMITS = ["Bing and Google count clicks, impressions and positions their own way. Their numbers sit side by side here and are never added together.", "Bing lists only its top queries and pages, grouped by week. A query or page missing from Bing’s list is unknown, not zero."];
36790
36799
  const LOCAL_BING = "Bing data runs on the person’s machine with the CLI: connect a Bing Webmaster API key once with `npx -y seo providers bing connect`, then run `npx -y seo report --site <site> --section bing --json`.";
36791
36800
  const NO_BING_HERE = "Bing: not included here, so these totals show Google only. Some sites get most of their search visits from Bing; run `npx -y seo report --site <site> --section bing --json` with the CLI to see them.";
36801
+ const BING_HOME = "https://www.bing.com/webmasters";
36802
+ const noBingData = (site) => `Bing has no data for ${site} yet. Newly added or newly verified sites take a few days to show data in Bing Webmaster Tools.`;
36792
36803
  const sitesSeen = /* @__PURE__ */ new WeakMap();
36793
36804
  async function bingFor(deps, property) {
36794
36805
  if (!deps.bing) return {
@@ -36808,13 +36819,14 @@ async function bingFor(deps, property) {
36808
36819
  sitesSeen.set(call, sites);
36809
36820
  }
36810
36821
  const site = bingSiteFor(await sites, property);
36811
- return site ? {
36822
+ if (site?.verified) return {
36812
36823
  call,
36813
- site
36814
- } : {
36824
+ site: site.url
36825
+ };
36826
+ return {
36815
36827
  call: null,
36816
36828
  site: null,
36817
- missing: `The connected Bing account has no verified site for ${property.replace(/^sc-domain:/, "")}. Add it in Bing Webmaster Tools; it can import sites from Search Console.`
36829
+ missing: site ? `${site.url} is in the connected Bing account but is not verified, so Bing gives no data for it. Verify it at ${BING_HOME}.` : `The connected Bing account does not have ${property.replace(/^sc-domain:/, "")}. Add and verify it at ${BING_HOME} (it can import sites from Search Console). New sites take a few days to show data.`
36818
36830
  };
36819
36831
  }
36820
36832
  function days(range) {
@@ -36823,6 +36835,7 @@ function days(range) {
36823
36835
  async function bingTotals(source, range, before) {
36824
36836
  if (!source.call) return { missing: source.missing };
36825
36837
  const traffic = await bingTraffic(source.call, source.site);
36838
+ if (!traffic.rows.length && !traffic.invalidRows) return { missing: noBingData(source.site) };
36826
36839
  const sum = (window) => {
36827
36840
  const rows = traffic.rows.filter((row) => row.date >= window.startDate && row.date <= window.endDate);
36828
36841
  return {
@@ -36889,24 +36902,26 @@ async function bingSection(source, read, property, range) {
36889
36902
  dataState: "final",
36890
36903
  maxRows: 25e3
36891
36904
  });
36892
- const [queries, pages, googleQueries, googlePages] = await Promise.all([
36905
+ const [queries, pages, googleRead] = await Promise.all([
36893
36906
  bingQueries(source.call, source.site),
36894
36907
  bingPages(source.call, source.site),
36895
- google("query"),
36896
- google("page")
36908
+ Promise.all([google("query"), google("page")]).catch((error) => error instanceof Error ? error : new Error(String(error)))
36897
36909
  ]);
36910
+ const googleError = googleRead instanceof Error ? googleRead : null;
36911
+ const [googleQueries, googlePages] = googleRead instanceof Error ? [] : googleRead;
36898
36912
  const beside = (bing, googleRows, name) => {
36899
36913
  const norm = (value) => name === "query" ? value.toLowerCase().trim() : value.replace(/#.*$/, "");
36900
- const byKey = new Map(googleRows.map((row) => [norm(row.keys[0] ?? ""), row]));
36914
+ const byKey = new Map(googleRows?.map((row) => [norm(row.keys[0] ?? ""), row]));
36901
36915
  return bing.map((row) => {
36902
36916
  const g = byKey.get(norm(row.key));
36917
+ const known = googleRows ? 0 : null;
36903
36918
  return {
36904
36919
  [name]: row.key,
36905
36920
  bingClicks: row.clicks,
36906
36921
  bingImpressions: row.impressions,
36907
36922
  bingPosition: row.position,
36908
- googleClicks: g?.clicks ?? 0,
36909
- googleImpressions: g?.impressions ?? 0,
36923
+ googleClicks: g?.clicks ?? known,
36924
+ googleImpressions: g?.impressions ?? known,
36910
36925
  googlePosition: g ? Math.round(g.position * 10) / 10 : null
36911
36926
  };
36912
36927
  });
@@ -36914,6 +36929,7 @@ async function bingSection(source, read, property, range) {
36914
36929
  const q = sumWeeks(queries.rows, range);
36915
36930
  const p = sumWeeks(pages.rows, range);
36916
36931
  const invalid = queries.invalidRows + pages.invalidRows;
36932
+ const empty = !queries.rows.length && !pages.rows.length && !invalid;
36917
36933
  return {
36918
36934
  range,
36919
36935
  bingSite: source.site,
@@ -36922,9 +36938,11 @@ async function bingSection(source, read, property, range) {
36922
36938
  bingPages: p.rows.length,
36923
36939
  weeks: q.weeks.length
36924
36940
  },
36925
- items: beside(q.rows, googleQueries.rows, "query"),
36926
- topPages: beside(p.rows, googlePages.rows, "page"),
36941
+ items: beside(q.rows, googleQueries?.rows, "query"),
36942
+ topPages: beside(p.rows, googlePages?.rows, "page"),
36927
36943
  caveats: [
36944
+ ...empty ? [noBingData(source.site)] : [],
36945
+ ...googleError ? [`Search Console was not available, so the Google columns are empty (unknown, not zero): ${googleError.message}`] : [],
36928
36946
  `Bing weeks counted: ${q.weeks.join(", ") || "none"} (each date names a week). Google numbers cover ${range.startDate} to ${range.endDate} exactly.`,
36929
36947
  "Google numbers are for the same query text or page URL. 0 means Google showed no row for it in these dates.",
36930
36948
  ...BING_LIMITS,
@@ -36962,6 +36980,7 @@ async function runBingRows(input, deps) {
36962
36980
  });
36963
36981
  }
36964
36982
  const notes = [
36983
+ ...all.rows.length || all.invalidRows ? [] : [noBingData(source.site)],
36965
36984
  `Bing weeks counted: ${now.weeks.join(", ") || "none"}; earlier period ${then.weeks.join(", ") || "none"}. Each date names a week.`,
36966
36985
  ...BING_LIMITS.slice(1),
36967
36986
  ...all.invalidRows ? [`${all.invalidRows} Bing rows were not valid and were left out.`] : []
@@ -37172,6 +37191,11 @@ async function facts(url) {
37172
37191
  function checkPages(urls) {
37173
37192
  return Promise.all(urls.slice(0, MAX_PAGES).map(facts));
37174
37193
  }
37194
+ function temporaryRedirectNote(pages) {
37195
+ const temporary = pages.filter((page) => (page.status === 302 || page.status === 307) && page.redirects_to !== null && new URL(page.redirects_to).host === new URL(page.url).host);
37196
+ if (!temporary.length) return null;
37197
+ return `Note: 302 and 307 are temporary redirects; a permanent move usually uses 301 or 308. Temporary redirects here: ${temporary.map((page) => `${page.url} (${page.status} to ${page.redirects_to})`).join("; ")}.`;
37198
+ }
37175
37199
  //#endregion
37176
37200
  //#region packages/core/dist/commands/report.js
37177
37201
  const SECTIONS = {
@@ -37461,7 +37485,7 @@ function reportUrls(rows, origin) {
37461
37485
  return found;
37462
37486
  }
37463
37487
  function pageFactsTable(checked, origin) {
37464
- return table(checked, [
37488
+ const rows = table(checked, [
37465
37489
  "url",
37466
37490
  "status",
37467
37491
  "redirects_to",
@@ -37472,6 +37496,8 @@ function pageFactsTable(checked, origin) {
37472
37496
  "robots",
37473
37497
  ...checked.some((page) => page.error) ? ["error"] : []
37474
37498
  ], origin, 320);
37499
+ const note = temporaryRedirectNote(checked);
37500
+ return note ? `${rows}\n${note}` : rows;
37475
37501
  }
37476
37502
  const PAGE_SIZE = 50;
37477
37503
  async function allSections(input, deps) {
@@ -37788,11 +37814,48 @@ const RESEARCH_ACTIONS = [
37788
37814
  ];
37789
37815
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
37790
37816
  const RESEARCH_HELP = "keyword_metrics (keywords: volume, difficulty, CPC), ranked_keywords (target: keywords a domain or URL ranks for), serp (keywords, up to 5: the ranking pages), competitors (target: domains that share keywords), backlinks (target: referring domains). Rows with a domain, and the target, get a free Ahrefs domain_rating column. Numbers are provider estimates, not Search Console data.";
37817
+ const GENERIC_PLATFORMS = [
37818
+ "youtube.com",
37819
+ "facebook.com",
37820
+ "wikipedia.org",
37821
+ "reddit.com",
37822
+ "quora.com",
37823
+ "instagram.com",
37824
+ "linkedin.com",
37825
+ "x.com",
37826
+ "twitter.com",
37827
+ "pinterest.com",
37828
+ "tiktok.com",
37829
+ "amazon.com"
37830
+ ];
37831
+ const within = (domain, parent) => domain === parent || domain.endsWith(`.${parent}`);
37832
+ function realCompetitors(rows, target) {
37833
+ const self = bareDomain(target);
37834
+ const domainOf = (row) => bareDomain(String(row.domain ?? row.competitor_domain ?? ""));
37835
+ const isSelf = (domain) => self !== "" && within(domain, self);
37836
+ const isPlatform = (domain) => GENERIC_PLATFORMS.some((name) => within(domain, name));
37837
+ const domains = rows.map(domainOf);
37838
+ const own = domains.filter(isSelf);
37839
+ const platforms = domains.filter((d) => !isSelf(d) && isPlatform(d));
37840
+ const gone = own.length + platforms.length;
37841
+ if (!gone) return {
37842
+ rows,
37843
+ note: null
37844
+ };
37845
+ const reasons = [own.length ? `the target itself (${own.join(", ")})` : "", platforms.length ? `generic platforms that share keywords with most sites (${platforms.join(", ")})` : ""].filter(Boolean);
37846
+ return {
37847
+ rows: rows.filter((row) => {
37848
+ const domain = domainOf(row);
37849
+ return !isSelf(domain) && !isPlatform(domain);
37850
+ }),
37851
+ note: `Left out ${gone} ${gone === 1 ? "row" : "rows"}: ${reasons.join(" and ")}. The fixed platform list is ${GENERIC_PLATFORMS.join(", ")}.`
37852
+ };
37853
+ }
37791
37854
  async function runResearch(input, keys, options) {
37792
37855
  const country = input.country ?? "us";
37793
37856
  const target = input.target ?? "";
37794
37857
  const offset = input.offset ?? 0;
37795
- const rows = await research(input.provider, keys, {
37858
+ const found = await research(input.provider, keys, {
37796
37859
  action: input.action,
37797
37860
  keywords: input.keywords ?? [],
37798
37861
  target,
@@ -37800,6 +37863,10 @@ async function runResearch(input, keys, options) {
37800
37863
  language: "en",
37801
37864
  limit: Math.min(1e3, Math.max(100, offset + 50))
37802
37865
  });
37866
+ const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
37867
+ rows: found,
37868
+ note: null
37869
+ };
37803
37870
  const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
37804
37871
  const rated = await withDomainRating(rows, drKey);
37805
37872
  const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
@@ -37813,6 +37880,7 @@ async function runResearch(input, keys, options) {
37813
37880
  detail: input.detail ?? "summary",
37814
37881
  notes: [
37815
37882
  `${input.provider} estimates, in the provider’s own units.`,
37883
+ ...note ? [note] : [],
37816
37884
  ...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
37817
37885
  ...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
37818
37886
  ],
@@ -37825,6 +37893,7 @@ async function runResearch(input, keys, options) {
37825
37893
  country,
37826
37894
  targetDomainRating: hasTargetDr ? targetDr : null,
37827
37895
  attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
37896
+ leftOut: note,
37828
37897
  total: rated.rows.length,
37829
37898
  offset,
37830
37899
  rows: rated.rows.slice(offset, offset + 50)
@@ -37999,12 +38068,13 @@ async function runLostPages(input, deps) {
37999
38068
  `${lost.length} pages kept ${LOST_SHARE * 100}% or less of at least ${MIN_LOST} clicks${lost.length > CHECKED ? `; the top ${CHECKED} by clicks lost are shown` : ""}.`,
38000
38069
  rows.length ? table(rows, Object.keys(rows[0] ?? {}), originOf(input.site)) : "No pages lost their clicks.",
38001
38070
  "Live checks are fetched now with no redirects followed. Index status is from URL Inspection; Google allows about 2,000 inspections a day per property.",
38071
+ temporaryRedirectNote(rows),
38002
38072
  `Next: ${deps.call("page")} for one URL; ${deps.call("report", {
38003
38073
  site: input.site,
38004
38074
  section: "content-decay"
38005
38075
  })} for pages that lost part of their clicks.`,
38006
38076
  FACTS_ONLY
38007
- ].join("\n\n"),
38077
+ ].filter(Boolean).join("\n\n"),
38008
38078
  data: {
38009
38079
  range,
38010
38080
  comparisonRange: before,
@@ -38168,7 +38238,7 @@ const SHARED_TOOLS = [
38168
38238
  ...detail
38169
38239
  }),
38170
38240
  run: async (surface, input) => runReport$1({
38171
- site: await surface.resolveSite(input.site),
38241
+ site: input.section === "bing" ? await surface.resolveSite(input.site).catch(() => input.site) : await surface.resolveSite(input.site),
38172
38242
  section: input.section,
38173
38243
  startDate: input.start_date,
38174
38244
  endDate: input.end_date,