seo 0.5.0 → 0.7.0

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-CsRkLZGn.js";
2
+ import { i as __toESM, n as load, r as __commonJSMin, t as version } from "./package-CmplBMg7.js";
3
3
  import { createHash, randomUUID, sign } from "node:crypto";
4
4
  import { ZodOptional, z } from "zod";
5
5
  import { arch, homedir, platform, totalmem } from "node:os";
@@ -13774,7 +13774,7 @@ async function runAnalyticsReport(gfetch, propertyId, body, options) {
13774
13774
  //#endregion
13775
13775
  //#region packages/core/dist/bing/api.js
13776
13776
  const BASE = "https://ssl.bing.com/webmaster/api.svc/json";
13777
- const MAX_BYTES$1 = 8e6;
13777
+ const MAX_BYTES$2 = 8e6;
13778
13778
  function bingCall(auth, options) {
13779
13779
  return async (method, params = {}) => {
13780
13780
  const url = new URL(`${BASE}/${method}`);
@@ -13799,7 +13799,7 @@ function bingCall(auth, options) {
13799
13799
  if (response.status === 429 || /throttle/i.test(detail)) throw new SeoError("RATE_LIMITED", "Bing Webmaster is limiting requests. Try again in a few minutes.");
13800
13800
  throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster answered ${response.status}. ${detail}`.trim());
13801
13801
  }
13802
- return (await readJson(response, "Bing Webmaster", { maxBytes: MAX_BYTES$1 })).d;
13802
+ return (await readJson(response, "Bing Webmaster", { maxBytes: MAX_BYTES$2 })).d;
13803
13803
  };
13804
13804
  }
13805
13805
  function day(value) {
@@ -13808,7 +13808,7 @@ function day(value) {
13808
13808
  const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
13809
13809
  return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
13810
13810
  }
13811
- const count = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
13811
+ const count$1 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
13812
13812
  function list(data, label) {
13813
13813
  if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
13814
13814
  return data.filter((item) => Boolean(item) && typeof item === "object");
@@ -13824,8 +13824,8 @@ async function bingTraffic(call, site) {
13824
13824
  let invalidRows = 0;
13825
13825
  for (const row of list(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
13826
13826
  const date = day(row.Date);
13827
- const clicks = count(row.Clicks);
13828
- const impressions = count(row.Impressions);
13827
+ const clicks = count$1(row.Clicks);
13828
+ const impressions = count$1(row.Impressions);
13829
13829
  if (!date || clicks === null || impressions === null) {
13830
13830
  invalidRows += 1;
13831
13831
  continue;
@@ -13848,9 +13848,9 @@ async function keyed(call, method, params) {
13848
13848
  for (const row of list(await call(method, params), "row list")) {
13849
13849
  const date = day(row.Date);
13850
13850
  const key = typeof row.Query === "string" ? row.Query.trim() : "";
13851
- const clicks = count(row.Clicks);
13852
- const impressions = count(row.Impressions);
13853
- const position = count(row.AvgImpressionPosition);
13851
+ const clicks = count$1(row.Clicks);
13852
+ const impressions = count$1(row.Impressions);
13853
+ const position = count$1(row.AvgImpressionPosition);
13854
13854
  if (!date || !key || clicks === null || impressions === null) {
13855
13855
  invalidRows += 1;
13856
13856
  continue;
@@ -14430,7 +14430,7 @@ async function readableSites(google, signIn) {
14430
14430
  permissionLevel: entry.permissionLevel ?? ""
14431
14431
  })).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
14432
14432
  }
14433
- const hostOf = (value) => {
14433
+ const hostOf$1 = (value) => {
14434
14434
  try {
14435
14435
  return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
14436
14436
  } catch {
@@ -14448,14 +14448,14 @@ async function runSites(input) {
14448
14448
  };
14449
14449
  })), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
14450
14450
  const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
14451
- const bingByHost = new Map(bingSites.map((site) => [hostOf(site.url), site]));
14451
+ const bingByHost = new Map(bingSites.map((site) => [hostOf$1(site.url), site]));
14452
14452
  const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
14453
14453
  ...property,
14454
14454
  ...named ? { account: account.email ?? "" } : {},
14455
- ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf(property.siteUrl))?.url ?? "" } : {}
14455
+ ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$1(property.siteUrl))?.url ?? "" } : {}
14456
14456
  })));
14457
- const googleHosts = new Set(properties.map((property) => hostOf(property.siteUrl)));
14458
- const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf(site.url)));
14457
+ const googleHosts = new Set(properties.map((property) => hostOf$1(property.siteUrl)));
14458
+ const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$1(site.url)));
14459
14459
  const failed = google.filter((account) => account.properties instanceof Error);
14460
14460
  const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
14461
14461
  property: property.property.replace("properties/", ""),
@@ -14484,6 +14484,7 @@ async function runSites(input) {
14484
14484
  ], null)}` : "Google Analytics: no properties readable with these sign-ins.",
14485
14485
  input.bing === void 0 ? null : bing === null ? "Bing Webmaster: not connected." : bing instanceof Error ? `Bing Webmaster: could not list sites. ${bing.message}` : bingOnly.length ? `Bing Webmaster sites with no Search Console property here (${bingOnly.length}): ${bingOnly.map((site) => site.url).join(", ")}.` : `Bing Webmaster: ${bingSites.length} verified sites; the bing column shows each one.`,
14486
14486
  `Research keys: ${research.length ? research.join(", ") : "none"}.`,
14487
+ input.clicky?.length ? `Clicky sites (seo_data with source "clicky"): ${input.clicky.join(", ")}.` : null,
14487
14488
  `Add a Google account${input.bing === void 0 ? "" : ", Bing"} or a key: ${input.connect}`,
14488
14489
  "Next: seo_report with a site."
14489
14490
  ].filter(Boolean).join("\n\n"),
@@ -14491,7 +14492,8 @@ async function runSites(input) {
14491
14492
  searchConsole: properties,
14492
14493
  googleAnalytics: analytics,
14493
14494
  bing: Array.isArray(bing) ? bingSites : null,
14494
- research
14495
+ research,
14496
+ clicky: input.clicky ?? []
14495
14497
  }
14496
14498
  };
14497
14499
  }
@@ -14751,13 +14753,13 @@ let credentials;
14751
14753
  function expiresSoon(value) {
14752
14754
  return [...value.google, ...value.bing].some((item) => "expiresAt" in item && item.expiresAt - EARLY < Date.now());
14753
14755
  }
14754
- async function cloudCredentials() {
14756
+ async function cloudCredentials(options = {}) {
14755
14757
  const current = credentials ? await credentials.catch(() => null) : null;
14756
- if (current && !expiresSoon(current)) return current;
14758
+ if (current && !expiresSoon(current) && !options.refreshClicky) return current;
14757
14759
  credentials = (async () => {
14758
14760
  const token = cloudToken();
14759
14761
  if (!token) throw new SeoError("AUTH_REQUIRED", notSignedIn());
14760
- const result = await call$1("/account/cli/credentials", { token });
14762
+ const result = await call$1(`/account/cli/credentials${options.refreshClicky ? "?clicky=refresh" : ""}`, { token });
14761
14763
  if (result.status === 401) throw new SeoError("AUTH_EXPIRED", `This machine is not linked to a seoskill.dev account any more. ${CLOUD_SIGN_IN}`);
14762
14764
  if (!result.ok) throw new SeoError("PROVIDER_UNAVAILABLE", `seoskill.dev returned ${result.status}. Try again in a minute.`);
14763
14765
  return result.body;
@@ -16800,7 +16802,7 @@ function extractCanonicalEvidence($, headers, baseUrl) {
16800
16802
  //#region packages/core/dist/extract/content-sketch.js
16801
16803
  const SHINGLE_SIZE = 5;
16802
16804
  const MAX_HASHES = 32;
16803
- function words(value) {
16805
+ function words$1(value) {
16804
16806
  return value.normalize("NFKC").toLocaleLowerCase("en").match(/[\p{L}\p{N}]+/gu) ?? [];
16805
16807
  }
16806
16808
  function sampleIndexes(total, limit) {
@@ -16811,7 +16813,7 @@ function digest(value) {
16811
16813
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
16812
16814
  }
16813
16815
  function contentSketch(value) {
16814
- const tokens = words(value);
16816
+ const tokens = words$1(value);
16815
16817
  const hashes = sampleIndexes(Math.max(0, tokens.length - SHINGLE_SIZE + 1), MAX_HASHES).map((index) => digest(tokens.slice(index, index + SHINGLE_SIZE).join(" ")));
16816
16818
  return {
16817
16819
  version: 1,
@@ -16823,6 +16825,11 @@ function contentSketch(value) {
16823
16825
  }
16824
16826
  //#endregion
16825
16827
  //#region packages/core/dist/extract/main-content.js
16828
+ function parseDocument$1(html) {
16829
+ const { document } = parseHTML(html);
16830
+ if (document.documentElement?.localName === "html") return document;
16831
+ return parseHTML(`<!doctype html><html><head></head><body>${html}</body></html>`).document;
16832
+ }
16826
16833
  function readableError(error) {
16827
16834
  return error instanceof Error ? error.message : String(error);
16828
16835
  }
@@ -16852,10 +16859,6 @@ function resolveDocumentUrls(document, baseUrl) {
16852
16859
  }
16853
16860
  }
16854
16861
  function readabilityResult(fetchResult, requested, fallbackReason, fallbackDetail) {
16855
- const { document } = parseHTML(fetchResult.html);
16856
- const bodyText = document.body.textContent ?? "";
16857
- const article = new Readability(document).parse();
16858
- const text = article?.textContent ?? bodyText;
16859
16862
  const fallback = requested !== "readability";
16860
16863
  const diagnostics = {
16861
16864
  requested,
@@ -16868,13 +16871,27 @@ function readabilityResult(fetchResult, requested, fallbackReason, fallbackDetai
16868
16871
  fallbackDetail
16869
16872
  } : {}
16870
16873
  };
16871
- return {
16872
- text,
16873
- excerpt: normalizedText(article?.excerpt),
16874
- wordCount: countCjkAwareWords(text),
16875
- diagnostics,
16876
- warnings: fallback && fallbackDetail ? [`Defuddle extraction fell back to Readability: ${fallbackDetail}`] : []
16877
- };
16874
+ const warnings = fallback && fallbackDetail ? [`Defuddle extraction fell back to Readability: ${fallbackDetail}`] : [];
16875
+ try {
16876
+ const document = parseDocument$1(fetchResult.html);
16877
+ const bodyText = document.body?.textContent ?? "";
16878
+ const article = new Readability(document).parse();
16879
+ const text = article?.textContent ?? bodyText;
16880
+ return {
16881
+ text,
16882
+ excerpt: normalizedText(article?.excerpt),
16883
+ wordCount: countCjkAwareWords(text),
16884
+ diagnostics,
16885
+ warnings
16886
+ };
16887
+ } catch (error) {
16888
+ return {
16889
+ text: "",
16890
+ wordCount: 0,
16891
+ diagnostics,
16892
+ warnings: [...warnings, `The page's main content could not be read: ${readableError(error)}`]
16893
+ };
16894
+ }
16878
16895
  }
16879
16896
  function defaultParseDefuddle(document, options) {
16880
16897
  return new Defuddle(document, options).parse();
@@ -16882,13 +16899,13 @@ function defaultParseDefuddle(document, options) {
16882
16899
  function extractMainContent(fetchResult, extractor = "defuddle", dependencies = {}) {
16883
16900
  if (extractor === "readability") return readabilityResult(fetchResult, extractor);
16884
16901
  try {
16885
- const { document } = parseHTML(fetchResult.html);
16902
+ const document = parseDocument$1(fetchResult.html);
16886
16903
  resolveDocumentUrls(document, fetchResult.finalUrl);
16887
16904
  const article = (dependencies.parseDefuddle ?? defaultParseDefuddle)(document, {
16888
16905
  url: fetchResult.finalUrl,
16889
16906
  useAsync: false
16890
16907
  });
16891
- const text = typeof article.content === "string" ? load(article.content).text() : document.body.textContent ?? "";
16908
+ const text = typeof article.content === "string" ? load(article.content).text() : document.body?.textContent ?? "";
16892
16909
  if (!text.trim()) return readabilityResult(fetchResult, extractor, "defuddle_empty", "Defuddle returned no main content");
16893
16910
  const reportedWordCount = article.wordCount;
16894
16911
  const hasValidWordCount = typeof reportedWordCount === "number" && Number.isFinite(reportedWordCount) && reportedWordCount >= 0;
@@ -16911,6 +16928,28 @@ function extractMainContent(fetchResult, extractor = "defuddle", dependencies =
16911
16928
  return readabilityResult(fetchResult, extractor, "defuddle_error", readableError(error));
16912
16929
  }
16913
16930
  }
16931
+ const MARKDOWN_LIMIT = 2e4;
16932
+ async function extractMainMarkdown(fetchResult) {
16933
+ const document = parseDocument$1(fetchResult.html);
16934
+ resolveDocumentUrls(document, fetchResult.finalUrl);
16935
+ const { Defuddle: toMarkdown } = await import("defuddle/node");
16936
+ const markdown = ((await toMarkdown(document, fetchResult.finalUrl, {
16937
+ markdown: true,
16938
+ useAsync: false
16939
+ })).content ?? "").trim();
16940
+ if (markdown.length <= 2e4) return {
16941
+ markdown,
16942
+ characters: markdown.length,
16943
+ truncated: false
16944
+ };
16945
+ const cut = markdown.slice(0, MARKDOWN_LIMIT);
16946
+ const end = cut.lastIndexOf("\n");
16947
+ return {
16948
+ markdown: (end > 2e4 / 2 ? cut.slice(0, end) : cut).trimEnd(),
16949
+ characters: markdown.length,
16950
+ truncated: true
16951
+ };
16952
+ }
16914
16953
  //#endregion
16915
16954
  //#region packages/core/dist/extract/google-rich-results.js
16916
16955
  const SUPPORTED_TYPES = /* @__PURE__ */ new Set([
@@ -20686,7 +20725,7 @@ const QUERY_FLAG_METHOD = {
20686
20725
  "navigational-other": "Names a domain that is not the property's host, ends with a spoken domain suffix or a login/site/app word, starts with a large platform name and adds no topic word, or is one short ASCII word that is not a brand term. Such queries usually look for another site."
20687
20726
  }
20688
20727
  };
20689
- const FUNCTION_WORDS = /* @__PURE__ */ new Set([
20728
+ const FUNCTION_WORDS$1 = /* @__PURE__ */ new Set([
20690
20729
  "a",
20691
20730
  "about",
20692
20731
  "an",
@@ -20751,7 +20790,7 @@ function isLikelyAutomatedQuery(query) {
20751
20790
  if (query.trim().split(/\s+/).filter(Boolean).some((token) => IDENTIFIER_TOKEN.test(token))) return true;
20752
20791
  const tokens = tokenize(query);
20753
20792
  if (tokens.length >= 20) return true;
20754
- if (tokens.length >= 8 && !/[^\p{ASCII}]/u.test(query) && !tokens.some((token) => FUNCTION_WORDS.has(token))) return true;
20793
+ if (tokens.length >= 8 && !/[^\p{ASCII}]/u.test(query) && !tokens.some((token) => FUNCTION_WORDS$1.has(token))) return true;
20755
20794
  return false;
20756
20795
  }
20757
20796
  const FILE_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -25631,6 +25670,9 @@ async function storedSiteKeys() {
25631
25670
  source: credential.source
25632
25671
  };
25633
25672
  }
25673
+ async function readClickySiteKeys() {
25674
+ return (await storedSiteKeys()).siteKeys;
25675
+ }
25634
25676
  async function readClickySiteKey(siteIdValue, input = {}) {
25635
25677
  const siteId = normalizeClickySiteId(siteIdValue);
25636
25678
  const environmentKey = (input.env ?? process.env)[CLICKY_SITEKEY_ENV]?.trim();
@@ -27687,7 +27729,7 @@ function analyzeCtrUnderperformersFromRows(input) {
27687
27729
  //#endregion
27688
27730
  //#region packages/core/dist/analyze/ctr-underperformers-report.js
27689
27731
  const MAX_GSC_ROWS$3 = 1e5;
27690
- const DEFAULT_DAYS$3 = 28;
27732
+ const DEFAULT_DAYS$4 = 28;
27691
27733
  const MAX_DAYS$2 = 548;
27692
27734
  function plural$4(count, singular, pluralLabel = `${singular}s`) {
27693
27735
  return count === 1 ? singular : pluralLabel;
@@ -27710,7 +27752,7 @@ async function ctrUnderperformersReportWith(input, dependencies) {
27710
27752
  const explicitRange = explicitDateRange(input, MAX_DAYS$2);
27711
27753
  const days = explicitRange?.days ?? integerOption$1({
27712
27754
  value: input.days,
27713
- fallback: DEFAULT_DAYS$3,
27755
+ fallback: DEFAULT_DAYS$4,
27714
27756
  minimum: 1,
27715
27757
  maximum: MAX_DAYS$2,
27716
27758
  label: "Days"
@@ -30915,7 +30957,7 @@ function quickWinTemplateSummaries(items) {
30915
30957
  }
30916
30958
  //#endregion
30917
30959
  //#region packages/core/dist/analyze/site-diagnostics/quick-wins-report.js
30918
- const DEFAULT_DAYS$2 = 28;
30960
+ const DEFAULT_DAYS$3 = 28;
30919
30961
  const MAX_DAYS$1 = 548;
30920
30962
  const DEFAULT_VERIFY_LIMIT$1 = 5;
30921
30963
  const MAX_VERIFY_LIMIT = 100;
@@ -30937,7 +30979,7 @@ async function quickWinsReportWith(input, dependencies) {
30937
30979
  const explicitRange = explicitDateRange(input, MAX_DAYS$1);
30938
30980
  const days = explicitRange?.days ?? integerOption$1({
30939
30981
  value: input.days,
30940
- fallback: DEFAULT_DAYS$2,
30982
+ fallback: DEFAULT_DAYS$3,
30941
30983
  minimum: 1,
30942
30984
  maximum: MAX_DAYS$1,
30943
30985
  label: "Days"
@@ -31361,7 +31403,7 @@ function analyzeStrikingDistanceRows(input) {
31361
31403
  }
31362
31404
  //#endregion
31363
31405
  //#region packages/core/dist/analyze/striking-distance-report.js
31364
- const DEFAULT_DAYS$1 = 28;
31406
+ const DEFAULT_DAYS$2 = 28;
31365
31407
  const MAX_DAYS = 548;
31366
31408
  const DEFAULT_VERIFY_LIMIT = 5;
31367
31409
  const MAX_SOURCE_ROWS$1 = 1e5;
@@ -31370,7 +31412,7 @@ function boundedInteger$2(value, fallback, minimum, maximum) {
31370
31412
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
31371
31413
  }
31372
31414
  function reportDays(value) {
31373
- if (value === void 0) return DEFAULT_DAYS$1;
31415
+ if (value === void 0) return DEFAULT_DAYS$2;
31374
31416
  if (!Number.isInteger(value) || value < 1 || value > MAX_DAYS) throw new SeoError("INVALID_INPUT", `Days must be a whole number between 1 and ${MAX_DAYS}.`);
31375
31417
  return value;
31376
31418
  }
@@ -32810,20 +32852,394 @@ function analyzeInternalLinksFromRows(input) {
32810
32852
  }
32811
32853
  };
32812
32854
  }
32813
- //#endregion
32814
- //#region packages/core/dist/analyze/internal-links-report.js
32815
- function internalLinksIntegerOption(input) {
32816
- const value = input.value ?? input.fallback;
32817
- if (!Number.isInteger(value) || value < input.minimum || value > input.maximum) throw new SeoError("INVALID_INPUT", `${input.label} must be a whole number between ${input.minimum} and ${input.maximum}.`);
32818
- return value;
32855
+ const MAX_TERMS = 100;
32856
+ const PLACEMENT_SENTENCES = 2;
32857
+ const ANCHORS_PER_SENTENCE = 3;
32858
+ const MIN_SENTENCE_WORDS = 6;
32859
+ const NAME_WEIGHT = 2;
32860
+ const GENERIC_ANCHORS = /^(click here|here|read more|learn more|more|this|this post|this article|this page|link|website|check it out|see more|view|continue reading)$/i;
32861
+ function normalise(value) {
32862
+ return value.normalize("NFKD").replace(/\p{Diacritic}/gu, "").normalize("NFC").replace(/([\p{L}\p{N}])[’'`]([\p{L}\p{N}])/gu, "$1$2").toLowerCase().replace(/&/g, " and ").replace(/[^\p{L}\p{N}]+/gu, " ").replace(/\s+/g, " ").trim();
32863
+ }
32864
+ const words = (value) => normalise(value).split(" ").filter(Boolean);
32865
+ const FUNCTION_WORDS = new Set("a an and are as at be but by for from has have how if in into is it its of on or that the their then there these they this to was we were what when where which who why will with you your our can do does not just more most also than too very about after before over under again all any each other some such no nor only own same so up out off one two both every few several many much should would could because while since although though until unless whether however".split(" "));
32866
+ const NOT_PLURAL = /* @__PURE__ */ new Set([
32867
+ "always",
32868
+ "atlas",
32869
+ "canvas",
32870
+ "gas",
32871
+ "lens",
32872
+ "means",
32873
+ "news",
32874
+ "perhaps",
32875
+ "series",
32876
+ "species",
32877
+ "sometimes",
32878
+ "whereas",
32879
+ "yes",
32880
+ "this",
32881
+ "has",
32882
+ "was",
32883
+ "does",
32884
+ "its"
32885
+ ]);
32886
+ const CHE = /* @__PURE__ */ new Set([
32887
+ "caches",
32888
+ "niches",
32889
+ "headaches",
32890
+ "cliches",
32891
+ "avalanches",
32892
+ "moustaches"
32893
+ ]);
32894
+ const NOT_AGENT = /* @__PURE__ */ new Set([
32895
+ "customer",
32896
+ "together",
32897
+ "however",
32898
+ "whatever",
32899
+ "whenever",
32900
+ "wherever",
32901
+ "remember",
32902
+ "consider",
32903
+ "character",
32904
+ "register",
32905
+ "november",
32906
+ "december",
32907
+ "september",
32908
+ "october"
32909
+ ]);
32910
+ function singular(word) {
32911
+ if (word.length <= 3 || !/^[a-z]+$/.test(word) || NOT_PLURAL.has(word) || /(ss|us|is)$/.test(word)) return word;
32912
+ if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
32913
+ if (/(xes|ches|shes|sses)$/.test(word) && !CHE.has(word)) return word.slice(0, -2);
32914
+ return word.endsWith("s") ? word.slice(0, -1) : word;
32915
+ }
32916
+ function stem(word) {
32917
+ const one = singular(word);
32918
+ if (!/^[a-z]+$/.test(one)) return one;
32919
+ if (one.length >= 8 && one.endsWith("er") && !one.endsWith("eer") && one.at(-3) !== one.at(-4) && !NOT_AGENT.has(one)) return one.slice(0, -2);
32920
+ if (one.length > 5 && one.endsWith("ly")) return one.slice(0, -2);
32921
+ if (one.length > 5 && one.endsWith("ed") && !one.endsWith("eed")) return one.slice(0, -2);
32922
+ if (one.length > 6 && one.endsWith("ing")) return one.slice(0, -3);
32923
+ return one;
32924
+ }
32925
+ const terms = (text) => words(text).filter((word) => word.length > 1 && !FUNCTION_WORDS.has(word) && !/^\d+$/.test(word)).map(stem);
32926
+ const slugWords = (url) => {
32927
+ try {
32928
+ return new URL(url).pathname.split(/[/._-]+/).filter(Boolean).join(" ");
32929
+ } catch {
32930
+ return "";
32931
+ }
32932
+ };
32933
+ function keyTerms(page) {
32934
+ const key = /* @__PURE__ */ new Map();
32935
+ const title = page.title.replace(/\s+[|\u2013\u2014-]\s+[^|\u2013\u2014]*$/u, "");
32936
+ for (const term of terms(page.description)) key.set(term, 1);
32937
+ for (const term of terms(`${title} ${page.h1} ${lastSegmentWords(page.url)}`)) key.set(term, NAME_WEIGHT);
32938
+ for (const term of terms(siteName(page.url))) key.delete(term);
32939
+ return key;
32940
+ }
32941
+ function lastSegmentWords(url) {
32942
+ try {
32943
+ return (new URL(url).pathname.split("/").filter(Boolean).at(-1) ?? "").split(/[._-]+/).join(" ");
32944
+ } catch {
32945
+ return "";
32946
+ }
32819
32947
  }
32820
- function internalLinksNumberOption(input) {
32821
- const value = input.value ?? input.fallback;
32822
- if (!Number.isFinite(value) || value < input.minimum || value > input.maximum) throw new SeoError("INVALID_INPUT", `${input.label} must be between ${input.minimum} and ${input.maximum}.`);
32823
- return value;
32948
+ function siteName(url) {
32949
+ try {
32950
+ return new URL(url).hostname.replace(/^www\./u, "").split(".")[0] ?? "";
32951
+ } catch {
32952
+ return "";
32953
+ }
32824
32954
  }
32825
- function internalLinksReportRange(days, now) {
32826
- return finalGscDateRange(days, now);
32955
+ function termCounts(page) {
32956
+ const counts = {};
32957
+ const add = (text, weight) => {
32958
+ for (const term of terms(text)) counts[term] = (counts[term] ?? 0) + weight;
32959
+ };
32960
+ add(page.title, 3);
32961
+ add(page.h1, 3);
32962
+ add(slugWords(page.url), 3);
32963
+ add(page.description, 2);
32964
+ for (const heading of page.headings) add(heading, 2);
32965
+ for (const sentence of page.sentences) add(sentence.text, 1);
32966
+ return counts;
32967
+ }
32968
+ const SKIP = /* @__PURE__ */ new Set([
32969
+ "script",
32970
+ "style",
32971
+ "noscript",
32972
+ "svg",
32973
+ "template",
32974
+ "iframe",
32975
+ "select"
32976
+ ]);
32977
+ const CHROME = /* @__PURE__ */ new Set([
32978
+ "nav",
32979
+ "header",
32980
+ "footer",
32981
+ "aside"
32982
+ ]);
32983
+ const MAIN = /* @__PURE__ */ new Set(["main", "article"]);
32984
+ const BLOCK = new Set("p div li h1 h2 h3 h4 h5 h6 blockquote td th dd dt pre section article main aside header footer nav figcaption summary details tr ul ol table body form".split(" "));
32985
+ const VOID = /* @__PURE__ */ new Set([
32986
+ "br",
32987
+ "hr",
32988
+ "img",
32989
+ "input",
32990
+ "meta",
32991
+ "link",
32992
+ "wbr",
32993
+ "source",
32994
+ "col",
32995
+ "embed"
32996
+ ]);
32997
+ const ENTITIES = {
32998
+ amp: "&",
32999
+ lt: "<",
33000
+ gt: ">",
33001
+ quot: "\"",
33002
+ apos: "'",
33003
+ nbsp: " ",
33004
+ hellip: "…",
33005
+ mdash: "—",
33006
+ ndash: "–",
33007
+ rsquo: "’",
33008
+ lsquo: "‘",
33009
+ rdquo: "”",
33010
+ ldquo: "“"
33011
+ };
33012
+ const TAG_ATTRS = String.raw`(?:[^>"'=]|=\s*"[^"]*"|=\s*'[^']*'|=(?!\s*["'])|["'])*`;
33013
+ const A_OPEN = "";
33014
+ const UNSPACED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
33015
+ function decode(text) {
33016
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, code) => {
33017
+ if (code[0] === "#") {
33018
+ const hex = code[1] === "x" || code[1] === "X";
33019
+ const point = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
33020
+ return point > 0 && point <= 1114111 ? String.fromCodePoint(point) : "";
33021
+ }
33022
+ return ENTITIES[code.toLowerCase()] ?? match;
33023
+ });
33024
+ }
33025
+ const clean$1 = (value) => value.split(A_OPEN).join("").replace(/\s+/g, " ").trim();
33026
+ function splitSentences(block) {
33027
+ return block.replace(/\s+/g, " ").split(/(?<=[.!?…]["”’)]?)\s+(?=["“‘(]?[A-Z0-9])|(?<=[。!?])/).map((sentence) => sentence.trim()).filter((sentence) => sentence.length <= 500);
33028
+ }
33029
+ function pageSentences(html) {
33030
+ const sentences = [];
33031
+ const hasMain = /<(main|article)[\s>]|role=["']main["']/i.test(html);
33032
+ const stack = [];
33033
+ let skip = 0;
33034
+ let chrome = 0;
33035
+ let mainDepth = 0;
33036
+ let main = hasMain ? 0 : 1;
33037
+ let section = "";
33038
+ let buffer = "";
33039
+ let inAnchor = false;
33040
+ const flushRaw = (tag) => {
33041
+ const raw = buffer;
33042
+ const text = clean$1(raw);
33043
+ buffer = "";
33044
+ if (!text || sentences.length >= 250) return;
33045
+ if (/^h[1-6]$/.test(tag)) {
33046
+ if (tag !== "h1") section = text;
33047
+ return;
33048
+ }
33049
+ for (const part of tag === "li" ? [raw] : splitSentences(raw)) {
33050
+ const sentence = clean$1(part);
33051
+ if (sentence.length >= 25 && sentence.split(" ").length >= 4 || UNSPACED.test(sentence) && sentence.length >= 10) {
33052
+ if (sentences.length >= 250) return;
33053
+ sentences.push({
33054
+ text: sentence,
33055
+ section,
33056
+ hasLink: part.includes(A_OPEN)
33057
+ });
33058
+ }
33059
+ }
33060
+ };
33061
+ const closeTo = (tag) => {
33062
+ if (!stack.some((entry) => entry.tag === tag)) return;
33063
+ while (stack.length) {
33064
+ const entry = stack.pop();
33065
+ if (!entry) break;
33066
+ flushRaw(entry.tag);
33067
+ if (entry.main) {
33068
+ main -= 1;
33069
+ mainDepth -= 1;
33070
+ }
33071
+ if (entry.chrome) chrome -= 1;
33072
+ if (entry.tag === tag) break;
33073
+ }
33074
+ };
33075
+ const tokens = html.matchAll(new RegExp(String.raw`<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<(\/?)([a-zA-Z][\w:-]*)(${TAG_ATTRS})>|([^<]+)`, "g"));
33076
+ for (const match of tokens) {
33077
+ const [whole, close, rawTag, rawAttrs = "", text] = match;
33078
+ if (text !== void 0) {
33079
+ if (!skip && main > 0 && chrome === 0) buffer += decode(text);
33080
+ continue;
33081
+ }
33082
+ if (!rawTag) continue;
33083
+ const tag = rawTag.toLowerCase();
33084
+ if (skip) {
33085
+ if (close && SKIP.has(tag)) skip -= 1;
33086
+ continue;
33087
+ }
33088
+ if (close) {
33089
+ if (tag === "a") inAnchor = false;
33090
+ else if (BLOCK.has(tag)) closeTo(tag);
33091
+ continue;
33092
+ }
33093
+ if (SKIP.has(tag)) {
33094
+ if (!whole.endsWith("/>")) skip += 1;
33095
+ continue;
33096
+ }
33097
+ if (tag === "br") buffer += " ";
33098
+ if (tag === "a" && !inAnchor && /\bhref\s*=/i.test(rawAttrs)) {
33099
+ inAnchor = true;
33100
+ if (main > 0 && chrome === 0) buffer += A_OPEN;
33101
+ }
33102
+ if (VOID.has(tag) || !BLOCK.has(tag)) continue;
33103
+ const parent = stack[stack.length - 1];
33104
+ if (parent) flushRaw(parent.tag);
33105
+ const role = /\brole\s*=\s*["']?main/i.test(rawAttrs);
33106
+ const isMain = MAIN.has(tag) || role;
33107
+ const isChrome = tag === "nav" || tag === "aside" || CHROME.has(tag) && mainDepth === 0;
33108
+ if (isMain) {
33109
+ main += 1;
33110
+ mainDepth += 1;
33111
+ }
33112
+ if (isChrome) chrome += 1;
33113
+ stack.push({
33114
+ tag,
33115
+ main: isMain,
33116
+ chrome: isChrome
33117
+ });
33118
+ }
33119
+ while (stack.length) {
33120
+ const last = stack[stack.length - 1];
33121
+ if (!last) break;
33122
+ closeTo(last.tag);
33123
+ }
33124
+ flushRaw("");
33125
+ return sentences;
33126
+ }
33127
+ var LinkCorpus = class {
33128
+ df = /* @__PURE__ */ new Map();
33129
+ size;
33130
+ constructor(pages) {
33131
+ this.size = pages.length;
33132
+ for (const counts of pages) for (const term in counts) this.df.set(term, (this.df.get(term) ?? 0) + 1);
33133
+ }
33134
+ idf(term) {
33135
+ return Math.log((this.size + 1) / ((this.df.get(term) ?? 1) + 1)) + 1;
33136
+ }
33137
+ vector(counts) {
33138
+ let norm = 0;
33139
+ const shared = [];
33140
+ for (const term in counts) {
33141
+ const weight = (1 + Math.log(counts[term] ?? 1)) * this.idf(term);
33142
+ norm += weight * weight;
33143
+ if ((this.df.get(term) ?? 0) >= 2) shared.push([term, weight]);
33144
+ }
33145
+ norm = Math.sqrt(norm) || 1;
33146
+ shared.sort((left, right) => right[1] - left[1] || (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0));
33147
+ return new Map(shared.slice(0, MAX_TERMS).map(([term, weight]) => [term, weight / norm]));
33148
+ }
33149
+ similarity(left, right) {
33150
+ let sum = 0;
33151
+ for (const [term, weight] of left) sum += weight * (right.get(term) ?? 0);
33152
+ return sum;
33153
+ }
33154
+ sentenceScore(sentence, key) {
33155
+ if (!key.size) return 0;
33156
+ const seen = new Set(terms(sentence));
33157
+ let hit = 0;
33158
+ let total = 0;
33159
+ for (const [term, place] of key) {
33160
+ const weight = this.idf(term) * place;
33161
+ total += weight;
33162
+ if (seen.has(term)) hit += weight;
33163
+ }
33164
+ return total ? hit / total : 0;
33165
+ }
33166
+ };
33167
+ function anchorCandidates(sentence, key, corpus, limit) {
33168
+ const scored = /* @__PURE__ */ new Map();
33169
+ const parts = sentence.split(/\s+/).map((word) => word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "")).filter(Boolean);
33170
+ for (let size = 2; size <= 4; size += 1) for (let start = 0; start + size <= parts.length; start += 1) {
33171
+ const gram = parts.slice(start, start + size);
33172
+ const first = (gram[0] ?? "").toLowerCase();
33173
+ const last = (gram[size - 1] ?? "").toLowerCase();
33174
+ if (FUNCTION_WORDS.has(first) || FUNCTION_WORDS.has(last) || /^\d+$/.test(first) || /^\d+$/.test(last)) continue;
33175
+ const stems = terms(gram.join(" "));
33176
+ if (stems.length < 2) continue;
33177
+ const hits = stems.filter((term) => key.has(term)).length;
33178
+ const named = stems.reduce((sum, term) => sum + (key.get(term) ?? 0) * corpus.idf(term), 0);
33179
+ if (!stems.some((term) => key.get(term) === NAME_WEIGHT)) continue;
33180
+ if (Number(key.has(terms(first)[0] ?? "")) + Number(key.has(terms(last)[0] ?? "")) < 2) continue;
33181
+ const text = gram.join(" ");
33182
+ const lower = text.toLowerCase();
33183
+ if (GENERIC_ANCHORS.test(lower)) continue;
33184
+ const rarity = stems.reduce((sum, term) => sum + corpus.idf(term), 0);
33185
+ const score = named * (1 + hits / key.size) + rarity * .05 - (size === 4 ? .2 : 0);
33186
+ const existing = scored.get(lower);
33187
+ if (!existing || existing.score < score) scored.set(lower, {
33188
+ text,
33189
+ score
33190
+ });
33191
+ }
33192
+ return [...scored.values()].sort((left, right) => right.score - left.score || (left.text < right.text ? -1 : left.text > right.text ? 1 : 0)).slice(0, limit).map((item) => item.text);
33193
+ }
33194
+ function linkPlacement(source, target, corpus) {
33195
+ const key = keyTerms(target);
33196
+ const contentSimilarity = corpus.similarity(corpus.vector(termCounts(source)), corpus.vector(termCounts(target)));
33197
+ const seen = /* @__PURE__ */ new Set();
33198
+ const sentences = source.sentences.filter((sentence) => {
33199
+ if (seen.has(sentence.text)) return false;
33200
+ seen.add(sentence.text);
33201
+ return !sentence.hasLink && sentence.text.split(/\s+/).length >= MIN_SENTENCE_WORDS;
33202
+ }).map((sentence, index) => ({
33203
+ sentence,
33204
+ index,
33205
+ match: corpus.sentenceScore(sentence.text, key)
33206
+ })).filter((item) => item.match > 0).sort((left, right) => right.match - left.match || left.index - right.index).slice(0, PLACEMENT_SENTENCES).map(({ sentence, match }) => ({
33207
+ text: sentence.text,
33208
+ section: sentence.section,
33209
+ match: Math.round(match * 100) / 100,
33210
+ anchors: anchorCandidates(sentence.text, key, corpus, ANCHORS_PER_SENTENCE)
33211
+ })).filter((item) => item.anchors.length > 0);
33212
+ return {
33213
+ contentSimilarity: Math.round(contentSimilarity * 100) / 100,
33214
+ sentences
33215
+ };
33216
+ }
33217
+ function placementPage(input) {
33218
+ return {
33219
+ url: input.url,
33220
+ title: input.title ?? "",
33221
+ h1: input.headings.find((heading) => heading.level === 1)?.text ?? "",
33222
+ description: input.metaDescription ?? "",
33223
+ headings: input.headings.filter((heading) => heading.level === 2 || heading.level === 3).slice(0, 8).map((heading) => heading.text),
33224
+ sentences: pageSentences(input.html)
33225
+ };
33226
+ }
33227
+ //#endregion
33228
+ //#region packages/core/dist/analyze/internal-links-report.js
33229
+ const DAY$1 = 864e5;
33230
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
33231
+ function internalLinksReportRange(input, fallbackDays, now) {
33232
+ if (!input.startDate && !input.endDate) return {
33233
+ ...finalGscDateRange(fallbackDays, now),
33234
+ days: fallbackDays
33235
+ };
33236
+ const { startDate, endDate } = input;
33237
+ if (!startDate || !endDate || !ISO_DAY.test(startDate) || !ISO_DAY.test(endDate) || startDate > endDate) throw new SeoError("INVALID_INPUT", "Send a start date and an end date together, in order, as YYYY-MM-DD.");
33238
+ return {
33239
+ startDate,
33240
+ endDate,
33241
+ days: Math.round((Date.parse(`${endDate}T00:00:00Z`) - Date.parse(`${startDate}T00:00:00Z`)) / DAY$1) + 1
33242
+ };
32827
33243
  }
32828
33244
  function completeInternalLinksSelection(analysis, runtime) {
32829
33245
  return {
@@ -32881,9 +33297,8 @@ async function verifyInternalLinkTarget(input) {
32881
33297
  let fetched;
32882
33298
  try {
32883
33299
  fetched = await input.dependencies.fetch(input.targetUrl, {
32884
- js: input.js ?? "auto",
32885
- refresh: input.refresh,
32886
- rate: input.rate
33300
+ js: "auto",
33301
+ refresh: input.refresh
32887
33302
  });
32888
33303
  } catch (error) {
32889
33304
  return {
@@ -32953,7 +33368,12 @@ async function verifyInternalLinkTarget(input) {
32953
33368
  verification: fatalSignals.length ? "technical-issue" : "verified",
32954
33369
  technicalSignals: signals,
32955
33370
  fetchDiagnostics: fetched.diagnostics,
32956
- warnings
33371
+ warnings,
33372
+ text: placementPage({
33373
+ ...page,
33374
+ url: page.finalUrl,
33375
+ html: fetched.html
33376
+ })
32957
33377
  };
32958
33378
  }
32959
33379
  function linksToAliases(page, aliases) {
@@ -32987,9 +33407,8 @@ async function verifyInternalLinkCandidate(input) {
32987
33407
  let fetched;
32988
33408
  try {
32989
33409
  fetched = await input.dependencies.fetch(input.candidate.sourceUrl, {
32990
- js: input.js ?? "auto",
32991
- refresh: input.refresh,
32992
- rate: input.rate
33410
+ js: "auto",
33411
+ refresh: input.refresh
32993
33412
  });
32994
33413
  } catch (error) {
32995
33414
  return {
@@ -33079,12 +33498,21 @@ async function verifyInternalLinkCandidate(input) {
33079
33498
  confidence
33080
33499
  }
33081
33500
  },
33501
+ text: placementPage({
33502
+ ...page,
33503
+ url: page.finalUrl,
33504
+ html: fetched.html
33505
+ }),
33082
33506
  warnings
33083
33507
  };
33084
33508
  }
33085
33509
  //#endregion
33086
33510
  //#region packages/core/dist/analyze/internal-links.js
33087
33511
  const MAX_GSC_ROWS = 1e5;
33512
+ const DEFAULT_DAYS$1 = 90;
33513
+ const RESULT_LIMIT = 20;
33514
+ const CHECK_LIMIT = 40;
33515
+ const MIN_IMPRESSIONS = 1;
33088
33516
  const defaultDependencies$2 = {
33089
33517
  searchAnalytics: querySearchAnalytics,
33090
33518
  fetch: fetchPage,
@@ -33095,43 +33523,17 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33095
33523
  const targetPage = new URL(assertUrlMatchesGscProperty(input.site, input.targetUrl));
33096
33524
  targetPage.hash = "";
33097
33525
  const targetUrl = targetPage.toString();
33098
- const days = internalLinksIntegerOption({
33099
- value: input.days,
33100
- fallback: 28,
33101
- minimum: 1,
33102
- maximum: 548,
33103
- label: "days"
33104
- });
33105
- const limit = internalLinksIntegerOption({
33106
- value: input.limit,
33107
- fallback: 20,
33108
- minimum: 1,
33109
- maximum: 100,
33110
- label: "limit"
33111
- });
33112
- const checkLimit = internalLinksIntegerOption({
33113
- value: input.checkLimit,
33114
- fallback: Math.max(limit, 40),
33115
- minimum: 1,
33116
- maximum: 200,
33117
- label: "checkLimit"
33118
- });
33119
- const minImpressions = internalLinksNumberOption({
33120
- value: input.minImpressions,
33121
- fallback: 1,
33122
- minimum: 0,
33123
- maximum: 1e9,
33124
- label: "minImpressions"
33125
- });
33526
+ const limit = RESULT_LIMIT;
33527
+ const checkLimit = CHECK_LIMIT;
33528
+ const minImpressions = MIN_IMPRESSIONS;
33126
33529
  const now = dependencies.now();
33127
- const range = internalLinksReportRange(days, now);
33530
+ const { days, ...range } = internalLinksReportRange(input, DEFAULT_DAYS$1, now);
33531
+ const brandTerms = deriveBrandTerms({ siteUrl: input.site });
33128
33532
  const ledger = new SessionLedger();
33129
33533
  const target = await verifyInternalLinkTarget({
33130
33534
  site: input.site,
33131
33535
  targetUrl,
33132
- js: input.js,
33133
33536
  refresh: input.refresh,
33134
- rate: input.rate,
33135
33537
  dependencies
33136
33538
  });
33137
33539
  const targetRequests = await Promise.all(target.aliases.map(async (alias) => {
@@ -33163,8 +33565,7 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33163
33565
  site: input.site,
33164
33566
  targetAliases: target.aliases,
33165
33567
  minImpressions,
33166
- brandTerms: input.brandTerms,
33167
- includeBrand: input.includeBrand
33568
+ brandTerms
33168
33569
  });
33169
33570
  const queryCandidates = target.verification === "verified" && preliminary.selection.targetEligibleQueries > 0;
33170
33571
  const sourceResult = queryCandidates ? await dependencies.searchAnalytics(input.site, {
@@ -33185,10 +33586,9 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33185
33586
  site: input.site,
33186
33587
  targetAliases: target.aliases,
33187
33588
  minImpressions,
33188
- brandTerms: input.brandTerms,
33189
- includeBrand: input.includeBrand
33589
+ brandTerms
33190
33590
  });
33191
- const items = [];
33591
+ const found = [];
33192
33592
  const warnings = [...target.warnings];
33193
33593
  let attemptedSources = 0;
33194
33594
  let checkedSources = 0;
@@ -33197,25 +33597,27 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33197
33597
  let selfAliasExclusions = 0;
33198
33598
  let failedChecks = 0;
33199
33599
  for (const candidate of analysis.candidates) {
33200
- if (attemptedSources >= checkLimit || items.length >= limit) break;
33600
+ if (attemptedSources >= checkLimit || found.length >= limit) break;
33201
33601
  attemptedSources += 1;
33202
33602
  const verified = await verifyInternalLinkCandidate({
33203
33603
  site: input.site,
33204
33604
  candidate,
33205
33605
  target,
33206
- js: input.js,
33207
33606
  refresh: input.refresh,
33208
- rate: input.rate,
33209
33607
  dependencies
33210
33608
  });
33211
33609
  warnings.push(...verified.warnings);
33212
- if (verified.item) items.push(verified.item);
33610
+ if (verified.item) found.push({
33611
+ item: verified.item,
33612
+ text: verified.text
33613
+ });
33213
33614
  if (verified.exclusion === "existing-link") existingLinkExclusions += 1;
33214
33615
  if (verified.exclusion === "technical") technicalExclusions += 1;
33215
33616
  if (verified.exclusion === "self-alias") selfAliasExclusions += 1;
33216
33617
  if (verified.exclusion === "failed") failedChecks += 1;
33217
33618
  else checkedSources += 1;
33218
33619
  }
33620
+ const items = placeLinks(found, target.text);
33219
33621
  const uncheckedCandidates = Math.max(0, analysis.candidates.length - attemptedSources);
33220
33622
  const targetTruncated = targetRequests.some(({ result }) => result.rowsFetched >= MAX_GSC_ROWS);
33221
33623
  const sourceTruncated = sourceResult.rowsFetched >= MAX_GSC_ROWS;
@@ -33277,13 +33679,14 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33277
33679
  },
33278
33680
  methodology: {
33279
33681
  id: "gsc_internal_link_candidates",
33280
- version: 3,
33682
+ version: 4,
33281
33683
  lexicalTargetLimit: 50,
33282
33684
  matchedQueryEvidenceLimit: 10,
33283
33685
  observedLinkEvidenceLimit: 20,
33284
33686
  matching: "pairwise_exact_then_precision_lexical",
33285
33687
  ranking: "exact_matches_then_matched_query_impressions_then_relevance",
33286
- contextualPlacementVerified: true
33688
+ contextualPlacementVerified: true,
33689
+ placement: "tfidf_sentence_match_then_phrase_anchors"
33287
33690
  },
33288
33691
  filters: {
33289
33692
  minImpressions,
@@ -33315,18 +33718,20 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33315
33718
  failedChecks,
33316
33719
  uncheckedCandidates,
33317
33720
  matchedQueryImpressions: items.reduce((sum, item) => sum + item.matchedQueryImpressions, 0),
33318
- brandFiltering: input.includeBrand ? "included" : "excluded",
33721
+ brandFiltering: "excluded",
33319
33722
  verdict: summaryVerdict
33320
33723
  },
33321
33724
  items,
33322
33725
  warnings: structuredWarnings,
33323
33726
  caveats: [
33324
- `Date window: ${range.startDate} to ${range.endDate} (${days} days), using final GSC data where available.`,
33727
+ `Date window: ${range.startDate} to ${range.endDate} (${days} days), using final GSC data where available.${input.startDate ? "" : ` Without dates the window is the last ${DEFAULT_DAYS$1} days.`}`,
33728
+ `Limits: up to ${checkLimit} source pages are checked live and up to ${limit} are returned. Queries need at least ${minImpressions} impression.`,
33325
33729
  `Selection: ${analysis.selection.targetEligibleQueries} eligible target queries, ${analysis.selection.candidateUrls} matched source pages, ${attemptedSources} attempted, ${checkedSources} successfully checked, ${items.length} returned.`,
33326
- `Minimum retained query impressions: ${minImpressions}. Brand queries were ${input.includeBrand ? "included" : "excluded when detected or configured"}.`,
33730
+ `Brand queries were left out. Brand terms found from the domain: ${brandTerms.join(", ") || "none"}.`,
33327
33731
  "GSC query rows omit anonymized queries and lower-volume rows. A 100,000-row cap is reported separately when reached.",
33328
33732
  "Exact query overlap is medium-confidence affinity evidence; lexical matches are low-confidence review evidence. Neither predicts traffic lift or link equity.",
33329
33733
  "The fetch checks HTML link placement, but it cannot prove editorial relevance. Review intent and reader usefulness before adding a link.",
33734
+ "Placement sentences are the source sentences without a link that share the most rare words with the target title, h1, description and URL. Anchors are phrases from those sentences. Content similarity compares the words of the checked pages only. All three are text heuristics, not ranking evidence.",
33330
33735
  uncheckedCandidates ? `${uncheckedCandidates} matched source page${uncheckedCandidates === 1 ? " was" : "s were"} not checked because the output or check limit was reached.` : "",
33331
33736
  failedChecks ? `${failedChecks} source verification attempt${failedChecks === 1 ? "" : "s"} failed. Failed fetches or extraction attempts are not counted as checked pages and do not support a missing-link conclusion.` : ""
33332
33737
  ].filter(Boolean),
@@ -33334,6 +33739,29 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33334
33739
  ledgerSummary: ledger.summary()
33335
33740
  };
33336
33741
  }
33742
+ function placeLinks(found, target) {
33743
+ const corpus = new LinkCorpus([target, ...found.map((entry) => entry.text)].filter((page) => Boolean(page)).map(termCounts));
33744
+ const placed = found.map(({ item, text }) => {
33745
+ const placement = target && text ? linkPlacement(text, target, corpus) : {
33746
+ contentSimilarity: 0,
33747
+ sentences: []
33748
+ };
33749
+ const best = placement.sentences[0];
33750
+ const recommendation = best ? {
33751
+ ...item.recommendation,
33752
+ action: `${item.recommendation.action} It fits in: "${best.text}" Anchor text could be ${best.anchors.map((anchor) => `"${anchor}"`).join(" or ")}.`
33753
+ } : {
33754
+ ...item.recommendation,
33755
+ action: `${item.recommendation.action} No sentence on this page names the target, so a link here needs new text.`
33756
+ };
33757
+ return {
33758
+ ...item,
33759
+ placement,
33760
+ recommendation
33761
+ };
33762
+ });
33763
+ return [...placed.filter((item) => item.placement.sentences.length > 0), ...placed.filter((item) => item.placement.sentences.length === 0)];
33764
+ }
33337
33765
  //#endregion
33338
33766
  //#region packages/core/dist/analyze/url-templates.js
33339
33767
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -36920,6 +37348,61 @@ async function updatePostmortemWorkflow(input, dependencies = {}) {
36920
37348
  }
36921
37349
  });
36922
37350
  }
37351
+ //#endregion
37352
+ //#region packages/core/dist/clicky/api.js
37353
+ const ENDPOINT$1 = "https://api.clicky.com/api/stats/4";
37354
+ const MAX_BYTES$1 = 5e6;
37355
+ var ClickyError = class extends Error {
37356
+ code;
37357
+ constructor(code, message) {
37358
+ super(message);
37359
+ this.code = code;
37360
+ }
37361
+ };
37362
+ function isClickySiteId(value) {
37363
+ return /^\d{1,30}$/u.test(value.trim());
37364
+ }
37365
+ function isClickySitekey(value) {
37366
+ return /^[A-Za-z0-9]{12,64}$/u.test(value.trim());
37367
+ }
37368
+ async function clickyStats(site, types, options = {}) {
37369
+ const url = new URL(ENDPOINT$1);
37370
+ url.searchParams.set("site_id", site.siteId);
37371
+ url.searchParams.set("sitekey", site.sitekey);
37372
+ url.searchParams.set("type", types.join(","));
37373
+ url.searchParams.set("output", "json");
37374
+ url.searchParams.set("limit", String(options.limit ?? 1e3));
37375
+ url.searchParams.set("app", "seo");
37376
+ if (options.date) url.searchParams.set("date", options.date);
37377
+ const response = await fetch(url).catch(() => {
37378
+ throw new ClickyError("remote", "Could not reach Clicky. Try again.");
37379
+ });
37380
+ if (!response.ok) throw new ClickyError("remote", `Clicky returned ${response.status}.`);
37381
+ const groups = await readJson(response, "Clicky", { maxBytes: MAX_BYTES$1 });
37382
+ if (!Array.isArray(groups)) throw new ClickyError("remote", "Clicky returned invalid data.");
37383
+ const failed = groups.find((group) => typeof group?.error === "string");
37384
+ if (failed?.error) throw /sitekey|site id|authenticat|permission/iu.test(failed.error) ? new ClickyError("auth", `Clicky rejected site ID ${site.siteId} or its sitekey.`) : new ClickyError("remote", `Clicky: ${failed.error.slice(0, 200)}`);
37385
+ const out = {};
37386
+ for (const type of types) out[type] = [];
37387
+ for (const group of groups) {
37388
+ if (!group?.type || !(group.type in out)) continue;
37389
+ for (const date of group.dates ?? []) for (const item of date.items ?? []) if (item && typeof item === "object") out[group.type]?.push(item);
37390
+ }
37391
+ return out;
37392
+ }
37393
+ async function clickyDomain(site) {
37394
+ const { pages } = await clickyStats(site, ["pages"], {
37395
+ date: "last-30-days",
37396
+ limit: 1
37397
+ });
37398
+ const url = pages?.find((item) => item.url)?.url;
37399
+ if (!url) return null;
37400
+ try {
37401
+ return new URL(url).hostname.replace(/^www\./u, "");
37402
+ } catch {
37403
+ return null;
37404
+ }
37405
+ }
36923
37406
  function rowsText(input) {
36924
37407
  const total = input.rows.length;
36925
37408
  if (input.detail === "full") return `${input.title}\n\n${JSON.stringify(input.rows.slice(input.offset, input.offset + input.limit))}`;
@@ -37104,7 +37587,8 @@ async function runAnalyticsRows(input, google, options) {
37104
37587
  //#endregion
37105
37588
  //#region packages/core/dist/commands/bing.js
37106
37589
  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."];
37107
- const LOCAL_BING = `Bing blocks the hosted server, so Bing data comes from the CLI on a machine. If you have a shell, link the CLI to the same account once: run \`npx -y seo login\`, show the person the link it prints, and after they approve it run \`npx -y seo login\` again. \`npx -y seo\` commands then use the same Google accounts, Bing and keys. Connect Bing on the account page if it is not connected, then run \`npx -y seo report --site <site> --section bing --json\`.`;
37590
+ const CLI_LINK = "If you have a shell, link the CLI to the same account once: run `npx -y seo login`, show the person the link it prints, and after they approve it run `npx -y seo login` again. `npx -y seo` commands then use the same Google accounts, Bing and keys.";
37591
+ const LOCAL_BING = `Bing blocks the hosted server, so Bing data comes from the CLI on a machine. ${CLI_LINK} Connect Bing on the account page if it is not connected, then run \`npx -y seo report --site <site> --section bing --json\`.`;
37108
37592
  const NO_BING_HERE = "Bing: not included here, so these totals show Google only. Some sites get most of their search visits from Bing; the CLI reads them with the same account: `npx -y seo login` once, then `npx -y seo report --site <site> --section bing --json`.";
37109
37593
  const BING_HOME = "https://www.bing.com/webmasters";
37110
37594
  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.`;
@@ -37322,6 +37806,161 @@ async function runBingRows(input, deps) {
37322
37806
  };
37323
37807
  }
37324
37808
  //#endregion
37809
+ //#region packages/core/dist/commands/clicky.js
37810
+ const CLICKY_KEY_PREFIX = "x-clicky-";
37811
+ function clickySites(keys) {
37812
+ const sites = [];
37813
+ for (const [name, value] of keys) {
37814
+ if (!name.startsWith("x-clicky-")) continue;
37815
+ const siteId = name.slice(9);
37816
+ if (!isClickySiteId(siteId)) continue;
37817
+ let sitekey = value.trim();
37818
+ let domain = null;
37819
+ let account = false;
37820
+ if (sitekey.startsWith("{")) try {
37821
+ const parsed = JSON.parse(sitekey);
37822
+ sitekey = typeof parsed.sitekey === "string" ? parsed.sitekey : "";
37823
+ domain = typeof parsed.domain === "string" ? parsed.domain : null;
37824
+ account = parsed.account === true;
37825
+ } catch {
37826
+ continue;
37827
+ }
37828
+ if (isClickySitekey(sitekey)) sites.push({
37829
+ siteId,
37830
+ sitekey,
37831
+ domain,
37832
+ account
37833
+ });
37834
+ }
37835
+ return sites.sort((left, right) => left.siteId < right.siteId ? -1 : left.siteId > right.siteId ? 1 : 0);
37836
+ }
37837
+ const clickyLabel = (site) => site.domain ? `${site.siteId} (${site.domain})` : site.siteId;
37838
+ function hostOf(value) {
37839
+ const text = value.trim().toLowerCase().replace(/^sc-domain:/u, "");
37840
+ try {
37841
+ return new URL(text.includes("://") ? text : `https://${text}`).hostname.replace(/^www\./u, "");
37842
+ } catch {
37843
+ return text;
37844
+ }
37845
+ }
37846
+ async function pickClickySite(saved, target, connect, refresh) {
37847
+ const host = hostOf(target);
37848
+ const match = (site) => site.siteId === target.trim() || site.domain === host;
37849
+ let sites = saved;
37850
+ if (!sites.some(match) && refresh) sites = await refresh() ?? sites;
37851
+ if (!sites.length) throw new CommandError("NO_KEY", `No Clicky site is saved. Add your Clicky account, or one site's ID and sitekey (Clicky, Preferences, Info): ${connect}`);
37852
+ const found = sites.find(match);
37853
+ if (found) return found;
37854
+ if (!isClickySiteId(target)) {
37855
+ for (const site of sites.filter((item) => !item.domain).slice(0, 10)) site.domain = await clickyDomain(site).catch(() => null);
37856
+ const named = sites.find(match);
37857
+ if (named) return named;
37858
+ }
37859
+ throw new CommandError("NO_ACCESS", `No saved Clicky site matches ${target}. Saved Clicky sites: ${sites.map(clickyLabel).join(", ")}. Pass one of these site IDs, or add the site: ${connect}`);
37860
+ }
37861
+ const DAY = 864e5;
37862
+ const isoDay = (time) => new Date(time).toISOString().slice(0, 10);
37863
+ function clickyRange(input) {
37864
+ if (!input.startDate && !input.endDate) {
37865
+ const end = Date.now() - DAY;
37866
+ return {
37867
+ startDate: isoDay(end - 27 * DAY),
37868
+ endDate: isoDay(end)
37869
+ };
37870
+ }
37871
+ const start = Date.parse(`${input.startDate}T00:00:00Z`);
37872
+ const end = Date.parse(`${input.endDate}T00:00:00Z`);
37873
+ if (!input.startDate || !input.endDate || !(start <= end)) throw new CommandError("INVALID_INPUT", "Send a start date and an end date together, in order.");
37874
+ if ((end - start) / DAY + 1 > 31) throw new CommandError("INVALID_INPUT", "Clicky reads at most 31 days at a time. Send a shorter range.");
37875
+ return {
37876
+ startDate: input.startDate,
37877
+ endDate: input.endDate
37878
+ };
37879
+ }
37880
+ const count = (item) => {
37881
+ const value = Number(item.value);
37882
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
37883
+ };
37884
+ const totals = (items) => items.map((item) => ({
37885
+ name: item.title ?? "",
37886
+ visits: count(item)
37887
+ })).filter((item) => Boolean(item.name) && item.visits !== null);
37888
+ const line = (items) => {
37889
+ const top = items.slice(0, 10).map((item) => `${item.name} ${item.visits}`);
37890
+ const rest = items.slice(10);
37891
+ if (rest.length) top.push(`${rest.length} others ${rest.reduce((sum, item) => sum + item.visits, 0)}`);
37892
+ return top.join("; ");
37893
+ };
37894
+ const LIMIT = 1e3;
37895
+ async function runClickyRows(input, keys, options) {
37896
+ const refresh = options.refresh;
37897
+ const site = await pickClickySite(clickySites(keys), input.site, options.connect, refresh && (async () => {
37898
+ const fresh = await refresh().catch(() => null);
37899
+ return fresh && clickySites(fresh);
37900
+ }));
37901
+ const range = clickyRange(input);
37902
+ const offset = input.offset ?? 0;
37903
+ const stats = await clickyStats(site, [
37904
+ "pages-entrance",
37905
+ "traffic-sources",
37906
+ "searches-engines"
37907
+ ], {
37908
+ date: `${range.startDate},${range.endDate}`,
37909
+ limit: LIMIT
37910
+ }).catch((error) => {
37911
+ if (error instanceof ClickyError) throw new CommandError(error.code === "auth" ? "NO_ACCESS" : "PROVIDER_ERROR", error.code === "auth" ? `${error.message} Save it again: ${options.connect}` : error.message);
37912
+ throw error;
37913
+ });
37914
+ const entrances = stats["pages-entrance"] ?? [];
37915
+ const visitsByPage = /* @__PURE__ */ new Map();
37916
+ for (const item of entrances) {
37917
+ const visits = count(item);
37918
+ if (visits === null || !item.url) continue;
37919
+ let page;
37920
+ try {
37921
+ page = new URL(item.url).pathname;
37922
+ } catch {
37923
+ continue;
37924
+ }
37925
+ visitsByPage.set(page, (visitsByPage.get(page) ?? 0) + visits);
37926
+ }
37927
+ const rows = [...visitsByPage].map(([page, visits]) => ({
37928
+ page,
37929
+ visits
37930
+ })).sort((left, right) => right.visits - left.visits || (left.page < right.page ? -1 : left.page > right.page ? 1 : 0));
37931
+ const sources = totals(stats["traffic-sources"] ?? []);
37932
+ const engines = totals(stats["searches-engines"] ?? []);
37933
+ const notes = [
37934
+ "Visits by landing page from every source, as Clicky counts them, in the Clicky site’s time zone. Clicky does not split landing pages by source.",
37935
+ sources.length ? `Traffic sources (visits): ${line(sources)}.` : "Traffic sources: none in this range.",
37936
+ engines.length ? `Search engines (visits): ${line(engines)}.` : "Search engines: none in this range."
37937
+ ];
37938
+ if (entrances.length >= LIMIT) notes.push(`Clicky returned its ${LIMIT}-row limit; pages past it are missing, not zero.`);
37939
+ return {
37940
+ text: rowsText({
37941
+ title: `Clicky site ${clickyLabel(site)}: landing pages, ${range.startDate} to ${range.endDate}.`,
37942
+ rows,
37943
+ offset,
37944
+ limit: 50,
37945
+ detail: input.detail ?? "summary",
37946
+ notes,
37947
+ more: options.call
37948
+ }),
37949
+ data: {
37950
+ siteId: site.siteId,
37951
+ domain: site.domain,
37952
+ range,
37953
+ metric: "visits",
37954
+ sources,
37955
+ searchEngines: engines,
37956
+ capped: entrances.length >= LIMIT,
37957
+ total: rows.length,
37958
+ offset,
37959
+ rows: rows.slice(offset, offset + 50)
37960
+ }
37961
+ };
37962
+ }
37963
+ //#endregion
37325
37964
  //#region packages/core/dist/commands/domain-rating.js
37326
37965
  const DR_ATTRIBUTION = "domain_rating: Domain Rating by Ahrefs (https://ahrefs.com/).";
37327
37966
  const ENDPOINT = "https://api.ahrefs.com/v3/public/domain-rating-free";
@@ -37547,7 +38186,7 @@ function nextFor(section, call) {
37547
38186
  "second-page": `Next: ${call("page")} for one page’s queries and live facts.`,
37548
38187
  "content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
37549
38188
  cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
37550
- "traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved.`,
38189
+ "traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved; ${call("index")} for pages that lost almost all their clicks and their index status.`,
37551
38190
  bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
37552
38191
  }[section];
37553
38192
  }
@@ -38235,11 +38874,13 @@ async function runPage(input, deps) {
38235
38874
  }]
38236
38875
  }]
38237
38876
  });
38238
- const [[facts], indexed, now, then] = await Promise.all([
38877
+ const full = input.detail === "full";
38878
+ const [[facts], indexed, now, then, markdown] = await Promise.all([
38239
38879
  deps.checkPages([input.url]),
38240
38880
  deps.inspect(input.site, input.url),
38241
38881
  query(range),
38242
- query(before)
38882
+ query(before),
38883
+ full && deps.pageMarkdown ? deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })) : null
38243
38884
  ]);
38244
38885
  const old = new Map(then.rows.map((row) => [row.keys[0], row]));
38245
38886
  const seen = /* @__PURE__ */ new Set();
@@ -38274,23 +38915,32 @@ async function runPage(input, deps) {
38274
38915
  const sum = (list, key) => list.reduce((total, row) => total + row[key], 0);
38275
38916
  const offset = input.offset ?? 0;
38276
38917
  return {
38277
- text: `${rowsText({
38278
- title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
38279
- notes: [
38280
- `Live page (fetched now, no redirects followed):\n${facts ? pageFactsTable([facts], null) : "Not fetched."}`,
38281
- indexed ? `Google index status:\n${table([indexed], Object.keys(indexed), null)}` : "Google index status: not available for this URL.",
38282
- `Query totals: clicks ${sum(now.rows, "clicks")} (was ${sum(then.rows, "clicks")}), impressions ${sum(now.rows, "impressions")} (was ${sum(then.rows, "impressions")}). Search Console hides rare queries, so these can be lower than the page total.`
38283
- ],
38284
- rows,
38285
- offset,
38286
- limit: 50,
38287
- detail: input.detail ?? "summary",
38288
- more: (next) => deps.call("page", {
38289
- url: input.url,
38290
- detail: "rows",
38291
- offset: next
38292
- })
38293
- })}\n\nNext: ${deps.call("research")} serp with the top query to see who else ranks.\n\n${FACTS_ONLY}`,
38918
+ text: [
38919
+ rowsText({
38920
+ title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
38921
+ notes: [
38922
+ `Live page (fetched now, no redirects followed):\n${facts ? pageFactsTable([facts], null) : "Not fetched."}`,
38923
+ indexed ? `Google index status:\n${table([indexed], Object.keys(indexed), null)}` : "Google index status: not available for this URL.",
38924
+ `Query totals: clicks ${sum(now.rows, "clicks")} (was ${sum(then.rows, "clicks")}), impressions ${sum(now.rows, "impressions")} (was ${sum(then.rows, "impressions")}). Search Console hides rare queries, so these can be lower than the page total.`,
38925
+ deps.pageMarkdown ? `Main content as Markdown: ${deps.call("page", {
38926
+ url: input.url,
38927
+ detail: "full"
38928
+ })}.` : `Main content as Markdown: only in the CLI and the local MCP server, with \`npx -y seo page --url ${input.url} --detail full\`.`
38929
+ ],
38930
+ rows,
38931
+ offset,
38932
+ limit: 50,
38933
+ detail: input.detail ?? "summary",
38934
+ more: (next) => deps.call("page", {
38935
+ url: input.url,
38936
+ detail: "rows",
38937
+ offset: next
38938
+ })
38939
+ }),
38940
+ ...full ? [markdownText(markdown, input.url)] : [],
38941
+ `Next: ${deps.call("research")} serp with the top query to see who else ranks.`,
38942
+ FACTS_ONLY
38943
+ ].join("\n\n"),
38294
38944
  data: {
38295
38945
  url: input.url,
38296
38946
  range,
@@ -38298,10 +38948,17 @@ async function runPage(input, deps) {
38298
38948
  page: facts ?? null,
38299
38949
  index: indexed,
38300
38950
  totalQueries: rows.length,
38301
- queries: rows.slice(offset, offset + 50)
38951
+ queries: rows.slice(offset, offset + 50),
38952
+ ...full ? { markdown } : {}
38302
38953
  }
38303
38954
  };
38304
38955
  }
38956
+ function markdownText(markdown, url) {
38957
+ if (!markdown) return `Main content as Markdown: not on this server; it runs in the CLI and the local MCP server. ${CLI_LINK} Then run \`npx -y seo page --url ${url} --detail full\`.`;
38958
+ if ("error" in markdown) return `Main content as Markdown: the page could not be read (${markdown.error}).`;
38959
+ if (!markdown.markdown) return "Main content as Markdown: the page has no main content to read.";
38960
+ return `Main content as Markdown (live page${markdown.truncated ? `, cut to ${markdown.markdown.length.toLocaleString("en-US")} of ${markdown.characters.toLocaleString("en-US")} characters` : ""}):\n\n${markdown.markdown}`;
38961
+ }
38305
38962
  async function runCompare(input, deps) {
38306
38963
  if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date)) throw new CommandError("INVALID_INPUT", "The date must be YYYY-MM-DD.");
38307
38964
  const last = latestFinalGscDate(deps.now?.() ?? /* @__PURE__ */ new Date());
@@ -38337,7 +38994,39 @@ const LOST_SHARE = .2;
38337
38994
  const MIN_LOST = 3;
38338
38995
  const CHECKED = 20;
38339
38996
  const INSPECTED = 10;
38340
- const LOST_PAGES_RULE = `Pages that had at least ${MIN_LOST} clicks in the previous 28 days and kept ${LOST_SHARE * 100}% or less of them in the last 28 days, with a live check of the top ${CHECKED} and Google’s index status for the top ${INSPECTED}.`;
38997
+ const SITE_WIDE_MIN = 5;
38998
+ function indexSummary(inspected, home) {
38999
+ const known = inspected.filter((item) => item?.verdict != null);
39000
+ const indexed = known.filter((item) => item.verdict === "PASS").length;
39001
+ const coverage = /* @__PURE__ */ new Map();
39002
+ for (const item of known) {
39003
+ if (item.verdict === "PASS") continue;
39004
+ const state = item.coverage ?? item.verdict ?? "unknown";
39005
+ coverage.set(state, (coverage.get(state) ?? 0) + 1);
39006
+ }
39007
+ const notIndexed = known.length - indexed;
39008
+ const homeIndexed = home?.status?.verdict ? home.status.verdict === "PASS" : null;
39009
+ const siteWide = notIndexed >= SITE_WIDE_MIN && notIndexed * 2 > known.length && homeIndexed;
39010
+ const byCoverage = [...coverage].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([state, count]) => ({
39011
+ state,
39012
+ count
39013
+ }));
39014
+ return {
39015
+ text: (known.length ? [`Index status of the ${known.length} inspected pages: ${indexed} indexed, ${notIndexed} not indexed${byCoverage.length ? ` (${byCoverage.map((item) => `${item.count} ${item.state}`).join("; ")})` : ""}.${home && homeIndexed !== null ? ` Home page ${home.url}: ${homeIndexed ? "indexed" : `not indexed (${home.status?.coverage ?? home.status?.verdict})`}.` : ""}`, siteWide ? `Site-wide pattern: ${notIndexed} of ${known.length} inspected pages are not in Google's index, but the home page is. This points to the whole site, not single pages. It does not show the cause.` : null] : []).filter(Boolean).join("\n"),
39016
+ data: {
39017
+ inspected: known.length,
39018
+ indexed,
39019
+ notIndexed,
39020
+ byCoverage,
39021
+ homePage: home && homeIndexed !== null ? {
39022
+ url: home.url,
39023
+ indexed: homeIndexed
39024
+ } : null,
39025
+ siteWide: Boolean(siteWide)
39026
+ }
39027
+ };
39028
+ }
39029
+ const LOST_PAGES_RULE = `Pages that had at least ${MIN_LOST} clicks in the previous 28 days and kept ${LOST_SHARE * 100}% or less of them in the last 28 days, with a live check of the top ${CHECKED} and Google’s index status for the top ${INSPECTED} and the home page.`;
38341
39030
  async function runLostPages(input, deps) {
38342
39031
  const range = dateWindow(input, 28);
38343
39032
  const before = earlierRange(range);
@@ -38355,7 +39044,17 @@ async function runLostPages(input, deps) {
38355
39044
  clicks: current.get(row.keys[0]) ?? 0
38356
39045
  })).filter((row) => row.clicks <= row.previous_clicks * LOST_SHARE).sort((a, b) => b.previous_clicks - b.clicks - (a.previous_clicks - a.clicks) || a.url.localeCompare(b.url));
38357
39046
  const top = lost.slice(0, CHECKED);
38358
- const [facts, indexed] = await Promise.all([deps.checkPages(top.map((row) => row.url)), Promise.all(top.slice(0, INSPECTED).map((row) => deps.inspect(input.site, row.url)))]);
39047
+ const homeUrl = top[0] ? `${new URL(top[0].url).origin}/` : null;
39048
+ const [facts, indexed, homeStatus] = await Promise.all([
39049
+ deps.checkPages(top.map((row) => row.url)),
39050
+ Promise.all(top.slice(0, INSPECTED).map((row) => deps.inspect(input.site, row.url))),
39051
+ homeUrl && !top.slice(0, INSPECTED).some((row) => row.url === homeUrl) ? deps.inspect(input.site, homeUrl).catch(() => null) : null
39052
+ ]);
39053
+ const homeIndex = top.slice(0, INSPECTED).findIndex((row) => row.url === homeUrl);
39054
+ const summary = indexSummary(homeIndex >= 0 ? indexed.filter((_, index) => index !== homeIndex) : indexed, homeUrl ? {
39055
+ url: homeUrl,
39056
+ status: homeIndex >= 0 ? indexed[homeIndex] ?? null : homeStatus
39057
+ } : null);
38359
39058
  const rows = top.map((row, index) => ({
38360
39059
  ...row,
38361
39060
  status: facts[index]?.status ?? null,
@@ -38371,6 +39070,7 @@ async function runLostPages(input, deps) {
38371
39070
  `Pages that lost their clicks on ${input.site}: ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
38372
39071
  `${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` : ""}.`,
38373
39072
  rows.length ? table(rows, Object.keys(rows[0] ?? {}), originOf(input.site)) : "No pages lost their clicks.",
39073
+ summary.text,
38374
39074
  "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.",
38375
39075
  temporaryRedirectNote(rows),
38376
39076
  `Next: ${deps.call("page")} for one URL; ${deps.call("report", {
@@ -38384,6 +39084,7 @@ async function runLostPages(input, deps) {
38384
39084
  comparisonRange: before,
38385
39085
  rule: LOST_PAGES_RULE,
38386
39086
  lostPages: lost.length,
39087
+ index: summary.data,
38387
39088
  pages: rows
38388
39089
  }
38389
39090
  };
@@ -38436,9 +39137,9 @@ const detail = {
38436
39137
  const DATA_SOURCES = [
38437
39138
  "search_console",
38438
39139
  "google_analytics",
38439
- "bing"
39140
+ "bing",
39141
+ "clicky"
38440
39142
  ];
38441
- const LOCAL_ANALYTICS = "Clicky and other analytics run on the person’s machine with the CLI and a provider package: `npx -y seo providers install @seoskill/clicky-provider`, connect it once with `seo providers connect clicky`, then `npx -y seo analytics clicky report --json`. `seo providers list` shows installed providers.";
38442
39143
  function tool(spec) {
38443
39144
  return spec;
38444
39145
  }
@@ -38452,6 +39153,7 @@ const SHARED_TOOLS = [
38452
39153
  run: async (surface) => runSites({
38453
39154
  accounts: await surface.accounts(),
38454
39155
  keys: await surface.keys(),
39156
+ clicky: clickySites(await surface.keys()).map(clickyLabel),
38455
39157
  signIn: surface.signIn,
38456
39158
  bing: surface.deps.bing ? await surface.deps.bing() : void 0,
38457
39159
  connect: surface.connect
@@ -38549,10 +39251,10 @@ const SHARED_TOOLS = [
38549
39251
  name: "seo_data",
38550
39252
  command: "data",
38551
39253
  title: "Raw rows",
38552
- description: `Raw rows for questions the report does not answer. search_console (default): rows for any dimensions, each with the previous period and the change; page and query narrow to rows whose URL or query contains that text. google_analytics: landing pages for visits from search, with Google Analytics’ own metric names. bing: Bing Webmaster query or page rows (first dimension) for the same site, by week. ${LOCAL_ANALYTICS}`,
39254
+ description: `Raw rows for questions the report does not answer. search_console (default): rows for any dimensions, each with the previous period and the change; page and query narrow to rows whose URL or query contains that text. google_analytics: landing pages for visits from search, with Google Analytics’ own metric names. bing: Bing Webmaster query or page rows (first dimension) for the same site, by week. clicky: landing-page visits, traffic sources and search engines from a Clicky site the person saved (site is its domain or Clicky site ID; at most 31 days).`,
38553
39255
  input: z.object({
38554
39256
  source: z.enum(DATA_SOURCES).default("search_console"),
38555
- site: z.string().min(1).describe("search_console and bing: the property or site. google_analytics: numeric property id from seo_sites."),
39257
+ site: z.string().min(1).describe("search_console and bing: the property or site. google_analytics: numeric property id from seo_sites. clicky: the site or its Clicky site ID."),
38556
39258
  dimensions: z.array(z.enum(DIMENSIONS)).max(4).default(["query"]).describe("search_console; bing uses the first, query or page."),
38557
39259
  page: z.string().min(1).optional().describe("search_console only: URL text, such as /blog/."),
38558
39260
  query: z.string().min(1).optional().describe("search_console only: query text."),
@@ -38560,6 +39262,17 @@ const SHARED_TOOLS = [
38560
39262
  ...detail
38561
39263
  }),
38562
39264
  run: async (surface, input) => {
39265
+ if (input.source === "clicky") return runClickyRows({
39266
+ site: input.site,
39267
+ startDate: input.start_date,
39268
+ endDate: input.end_date,
39269
+ detail: input.detail,
39270
+ offset: input.offset
39271
+ }, await surface.keys(), {
39272
+ connect: surface.connect,
39273
+ call: (offset) => `seo_data with source "clicky", site "${input.site}", detail "rows", offset ${offset}`,
39274
+ refresh: surface.refreshClicky
39275
+ });
38563
39276
  if (input.source === "bing") return runBingRows({
38564
39277
  site: input.site,
38565
39278
  dimension: input.dimensions[0] === "page" ? "page" : "query",
@@ -38603,10 +39316,10 @@ async function runSharedTool(tool, surface, input) {
38603
39316
  if (!parsed.success) throw new CommandError("INVALID_INPUT", parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; "));
38604
39317
  return tool.run(surface, parsed.data);
38605
39318
  }
38606
- const MCP_INSTRUCTIONS = `SEO Skill reads the person's own Search Console, Google Analytics, Bing Webmaster Tools and optional keyword and analytics accounts, and returns facts with their dates and limits. It gives no advice.
39319
+ const MCP_INSTRUCTIONS = `SEO Skill reads the person's own Search Console, Google Analytics, Bing Webmaster Tools, Clicky and optional keyword accounts, and returns facts with their dates and limits. It gives no advice.
38607
39320
  1. seo_sites: the properties the connected accounts can read.
38608
39321
  2. seo_report: the main report. Run it without a section first; it shows the overview and every section's count and top rows. Then ask for the one section you need.
38609
- 3. seo_page for one URL, seo_compare for before and after a date, seo_index for pages that lost their clicks, seo_research for keyword, competitor and backlink data from the DataForSEO or Ahrefs key the person saved, seo_data for raw rows.
39322
+ 3. seo_page for one URL, seo_compare for before and after a date, seo_index for pages that lost their clicks, seo_research for keyword, competitor and backlink data from the DataForSEO or Ahrefs key the person saved, seo_data for raw rows, including Google Analytics, Bing and Clicky.
38610
39323
  Show the person the facts that matter, for example in a table or a small dashboard, and decide with them what to change. Do not edit pages without their approval.`;
38611
39324
  const mcpCall = (command, args) => {
38612
39325
  const name = `seo_${command.replace("-", "_")}`;
@@ -38811,6 +39524,7 @@ function nodeCommandDeps(options) {
38811
39524
  return null;
38812
39525
  }
38813
39526
  },
39527
+ pageMarkdown: async (url) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh })),
38814
39528
  call: options.call,
38815
39529
  bing: localBing,
38816
39530
  bingConnect: LOCAL_BING_CONNECT
@@ -38833,12 +39547,17 @@ async function localAccounts() {
38833
39547
  }));
38834
39548
  }
38835
39549
  async function localResearchKeys() {
38836
- const [dataForSeo, ahrefs, saved] = await Promise.all([
39550
+ const [dataForSeo, ahrefs, clicky, saved] = await Promise.all([
38837
39551
  readDataForSeoCredentials().catch(() => void 0),
38838
39552
  readAhrefsApiKey().catch(() => void 0),
39553
+ readClickySiteKeys().catch(() => ({})),
38839
39554
  cloudLinked() ? cloudKeys().catch(() => ({})) : {}
38840
39555
  ]);
38841
39556
  const keys = new Headers(saved);
39557
+ for (const [siteId, sitekey] of Object.entries(clicky)) {
39558
+ const name = `${CLICKY_KEY_PREFIX}${siteId}`;
39559
+ if (!keys.has(name)) keys.set(name, sitekey);
39560
+ }
38842
39561
  if (dataForSeo) {
38843
39562
  keys.set("x-dataforseo-login", dataForSeo.login);
38844
39563
  keys.set("x-dataforseo-password", dataForSeo.password);
@@ -38846,6 +39565,11 @@ async function localResearchKeys() {
38846
39565
  if (ahrefs?.apiKey) keys.set("x-ahrefs-key", ahrefs.apiKey);
38847
39566
  return keys;
38848
39567
  }
39568
+ async function localRefreshClicky() {
39569
+ if (!cloudLinked()) return null;
39570
+ await cloudCredentials({ refreshClicky: true });
39571
+ return localResearchKeys();
39572
+ }
38849
39573
  async function resolveLocalSite(site, options = {}) {
38850
39574
  const found = matchProperty((await listSites(options.refresh, options.account)).map((entry) => entry.siteUrl), site);
38851
39575
  if (!found) throw new CommandError("NO_ACCESS", `This Google login has no Search Console property for ${site}. Run \`seo sites\` to list the properties it can read.`);
@@ -40835,46 +41559,18 @@ function registerOpportunityTools(server, dependencies = {}) {
40835
41559
  server.registerTool("seo_internal_links", {
40836
41560
  description: "Find verified internal-link review candidates for a target URL",
40837
41561
  inputSchema: {
40838
- ...mcpReportInputSchema([
40839
- "site",
40840
- "days",
40841
- "limit",
40842
- "checkLimit",
40843
- "minImpressions",
40844
- "includeBrand",
40845
- "js",
40846
- "fetchConcurrency",
40847
- "fetchIntervalCap",
40848
- "fetchIntervalMs",
40849
- "refresh"
40850
- ]),
41562
+ ...mcpReportInputSchema(["site", "refresh"]),
40851
41563
  targetUrl: z$1.string().url().refine((value) => /^https?:\/\//.test(value), "Use an HTTP(S) URL."),
40852
- days: z$1.number().int().min(1).max(548).optional(),
40853
- limit: z$1.number().int().min(1).max(100).optional(),
40854
- checkLimit: z$1.number().int().min(1).max(200).optional(),
40855
- minImpressions: z$1.number().min(0).max(1e9).optional(),
40856
- brandTerms: z$1.array(z$1.string().trim().min(1).max(200)).max(20).optional(),
40857
- fetchConcurrency: z$1.number().int().min(1).max(16).optional(),
40858
- fetchIntervalCap: z$1.number().int().min(1).max(60).optional(),
40859
- fetchIntervalMs: z$1.number().int().min(100).max(6e4).optional()
41564
+ startDate: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
41565
+ endDate: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
40860
41566
  }
40861
- }, async ({ site, targetUrl, days, limit, checkLimit, minImpressions, brandTerms, includeBrand, js, fetchConcurrency, fetchIntervalCap, fetchIntervalMs, refresh }) => {
41567
+ }, async ({ site, targetUrl, startDate, endDate, refresh }) => {
40862
41568
  try {
40863
41569
  const result = await (dependencies.internalLinksReport ?? internalLinksReport)({
40864
41570
  site,
40865
41571
  targetUrl,
40866
- days,
40867
- limit,
40868
- checkLimit,
40869
- minImpressions,
40870
- brandTerms,
40871
- includeBrand,
40872
- js: resolveJsOption(js, void 0),
40873
- rate: fetchRateInput({
40874
- fetchConcurrency,
40875
- fetchIntervalCap,
40876
- fetchIntervalMs
40877
- }),
41572
+ startDate,
41573
+ endDate,
40878
41574
  refresh
40879
41575
  });
40880
41576
  return toolSuccess(result.summary.verdict, result);
@@ -41183,12 +41879,14 @@ const REPORT_DEPTH = {
41183
41879
  readOrder: [
41184
41880
  "dataStatus, target verification, aliases, canonical, and technical signals",
41185
41881
  "source completeness and selection attempted, checked, failed, unchecked, and exclusions",
41186
- "per-item exact versus lexical matches and linkEvidence"
41882
+ "per-item exact versus lexical matches and linkEvidence",
41883
+ "per-item placement: the sentence where a link fits, its anchors, and contentSimilarity"
41187
41884
  ],
41188
41885
  doNotClaim: [
41189
- "A low checkLimit can leave plausible candidates unchecked, so absence is not proven.",
41886
+ "The live check stops at 40 source pages, so unchecked candidates can remain and absence is not proven.",
41190
41887
  "Query overlap does not prove a link belongs or that adding one improves rankings.",
41191
- "Priority is a heuristic, not estimated impact."
41888
+ "Priority is a heuristic, not estimated impact.",
41889
+ "Placement sentences, anchors and contentSimilarity are text heuristics; they do not prove the link helps a reader."
41192
41890
  ],
41193
41891
  verify: "Read the source passage, add one contextual link if the target is genuinely useful, and re-crawl to confirm it.",
41194
41892
  related: [
@@ -42362,6 +43060,7 @@ function registerSharedTools(server, options = {}) {
42362
43060
  deps: nodeCommandDeps({ call: mcpCall }),
42363
43061
  resolveSite: (site) => resolveLocalSite(site),
42364
43062
  keys: localResearchKeys,
43063
+ refreshClicky: localRefreshClicky,
42365
43064
  drKey: null,
42366
43065
  accounts: localAccounts,
42367
43066
  signIn: LOCAL_SIGN_IN.signIn,
@@ -42386,14 +43085,20 @@ function registerSharedTools(server, options = {}) {
42386
43085
  })));
42387
43086
  server.registerTool("seo_internal_links", {
42388
43087
  title: "Internal link opportunities",
42389
- description: "Pages of the same site that could link to a target page, from Search Console and live page checks on this machine.",
42390
- inputSchema: { url: z$1.url() },
43088
+ description: "Pages of the same site that could link to a target page, from Search Console and live page checks on this machine. Each one names the sentence where the link fits and anchor text from it. Takes the target URL and optional dates (default: the last 90 days).",
43089
+ inputSchema: {
43090
+ url: z$1.url(),
43091
+ start_date: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD. Optional; send with end_date."),
43092
+ end_date: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD. Optional; send with start_date.")
43093
+ },
42391
43094
  annotations: readOnly
42392
- }, async ({ url }) => counted("internal-links", {}, options.telemetry, async () => {
43095
+ }, async ({ url, start_date, end_date }) => counted("internal-links", {}, options.telemetry, async () => {
42393
43096
  try {
42394
43097
  return await executeReport("internal-links", {
42395
43098
  site: await resolveLocalSite(url),
42396
- targetUrl: url
43099
+ targetUrl: url,
43100
+ startDate: start_date,
43101
+ endDate: end_date
42397
43102
  });
42398
43103
  } catch (error) {
42399
43104
  return toolError(localError(error));