seo 0.6.0 → 0.7.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-CwLtPH6B.js";
2
+ import { i as __toESM, n as load, r as __commonJSMin, t as version } from "./package-BaQwwfSG.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";
@@ -16802,7 +16802,7 @@ function extractCanonicalEvidence($, headers, baseUrl) {
16802
16802
  //#region packages/core/dist/extract/content-sketch.js
16803
16803
  const SHINGLE_SIZE = 5;
16804
16804
  const MAX_HASHES = 32;
16805
- function words(value) {
16805
+ function words$1(value) {
16806
16806
  return value.normalize("NFKC").toLocaleLowerCase("en").match(/[\p{L}\p{N}]+/gu) ?? [];
16807
16807
  }
16808
16808
  function sampleIndexes(total, limit) {
@@ -16813,7 +16813,7 @@ function digest(value) {
16813
16813
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
16814
16814
  }
16815
16815
  function contentSketch(value) {
16816
- const tokens = words(value);
16816
+ const tokens = words$1(value);
16817
16817
  const hashes = sampleIndexes(Math.max(0, tokens.length - SHINGLE_SIZE + 1), MAX_HASHES).map((index) => digest(tokens.slice(index, index + SHINGLE_SIZE).join(" ")));
16818
16818
  return {
16819
16819
  version: 1,
@@ -16825,6 +16825,11 @@ function contentSketch(value) {
16825
16825
  }
16826
16826
  //#endregion
16827
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
+ }
16828
16833
  function readableError(error) {
16829
16834
  return error instanceof Error ? error.message : String(error);
16830
16835
  }
@@ -16854,10 +16859,6 @@ function resolveDocumentUrls(document, baseUrl) {
16854
16859
  }
16855
16860
  }
16856
16861
  function readabilityResult(fetchResult, requested, fallbackReason, fallbackDetail) {
16857
- const { document } = parseHTML(fetchResult.html);
16858
- const bodyText = document.body.textContent ?? "";
16859
- const article = new Readability(document).parse();
16860
- const text = article?.textContent ?? bodyText;
16861
16862
  const fallback = requested !== "readability";
16862
16863
  const diagnostics = {
16863
16864
  requested,
@@ -16870,13 +16871,27 @@ function readabilityResult(fetchResult, requested, fallbackReason, fallbackDetai
16870
16871
  fallbackDetail
16871
16872
  } : {}
16872
16873
  };
16873
- return {
16874
- text,
16875
- excerpt: normalizedText(article?.excerpt),
16876
- wordCount: countCjkAwareWords(text),
16877
- diagnostics,
16878
- warnings: fallback && fallbackDetail ? [`Defuddle extraction fell back to Readability: ${fallbackDetail}`] : []
16879
- };
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
+ }
16880
16895
  }
16881
16896
  function defaultParseDefuddle(document, options) {
16882
16897
  return new Defuddle(document, options).parse();
@@ -16884,13 +16899,13 @@ function defaultParseDefuddle(document, options) {
16884
16899
  function extractMainContent(fetchResult, extractor = "defuddle", dependencies = {}) {
16885
16900
  if (extractor === "readability") return readabilityResult(fetchResult, extractor);
16886
16901
  try {
16887
- const { document } = parseHTML(fetchResult.html);
16902
+ const document = parseDocument$1(fetchResult.html);
16888
16903
  resolveDocumentUrls(document, fetchResult.finalUrl);
16889
16904
  const article = (dependencies.parseDefuddle ?? defaultParseDefuddle)(document, {
16890
16905
  url: fetchResult.finalUrl,
16891
16906
  useAsync: false
16892
16907
  });
16893
- 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 ?? "";
16894
16909
  if (!text.trim()) return readabilityResult(fetchResult, extractor, "defuddle_empty", "Defuddle returned no main content");
16895
16910
  const reportedWordCount = article.wordCount;
16896
16911
  const hasValidWordCount = typeof reportedWordCount === "number" && Number.isFinite(reportedWordCount) && reportedWordCount >= 0;
@@ -16913,6 +16928,28 @@ function extractMainContent(fetchResult, extractor = "defuddle", dependencies =
16913
16928
  return readabilityResult(fetchResult, extractor, "defuddle_error", readableError(error));
16914
16929
  }
16915
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
+ }
16916
16953
  //#endregion
16917
16954
  //#region packages/core/dist/extract/google-rich-results.js
16918
16955
  const SUPPORTED_TYPES = /* @__PURE__ */ new Set([
@@ -20688,7 +20725,7 @@ const QUERY_FLAG_METHOD = {
20688
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."
20689
20726
  }
20690
20727
  };
20691
- const FUNCTION_WORDS = /* @__PURE__ */ new Set([
20728
+ const FUNCTION_WORDS$1 = /* @__PURE__ */ new Set([
20692
20729
  "a",
20693
20730
  "about",
20694
20731
  "an",
@@ -20753,7 +20790,7 @@ function isLikelyAutomatedQuery(query) {
20753
20790
  if (query.trim().split(/\s+/).filter(Boolean).some((token) => IDENTIFIER_TOKEN.test(token))) return true;
20754
20791
  const tokens = tokenize(query);
20755
20792
  if (tokens.length >= 20) return true;
20756
- 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;
20757
20794
  return false;
20758
20795
  }
20759
20796
  const FILE_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -27692,7 +27729,7 @@ function analyzeCtrUnderperformersFromRows(input) {
27692
27729
  //#endregion
27693
27730
  //#region packages/core/dist/analyze/ctr-underperformers-report.js
27694
27731
  const MAX_GSC_ROWS$3 = 1e5;
27695
- const DEFAULT_DAYS$3 = 28;
27732
+ const DEFAULT_DAYS$4 = 28;
27696
27733
  const MAX_DAYS$2 = 548;
27697
27734
  function plural$4(count, singular, pluralLabel = `${singular}s`) {
27698
27735
  return count === 1 ? singular : pluralLabel;
@@ -27715,7 +27752,7 @@ async function ctrUnderperformersReportWith(input, dependencies) {
27715
27752
  const explicitRange = explicitDateRange(input, MAX_DAYS$2);
27716
27753
  const days = explicitRange?.days ?? integerOption$1({
27717
27754
  value: input.days,
27718
- fallback: DEFAULT_DAYS$3,
27755
+ fallback: DEFAULT_DAYS$4,
27719
27756
  minimum: 1,
27720
27757
  maximum: MAX_DAYS$2,
27721
27758
  label: "Days"
@@ -30920,7 +30957,7 @@ function quickWinTemplateSummaries(items) {
30920
30957
  }
30921
30958
  //#endregion
30922
30959
  //#region packages/core/dist/analyze/site-diagnostics/quick-wins-report.js
30923
- const DEFAULT_DAYS$2 = 28;
30960
+ const DEFAULT_DAYS$3 = 28;
30924
30961
  const MAX_DAYS$1 = 548;
30925
30962
  const DEFAULT_VERIFY_LIMIT$1 = 5;
30926
30963
  const MAX_VERIFY_LIMIT = 100;
@@ -30942,7 +30979,7 @@ async function quickWinsReportWith(input, dependencies) {
30942
30979
  const explicitRange = explicitDateRange(input, MAX_DAYS$1);
30943
30980
  const days = explicitRange?.days ?? integerOption$1({
30944
30981
  value: input.days,
30945
- fallback: DEFAULT_DAYS$2,
30982
+ fallback: DEFAULT_DAYS$3,
30946
30983
  minimum: 1,
30947
30984
  maximum: MAX_DAYS$1,
30948
30985
  label: "Days"
@@ -31366,7 +31403,7 @@ function analyzeStrikingDistanceRows(input) {
31366
31403
  }
31367
31404
  //#endregion
31368
31405
  //#region packages/core/dist/analyze/striking-distance-report.js
31369
- const DEFAULT_DAYS$1 = 28;
31406
+ const DEFAULT_DAYS$2 = 28;
31370
31407
  const MAX_DAYS = 548;
31371
31408
  const DEFAULT_VERIFY_LIMIT = 5;
31372
31409
  const MAX_SOURCE_ROWS$1 = 1e5;
@@ -31375,7 +31412,7 @@ function boundedInteger$2(value, fallback, minimum, maximum) {
31375
31412
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
31376
31413
  }
31377
31414
  function reportDays(value) {
31378
- if (value === void 0) return DEFAULT_DAYS$1;
31415
+ if (value === void 0) return DEFAULT_DAYS$2;
31379
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}.`);
31380
31417
  return value;
31381
31418
  }
@@ -32815,20 +32852,394 @@ function analyzeInternalLinksFromRows(input) {
32815
32852
  }
32816
32853
  };
32817
32854
  }
32818
- //#endregion
32819
- //#region packages/core/dist/analyze/internal-links-report.js
32820
- function internalLinksIntegerOption(input) {
32821
- const value = input.value ?? input.fallback;
32822
- 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}.`);
32823
- 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
+ }
32824
32947
  }
32825
- function internalLinksNumberOption(input) {
32826
- const value = input.value ?? input.fallback;
32827
- 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}.`);
32828
- return value;
32948
+ function siteName(url) {
32949
+ try {
32950
+ return new URL(url).hostname.replace(/^www\./u, "").split(".")[0] ?? "";
32951
+ } catch {
32952
+ return "";
32953
+ }
32829
32954
  }
32830
- function internalLinksReportRange(days, now) {
32831
- 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
+ };
32832
33243
  }
32833
33244
  function completeInternalLinksSelection(analysis, runtime) {
32834
33245
  return {
@@ -32886,9 +33297,8 @@ async function verifyInternalLinkTarget(input) {
32886
33297
  let fetched;
32887
33298
  try {
32888
33299
  fetched = await input.dependencies.fetch(input.targetUrl, {
32889
- js: input.js ?? "auto",
32890
- refresh: input.refresh,
32891
- rate: input.rate
33300
+ js: "auto",
33301
+ refresh: input.refresh
32892
33302
  });
32893
33303
  } catch (error) {
32894
33304
  return {
@@ -32958,7 +33368,12 @@ async function verifyInternalLinkTarget(input) {
32958
33368
  verification: fatalSignals.length ? "technical-issue" : "verified",
32959
33369
  technicalSignals: signals,
32960
33370
  fetchDiagnostics: fetched.diagnostics,
32961
- warnings
33371
+ warnings,
33372
+ text: placementPage({
33373
+ ...page,
33374
+ url: page.finalUrl,
33375
+ html: fetched.html
33376
+ })
32962
33377
  };
32963
33378
  }
32964
33379
  function linksToAliases(page, aliases) {
@@ -32992,9 +33407,8 @@ async function verifyInternalLinkCandidate(input) {
32992
33407
  let fetched;
32993
33408
  try {
32994
33409
  fetched = await input.dependencies.fetch(input.candidate.sourceUrl, {
32995
- js: input.js ?? "auto",
32996
- refresh: input.refresh,
32997
- rate: input.rate
33410
+ js: "auto",
33411
+ refresh: input.refresh
32998
33412
  });
32999
33413
  } catch (error) {
33000
33414
  return {
@@ -33084,12 +33498,21 @@ async function verifyInternalLinkCandidate(input) {
33084
33498
  confidence
33085
33499
  }
33086
33500
  },
33501
+ text: placementPage({
33502
+ ...page,
33503
+ url: page.finalUrl,
33504
+ html: fetched.html
33505
+ }),
33087
33506
  warnings
33088
33507
  };
33089
33508
  }
33090
33509
  //#endregion
33091
33510
  //#region packages/core/dist/analyze/internal-links.js
33092
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;
33093
33516
  const defaultDependencies$2 = {
33094
33517
  searchAnalytics: querySearchAnalytics,
33095
33518
  fetch: fetchPage,
@@ -33100,43 +33523,17 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33100
33523
  const targetPage = new URL(assertUrlMatchesGscProperty(input.site, input.targetUrl));
33101
33524
  targetPage.hash = "";
33102
33525
  const targetUrl = targetPage.toString();
33103
- const days = internalLinksIntegerOption({
33104
- value: input.days,
33105
- fallback: 28,
33106
- minimum: 1,
33107
- maximum: 548,
33108
- label: "days"
33109
- });
33110
- const limit = internalLinksIntegerOption({
33111
- value: input.limit,
33112
- fallback: 20,
33113
- minimum: 1,
33114
- maximum: 100,
33115
- label: "limit"
33116
- });
33117
- const checkLimit = internalLinksIntegerOption({
33118
- value: input.checkLimit,
33119
- fallback: Math.max(limit, 40),
33120
- minimum: 1,
33121
- maximum: 200,
33122
- label: "checkLimit"
33123
- });
33124
- const minImpressions = internalLinksNumberOption({
33125
- value: input.minImpressions,
33126
- fallback: 1,
33127
- minimum: 0,
33128
- maximum: 1e9,
33129
- label: "minImpressions"
33130
- });
33526
+ const limit = RESULT_LIMIT;
33527
+ const checkLimit = CHECK_LIMIT;
33528
+ const minImpressions = MIN_IMPRESSIONS;
33131
33529
  const now = dependencies.now();
33132
- const range = internalLinksReportRange(days, now);
33530
+ const { days, ...range } = internalLinksReportRange(input, DEFAULT_DAYS$1, now);
33531
+ const brandTerms = deriveBrandTerms({ siteUrl: input.site });
33133
33532
  const ledger = new SessionLedger();
33134
33533
  const target = await verifyInternalLinkTarget({
33135
33534
  site: input.site,
33136
33535
  targetUrl,
33137
- js: input.js,
33138
33536
  refresh: input.refresh,
33139
- rate: input.rate,
33140
33537
  dependencies
33141
33538
  });
33142
33539
  const targetRequests = await Promise.all(target.aliases.map(async (alias) => {
@@ -33168,8 +33565,7 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33168
33565
  site: input.site,
33169
33566
  targetAliases: target.aliases,
33170
33567
  minImpressions,
33171
- brandTerms: input.brandTerms,
33172
- includeBrand: input.includeBrand
33568
+ brandTerms
33173
33569
  });
33174
33570
  const queryCandidates = target.verification === "verified" && preliminary.selection.targetEligibleQueries > 0;
33175
33571
  const sourceResult = queryCandidates ? await dependencies.searchAnalytics(input.site, {
@@ -33190,10 +33586,9 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33190
33586
  site: input.site,
33191
33587
  targetAliases: target.aliases,
33192
33588
  minImpressions,
33193
- brandTerms: input.brandTerms,
33194
- includeBrand: input.includeBrand
33589
+ brandTerms
33195
33590
  });
33196
- const items = [];
33591
+ const found = [];
33197
33592
  const warnings = [...target.warnings];
33198
33593
  let attemptedSources = 0;
33199
33594
  let checkedSources = 0;
@@ -33202,25 +33597,27 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33202
33597
  let selfAliasExclusions = 0;
33203
33598
  let failedChecks = 0;
33204
33599
  for (const candidate of analysis.candidates) {
33205
- if (attemptedSources >= checkLimit || items.length >= limit) break;
33600
+ if (attemptedSources >= checkLimit || found.length >= limit) break;
33206
33601
  attemptedSources += 1;
33207
33602
  const verified = await verifyInternalLinkCandidate({
33208
33603
  site: input.site,
33209
33604
  candidate,
33210
33605
  target,
33211
- js: input.js,
33212
33606
  refresh: input.refresh,
33213
- rate: input.rate,
33214
33607
  dependencies
33215
33608
  });
33216
33609
  warnings.push(...verified.warnings);
33217
- if (verified.item) items.push(verified.item);
33610
+ if (verified.item) found.push({
33611
+ item: verified.item,
33612
+ text: verified.text
33613
+ });
33218
33614
  if (verified.exclusion === "existing-link") existingLinkExclusions += 1;
33219
33615
  if (verified.exclusion === "technical") technicalExclusions += 1;
33220
33616
  if (verified.exclusion === "self-alias") selfAliasExclusions += 1;
33221
33617
  if (verified.exclusion === "failed") failedChecks += 1;
33222
33618
  else checkedSources += 1;
33223
33619
  }
33620
+ const items = placeLinks(found, target.text);
33224
33621
  const uncheckedCandidates = Math.max(0, analysis.candidates.length - attemptedSources);
33225
33622
  const targetTruncated = targetRequests.some(({ result }) => result.rowsFetched >= MAX_GSC_ROWS);
33226
33623
  const sourceTruncated = sourceResult.rowsFetched >= MAX_GSC_ROWS;
@@ -33282,13 +33679,14 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33282
33679
  },
33283
33680
  methodology: {
33284
33681
  id: "gsc_internal_link_candidates",
33285
- version: 3,
33682
+ version: 4,
33286
33683
  lexicalTargetLimit: 50,
33287
33684
  matchedQueryEvidenceLimit: 10,
33288
33685
  observedLinkEvidenceLimit: 20,
33289
33686
  matching: "pairwise_exact_then_precision_lexical",
33290
33687
  ranking: "exact_matches_then_matched_query_impressions_then_relevance",
33291
- contextualPlacementVerified: true
33688
+ contextualPlacementVerified: true,
33689
+ placement: "tfidf_sentence_match_then_phrase_anchors"
33292
33690
  },
33293
33691
  filters: {
33294
33692
  minImpressions,
@@ -33320,18 +33718,20 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33320
33718
  failedChecks,
33321
33719
  uncheckedCandidates,
33322
33720
  matchedQueryImpressions: items.reduce((sum, item) => sum + item.matchedQueryImpressions, 0),
33323
- brandFiltering: input.includeBrand ? "included" : "excluded",
33721
+ brandFiltering: "excluded",
33324
33722
  verdict: summaryVerdict
33325
33723
  },
33326
33724
  items,
33327
33725
  warnings: structuredWarnings,
33328
33726
  caveats: [
33329
- `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.`,
33330
33729
  `Selection: ${analysis.selection.targetEligibleQueries} eligible target queries, ${analysis.selection.candidateUrls} matched source pages, ${attemptedSources} attempted, ${checkedSources} successfully checked, ${items.length} returned.`,
33331
- `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"}.`,
33332
33731
  "GSC query rows omit anonymized queries and lower-volume rows. A 100,000-row cap is reported separately when reached.",
33333
33732
  "Exact query overlap is medium-confidence affinity evidence; lexical matches are low-confidence review evidence. Neither predicts traffic lift or link equity.",
33334
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.",
33335
33735
  uncheckedCandidates ? `${uncheckedCandidates} matched source page${uncheckedCandidates === 1 ? " was" : "s were"} not checked because the output or check limit was reached.` : "",
33336
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.` : ""
33337
33737
  ].filter(Boolean),
@@ -33339,6 +33739,29 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
33339
33739
  ledgerSummary: ledger.summary()
33340
33740
  };
33341
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
+ }
33342
33765
  //#endregion
33343
33766
  //#region packages/core/dist/analyze/url-templates.js
33344
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;
@@ -37164,7 +37587,8 @@ async function runAnalyticsRows(input, google, options) {
37164
37587
  //#endregion
37165
37588
  //#region packages/core/dist/commands/bing.js
37166
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."];
37167
- 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\`.`;
37168
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`.";
37169
37593
  const BING_HOME = "https://www.bing.com/webmasters";
37170
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.`;
@@ -38162,10 +38586,32 @@ async function research(provider, headers, input) {
38162
38586
  function countryName(code) {
38163
38587
  return new Intl.DisplayNames("en", { type: "region" }).of(code.toUpperCase()) ?? "United States";
38164
38588
  }
38589
+ function mainLanguage(country) {
38590
+ try {
38591
+ return new Intl.Locale(`und-${country.toUpperCase()}`).maximize().language;
38592
+ } catch {
38593
+ return "en";
38594
+ }
38595
+ }
38596
+ const LABS_LANGUAGES = new Map(`
38597
+ ae:ar al:sq am:hy ao:pt ar:es at:de au:en az:az ba:bs bd:bn be:nl bf:fr
38598
+ bg:bg bh:ar bo:es br:pt ca:en ch:de ci:fr cl:es cm:fr co:es cr:es cy:el
38599
+ cz:cs de:de dk:da dz:ar ec:es ee:et eg:ar es:es fi:fi fr:fr gb:en gh:en
38600
+ gr:el gt:es hk:en hr:hr hu:hu id:id ie:en il:he in:en it:it jo:ar jp:ja
38601
+ ke:en kh:en kr:ko kz:ru lk:en lt:lt lv:lv ma:ar mc:fr md:ro mk:mk mm:en
38602
+ mt:en mx:es my:en ng:en ni:es nl:nl no:nb nz:en pa:es pe:es ph:en pk:en
38603
+ pl:pl pt:pt py:es ro:ro rs:sr sa:ar se:sv sg:en si:sl sk:sk sn:fr sv:es
38604
+ th:th tn:ar tr:tr tw:zh-TW ua:uk us:en uy:es ve:es vn:vi za:en
38605
+ `.trim().split(/\s+/).map((pair) => pair.split(":")));
38606
+ function dataForSeoLanguage(country) {
38607
+ return LABS_LANGUAGES.get(country.toLowerCase()) ?? null;
38608
+ }
38165
38609
  async function dataForSeo([login, password], input) {
38610
+ const language = dataForSeoLanguage(input.country);
38611
+ if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
38166
38612
  const market = {
38167
38613
  location_name: countryName(input.country),
38168
- language_code: input.language
38614
+ language_code: language ?? mainLanguage(input.country)
38169
38615
  };
38170
38616
  const post = async (path, task) => {
38171
38617
  const result = (await (await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
@@ -38374,12 +38820,12 @@ async function runResearch(input, keys, options) {
38374
38820
  const country = input.country ?? "us";
38375
38821
  const target = input.target ?? "";
38376
38822
  const offset = input.offset ?? 0;
38823
+ const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
38377
38824
  const found = await research(input.provider, keys, {
38378
38825
  action: input.action,
38379
38826
  keywords: input.keywords ?? [],
38380
38827
  target,
38381
38828
  country,
38382
- language: "en",
38383
38829
  limit: Math.min(1e3, Math.max(100, offset + 50))
38384
38830
  });
38385
38831
  const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
@@ -38392,7 +38838,7 @@ async function runResearch(input, keys, options) {
38392
38838
  const hasTargetDr = targetDr !== void 0 && targetDr !== null;
38393
38839
  return {
38394
38840
  text: rowsText({
38395
- title: `${input.provider} ${input.action}${target ? ` for ${target}` : ""}, ${country}.`,
38841
+ title: `${input.provider} ${input.action}${target ? ` for ${target}` : ""}, ${country}${language ? `, language ${language}` : ""}.`,
38396
38842
  rows: rated.rows,
38397
38843
  offset,
38398
38844
  limit: 50,
@@ -38410,6 +38856,7 @@ async function runResearch(input, keys, options) {
38410
38856
  action: input.action,
38411
38857
  target: target || null,
38412
38858
  country,
38859
+ language,
38413
38860
  targetDomainRating: hasTargetDr ? targetDr : null,
38414
38861
  attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
38415
38862
  leftOut: note,
@@ -38450,11 +38897,13 @@ async function runPage(input, deps) {
38450
38897
  }]
38451
38898
  }]
38452
38899
  });
38453
- const [[facts], indexed, now, then] = await Promise.all([
38900
+ const full = input.detail === "full";
38901
+ const [[facts], indexed, now, then, markdown] = await Promise.all([
38454
38902
  deps.checkPages([input.url]),
38455
38903
  deps.inspect(input.site, input.url),
38456
38904
  query(range),
38457
- query(before)
38905
+ query(before),
38906
+ full && deps.pageMarkdown ? deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })) : null
38458
38907
  ]);
38459
38908
  const old = new Map(then.rows.map((row) => [row.keys[0], row]));
38460
38909
  const seen = /* @__PURE__ */ new Set();
@@ -38489,23 +38938,32 @@ async function runPage(input, deps) {
38489
38938
  const sum = (list, key) => list.reduce((total, row) => total + row[key], 0);
38490
38939
  const offset = input.offset ?? 0;
38491
38940
  return {
38492
- text: `${rowsText({
38493
- title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
38494
- notes: [
38495
- `Live page (fetched now, no redirects followed):\n${facts ? pageFactsTable([facts], null) : "Not fetched."}`,
38496
- indexed ? `Google index status:\n${table([indexed], Object.keys(indexed), null)}` : "Google index status: not available for this URL.",
38497
- `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.`
38498
- ],
38499
- rows,
38500
- offset,
38501
- limit: 50,
38502
- detail: input.detail ?? "summary",
38503
- more: (next) => deps.call("page", {
38504
- url: input.url,
38505
- detail: "rows",
38506
- offset: next
38507
- })
38508
- })}\n\nNext: ${deps.call("research")} serp with the top query to see who else ranks.\n\n${FACTS_ONLY}`,
38941
+ text: [
38942
+ rowsText({
38943
+ title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
38944
+ notes: [
38945
+ `Live page (fetched now, no redirects followed):\n${facts ? pageFactsTable([facts], null) : "Not fetched."}`,
38946
+ indexed ? `Google index status:\n${table([indexed], Object.keys(indexed), null)}` : "Google index status: not available for this URL.",
38947
+ `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.`,
38948
+ deps.pageMarkdown ? `Main content as Markdown: ${deps.call("page", {
38949
+ url: input.url,
38950
+ detail: "full"
38951
+ })}.` : `Main content as Markdown: only in the CLI and the local MCP server, with \`npx -y seo page --url ${input.url} --detail full\`.`
38952
+ ],
38953
+ rows,
38954
+ offset,
38955
+ limit: 50,
38956
+ detail: input.detail ?? "summary",
38957
+ more: (next) => deps.call("page", {
38958
+ url: input.url,
38959
+ detail: "rows",
38960
+ offset: next
38961
+ })
38962
+ }),
38963
+ ...full ? [markdownText(markdown, input.url)] : [],
38964
+ `Next: ${deps.call("research")} serp with the top query to see who else ranks.`,
38965
+ FACTS_ONLY
38966
+ ].join("\n\n"),
38509
38967
  data: {
38510
38968
  url: input.url,
38511
38969
  range,
@@ -38513,10 +38971,17 @@ async function runPage(input, deps) {
38513
38971
  page: facts ?? null,
38514
38972
  index: indexed,
38515
38973
  totalQueries: rows.length,
38516
- queries: rows.slice(offset, offset + 50)
38974
+ queries: rows.slice(offset, offset + 50),
38975
+ ...full ? { markdown } : {}
38517
38976
  }
38518
38977
  };
38519
38978
  }
38979
+ function markdownText(markdown, url) {
38980
+ 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\`.`;
38981
+ if ("error" in markdown) return `Main content as Markdown: the page could not be read (${markdown.error}).`;
38982
+ if (!markdown.markdown) return "Main content as Markdown: the page has no main content to read.";
38983
+ 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}`;
38984
+ }
38520
38985
  async function runCompare(input, deps) {
38521
38986
  if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date)) throw new CommandError("INVALID_INPUT", "The date must be YYYY-MM-DD.");
38522
38987
  const last = latestFinalGscDate(deps.now?.() ?? /* @__PURE__ */ new Date());
@@ -39082,6 +39547,7 @@ function nodeCommandDeps(options) {
39082
39547
  return null;
39083
39548
  }
39084
39549
  },
39550
+ pageMarkdown: async (url) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh })),
39085
39551
  call: options.call,
39086
39552
  bing: localBing,
39087
39553
  bingConnect: LOCAL_BING_CONNECT
@@ -41116,46 +41582,18 @@ function registerOpportunityTools(server, dependencies = {}) {
41116
41582
  server.registerTool("seo_internal_links", {
41117
41583
  description: "Find verified internal-link review candidates for a target URL",
41118
41584
  inputSchema: {
41119
- ...mcpReportInputSchema([
41120
- "site",
41121
- "days",
41122
- "limit",
41123
- "checkLimit",
41124
- "minImpressions",
41125
- "includeBrand",
41126
- "js",
41127
- "fetchConcurrency",
41128
- "fetchIntervalCap",
41129
- "fetchIntervalMs",
41130
- "refresh"
41131
- ]),
41585
+ ...mcpReportInputSchema(["site", "refresh"]),
41132
41586
  targetUrl: z$1.string().url().refine((value) => /^https?:\/\//.test(value), "Use an HTTP(S) URL."),
41133
- days: z$1.number().int().min(1).max(548).optional(),
41134
- limit: z$1.number().int().min(1).max(100).optional(),
41135
- checkLimit: z$1.number().int().min(1).max(200).optional(),
41136
- minImpressions: z$1.number().min(0).max(1e9).optional(),
41137
- brandTerms: z$1.array(z$1.string().trim().min(1).max(200)).max(20).optional(),
41138
- fetchConcurrency: z$1.number().int().min(1).max(16).optional(),
41139
- fetchIntervalCap: z$1.number().int().min(1).max(60).optional(),
41140
- fetchIntervalMs: z$1.number().int().min(100).max(6e4).optional()
41587
+ startDate: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
41588
+ endDate: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
41141
41589
  }
41142
- }, async ({ site, targetUrl, days, limit, checkLimit, minImpressions, brandTerms, includeBrand, js, fetchConcurrency, fetchIntervalCap, fetchIntervalMs, refresh }) => {
41590
+ }, async ({ site, targetUrl, startDate, endDate, refresh }) => {
41143
41591
  try {
41144
41592
  const result = await (dependencies.internalLinksReport ?? internalLinksReport)({
41145
41593
  site,
41146
41594
  targetUrl,
41147
- days,
41148
- limit,
41149
- checkLimit,
41150
- minImpressions,
41151
- brandTerms,
41152
- includeBrand,
41153
- js: resolveJsOption(js, void 0),
41154
- rate: fetchRateInput({
41155
- fetchConcurrency,
41156
- fetchIntervalCap,
41157
- fetchIntervalMs
41158
- }),
41595
+ startDate,
41596
+ endDate,
41159
41597
  refresh
41160
41598
  });
41161
41599
  return toolSuccess(result.summary.verdict, result);
@@ -41464,12 +41902,14 @@ const REPORT_DEPTH = {
41464
41902
  readOrder: [
41465
41903
  "dataStatus, target verification, aliases, canonical, and technical signals",
41466
41904
  "source completeness and selection attempted, checked, failed, unchecked, and exclusions",
41467
- "per-item exact versus lexical matches and linkEvidence"
41905
+ "per-item exact versus lexical matches and linkEvidence",
41906
+ "per-item placement: the sentence where a link fits, its anchors, and contentSimilarity"
41468
41907
  ],
41469
41908
  doNotClaim: [
41470
- "A low checkLimit can leave plausible candidates unchecked, so absence is not proven.",
41909
+ "The live check stops at 40 source pages, so unchecked candidates can remain and absence is not proven.",
41471
41910
  "Query overlap does not prove a link belongs or that adding one improves rankings.",
41472
- "Priority is a heuristic, not estimated impact."
41911
+ "Priority is a heuristic, not estimated impact.",
41912
+ "Placement sentences, anchors and contentSimilarity are text heuristics; they do not prove the link helps a reader."
41473
41913
  ],
41474
41914
  verify: "Read the source passage, add one contextual link if the target is genuinely useful, and re-crawl to confirm it.",
41475
41915
  related: [
@@ -42668,14 +43108,20 @@ function registerSharedTools(server, options = {}) {
42668
43108
  })));
42669
43109
  server.registerTool("seo_internal_links", {
42670
43110
  title: "Internal link opportunities",
42671
- description: "Pages of the same site that could link to a target page, from Search Console and live page checks on this machine.",
42672
- inputSchema: { url: z$1.url() },
43111
+ 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).",
43112
+ inputSchema: {
43113
+ url: z$1.url(),
43114
+ start_date: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD. Optional; send with end_date."),
43115
+ end_date: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD. Optional; send with start_date.")
43116
+ },
42673
43117
  annotations: readOnly
42674
- }, async ({ url }) => counted("internal-links", {}, options.telemetry, async () => {
43118
+ }, async ({ url, start_date, end_date }) => counted("internal-links", {}, options.telemetry, async () => {
42675
43119
  try {
42676
43120
  return await executeReport("internal-links", {
42677
43121
  site: await resolveLocalSite(url),
42678
- targetUrl: url
43122
+ targetUrl: url,
43123
+ startDate: start_date,
43124
+ endDate: end_date
42679
43125
  });
42680
43126
  } catch (error) {
42681
43127
  return toolError(localError(error));