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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as load, t as version } from "./package-CwLtPH6B.js";
2
+ import { n as load, t as version } from "./package-BaQwwfSG.js";
3
3
  import { SEO_PROVIDER_API_VERSION } from "./provider-sdk.js";
4
4
  import crypto, { createHash, randomUUID, sign } from "node:crypto";
5
5
  import { z } from "zod";
@@ -4694,7 +4694,7 @@ function extractCanonicalEvidence($, headers, baseUrl) {
4694
4694
  //#region packages/core/src/extract/content-sketch.ts
4695
4695
  const SHINGLE_SIZE = 5;
4696
4696
  const MAX_HASHES = 32;
4697
- function words(value) {
4697
+ function words$1(value) {
4698
4698
  return value.normalize("NFKC").toLocaleLowerCase("en").match(/[\p{L}\p{N}]+/gu) ?? [];
4699
4699
  }
4700
4700
  function sampleIndexes(total, limit) {
@@ -4705,7 +4705,7 @@ function digest(value) {
4705
4705
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
4706
4706
  }
4707
4707
  function contentSketch(value) {
4708
- const tokens = words(value);
4708
+ const tokens = words$1(value);
4709
4709
  const hashes = sampleIndexes(Math.max(0, tokens.length - SHINGLE_SIZE + 1), MAX_HASHES).map((index) => digest(tokens.slice(index, index + SHINGLE_SIZE).join(" ")));
4710
4710
  return {
4711
4711
  version: 1,
@@ -4717,6 +4717,11 @@ function contentSketch(value) {
4717
4717
  }
4718
4718
  //#endregion
4719
4719
  //#region packages/core/src/extract/main-content.ts
4720
+ function parseDocument$1(html) {
4721
+ const { document } = parseHTML(html);
4722
+ if (document.documentElement?.localName === "html") return document;
4723
+ return parseHTML(`<!doctype html><html><head></head><body>${html}</body></html>`).document;
4724
+ }
4720
4725
  function readableError(error) {
4721
4726
  return error instanceof Error ? error.message : String(error);
4722
4727
  }
@@ -4746,10 +4751,6 @@ function resolveDocumentUrls(document, baseUrl) {
4746
4751
  }
4747
4752
  }
4748
4753
  function readabilityResult(fetchResult, requested, fallbackReason, fallbackDetail) {
4749
- const { document } = parseHTML(fetchResult.html);
4750
- const bodyText = document.body.textContent ?? "";
4751
- const article = new Readability(document).parse();
4752
- const text = article?.textContent ?? bodyText;
4753
4754
  const fallback = requested !== "readability";
4754
4755
  const diagnostics = {
4755
4756
  requested,
@@ -4762,13 +4763,27 @@ function readabilityResult(fetchResult, requested, fallbackReason, fallbackDetai
4762
4763
  fallbackDetail
4763
4764
  } : {}
4764
4765
  };
4765
- return {
4766
- text,
4767
- excerpt: normalizedText(article?.excerpt),
4768
- wordCount: countCjkAwareWords(text),
4769
- diagnostics,
4770
- warnings: fallback && fallbackDetail ? [`Defuddle extraction fell back to Readability: ${fallbackDetail}`] : []
4771
- };
4766
+ const warnings = fallback && fallbackDetail ? [`Defuddle extraction fell back to Readability: ${fallbackDetail}`] : [];
4767
+ try {
4768
+ const document = parseDocument$1(fetchResult.html);
4769
+ const bodyText = document.body?.textContent ?? "";
4770
+ const article = new Readability(document).parse();
4771
+ const text = article?.textContent ?? bodyText;
4772
+ return {
4773
+ text,
4774
+ excerpt: normalizedText(article?.excerpt),
4775
+ wordCount: countCjkAwareWords(text),
4776
+ diagnostics,
4777
+ warnings
4778
+ };
4779
+ } catch (error) {
4780
+ return {
4781
+ text: "",
4782
+ wordCount: 0,
4783
+ diagnostics,
4784
+ warnings: [...warnings, `The page's main content could not be read: ${readableError(error)}`]
4785
+ };
4786
+ }
4772
4787
  }
4773
4788
  function defaultParseDefuddle(document, options) {
4774
4789
  return new Defuddle(document, options).parse();
@@ -4776,13 +4791,13 @@ function defaultParseDefuddle(document, options) {
4776
4791
  function extractMainContent(fetchResult, extractor = "defuddle", dependencies = {}) {
4777
4792
  if (extractor === "readability") return readabilityResult(fetchResult, extractor);
4778
4793
  try {
4779
- const { document } = parseHTML(fetchResult.html);
4794
+ const document = parseDocument$1(fetchResult.html);
4780
4795
  resolveDocumentUrls(document, fetchResult.finalUrl);
4781
4796
  const article = (dependencies.parseDefuddle ?? defaultParseDefuddle)(document, {
4782
4797
  url: fetchResult.finalUrl,
4783
4798
  useAsync: false
4784
4799
  });
4785
- const text = typeof article.content === "string" ? load(article.content).text() : document.body.textContent ?? "";
4800
+ const text = typeof article.content === "string" ? load(article.content).text() : document.body?.textContent ?? "";
4786
4801
  if (!text.trim()) return readabilityResult(fetchResult, extractor, "defuddle_empty", "Defuddle returned no main content");
4787
4802
  const reportedWordCount = article.wordCount;
4788
4803
  const hasValidWordCount = typeof reportedWordCount === "number" && Number.isFinite(reportedWordCount) && reportedWordCount >= 0;
@@ -4805,6 +4820,28 @@ function extractMainContent(fetchResult, extractor = "defuddle", dependencies =
4805
4820
  return readabilityResult(fetchResult, extractor, "defuddle_error", readableError(error));
4806
4821
  }
4807
4822
  }
4823
+ const MARKDOWN_LIMIT = 2e4;
4824
+ async function extractMainMarkdown(fetchResult) {
4825
+ const document = parseDocument$1(fetchResult.html);
4826
+ resolveDocumentUrls(document, fetchResult.finalUrl);
4827
+ const { Defuddle: toMarkdown } = await import("defuddle/node");
4828
+ const markdown = ((await toMarkdown(document, fetchResult.finalUrl, {
4829
+ markdown: true,
4830
+ useAsync: false
4831
+ })).content ?? "").trim();
4832
+ if (markdown.length <= 2e4) return {
4833
+ markdown,
4834
+ characters: markdown.length,
4835
+ truncated: false
4836
+ };
4837
+ const cut = markdown.slice(0, MARKDOWN_LIMIT);
4838
+ const end = cut.lastIndexOf("\n");
4839
+ return {
4840
+ markdown: (end > 2e4 / 2 ? cut.slice(0, end) : cut).trimEnd(),
4841
+ characters: markdown.length,
4842
+ truncated: true
4843
+ };
4844
+ }
4808
4845
  //#endregion
4809
4846
  //#region packages/core/src/extract/google-rich-results.ts
4810
4847
  const SUPPORTED_TYPES = /* @__PURE__ */ new Set([
@@ -8639,7 +8676,7 @@ const QUERY_FLAG_METHOD = {
8639
8676
  "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."
8640
8677
  }
8641
8678
  };
8642
- const FUNCTION_WORDS = /* @__PURE__ */ new Set([
8679
+ const FUNCTION_WORDS$1 = /* @__PURE__ */ new Set([
8643
8680
  "a",
8644
8681
  "about",
8645
8682
  "an",
@@ -8704,7 +8741,7 @@ function isLikelyAutomatedQuery(query) {
8704
8741
  if (query.trim().split(/\s+/).filter(Boolean).some((token) => IDENTIFIER_TOKEN.test(token))) return true;
8705
8742
  const tokens = tokenize(query);
8706
8743
  if (tokens.length >= 20) return true;
8707
- if (tokens.length >= 8 && !/[^\p{ASCII}]/u.test(query) && !tokens.some((token) => FUNCTION_WORDS.has(token))) return true;
8744
+ if (tokens.length >= 8 && !/[^\p{ASCII}]/u.test(query) && !tokens.some((token) => FUNCTION_WORDS$1.has(token))) return true;
8708
8745
  return false;
8709
8746
  }
8710
8747
  const FILE_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -16682,7 +16719,7 @@ function analyzeCtrUnderperformersFromRows(input) {
16682
16719
  //#endregion
16683
16720
  //#region packages/core/src/analyze/ctr-underperformers-report.ts
16684
16721
  const MAX_GSC_ROWS$3 = 1e5;
16685
- const DEFAULT_DAYS$3 = 28;
16722
+ const DEFAULT_DAYS$4 = 28;
16686
16723
  const MAX_DAYS$2 = 548;
16687
16724
  function plural$4(count, singular, pluralLabel = `${singular}s`) {
16688
16725
  return count === 1 ? singular : pluralLabel;
@@ -16705,7 +16742,7 @@ async function ctrUnderperformersReportWith(input, dependencies) {
16705
16742
  const explicitRange = explicitDateRange(input, MAX_DAYS$2);
16706
16743
  const days = explicitRange?.days ?? integerOption$1({
16707
16744
  value: input.days,
16708
- fallback: DEFAULT_DAYS$3,
16745
+ fallback: DEFAULT_DAYS$4,
16709
16746
  minimum: 1,
16710
16747
  maximum: MAX_DAYS$2,
16711
16748
  label: "Days"
@@ -19962,7 +19999,7 @@ function quickWinTemplateSummaries(items) {
19962
19999
  }
19963
20000
  //#endregion
19964
20001
  //#region packages/core/src/analyze/site-diagnostics/quick-wins-report.ts
19965
- const DEFAULT_DAYS$2 = 28;
20002
+ const DEFAULT_DAYS$3 = 28;
19966
20003
  const MAX_DAYS$1 = 548;
19967
20004
  const DEFAULT_VERIFY_LIMIT$1 = 5;
19968
20005
  const MAX_VERIFY_LIMIT = 100;
@@ -19984,7 +20021,7 @@ async function quickWinsReportWith(input, dependencies) {
19984
20021
  const explicitRange = explicitDateRange(input, MAX_DAYS$1);
19985
20022
  const days = explicitRange?.days ?? integerOption$1({
19986
20023
  value: input.days,
19987
- fallback: DEFAULT_DAYS$2,
20024
+ fallback: DEFAULT_DAYS$3,
19988
20025
  minimum: 1,
19989
20026
  maximum: MAX_DAYS$1,
19990
20027
  label: "Days"
@@ -20408,7 +20445,7 @@ function analyzeStrikingDistanceRows(input) {
20408
20445
  }
20409
20446
  //#endregion
20410
20447
  //#region packages/core/src/analyze/striking-distance-report.ts
20411
- const DEFAULT_DAYS$1 = 28;
20448
+ const DEFAULT_DAYS$2 = 28;
20412
20449
  const MAX_DAYS = 548;
20413
20450
  const DEFAULT_VERIFY_LIMIT = 5;
20414
20451
  const MAX_SOURCE_ROWS$1 = 1e5;
@@ -20417,7 +20454,7 @@ function boundedInteger$2(value, fallback, minimum, maximum) {
20417
20454
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
20418
20455
  }
20419
20456
  function reportDays(value) {
20420
- if (value === void 0) return DEFAULT_DAYS$1;
20457
+ if (value === void 0) return DEFAULT_DAYS$2;
20421
20458
  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}.`);
20422
20459
  return value;
20423
20460
  }
@@ -21859,20 +21896,394 @@ function analyzeInternalLinksFromRows(input) {
21859
21896
  }
21860
21897
  };
21861
21898
  }
21862
- //#endregion
21863
- //#region packages/core/src/analyze/internal-links-report.ts
21864
- function internalLinksIntegerOption(input) {
21865
- const value = input.value ?? input.fallback;
21866
- 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}.`);
21867
- return value;
21899
+ const MAX_TERMS = 100;
21900
+ const PLACEMENT_SENTENCES = 2;
21901
+ const ANCHORS_PER_SENTENCE = 3;
21902
+ const MIN_SENTENCE_WORDS = 6;
21903
+ const NAME_WEIGHT = 2;
21904
+ 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;
21905
+ function normalise(value) {
21906
+ 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();
21907
+ }
21908
+ const words = (value) => normalise(value).split(" ").filter(Boolean);
21909
+ 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(" "));
21910
+ const NOT_PLURAL = /* @__PURE__ */ new Set([
21911
+ "always",
21912
+ "atlas",
21913
+ "canvas",
21914
+ "gas",
21915
+ "lens",
21916
+ "means",
21917
+ "news",
21918
+ "perhaps",
21919
+ "series",
21920
+ "species",
21921
+ "sometimes",
21922
+ "whereas",
21923
+ "yes",
21924
+ "this",
21925
+ "has",
21926
+ "was",
21927
+ "does",
21928
+ "its"
21929
+ ]);
21930
+ const CHE = /* @__PURE__ */ new Set([
21931
+ "caches",
21932
+ "niches",
21933
+ "headaches",
21934
+ "cliches",
21935
+ "avalanches",
21936
+ "moustaches"
21937
+ ]);
21938
+ const NOT_AGENT = /* @__PURE__ */ new Set([
21939
+ "customer",
21940
+ "together",
21941
+ "however",
21942
+ "whatever",
21943
+ "whenever",
21944
+ "wherever",
21945
+ "remember",
21946
+ "consider",
21947
+ "character",
21948
+ "register",
21949
+ "november",
21950
+ "december",
21951
+ "september",
21952
+ "october"
21953
+ ]);
21954
+ function singular(word) {
21955
+ if (word.length <= 3 || !/^[a-z]+$/.test(word) || NOT_PLURAL.has(word) || /(ss|us|is)$/.test(word)) return word;
21956
+ if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
21957
+ if (/(xes|ches|shes|sses)$/.test(word) && !CHE.has(word)) return word.slice(0, -2);
21958
+ return word.endsWith("s") ? word.slice(0, -1) : word;
21959
+ }
21960
+ function stem(word) {
21961
+ const one = singular(word);
21962
+ if (!/^[a-z]+$/.test(one)) return one;
21963
+ 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);
21964
+ if (one.length > 5 && one.endsWith("ly")) return one.slice(0, -2);
21965
+ if (one.length > 5 && one.endsWith("ed") && !one.endsWith("eed")) return one.slice(0, -2);
21966
+ if (one.length > 6 && one.endsWith("ing")) return one.slice(0, -3);
21967
+ return one;
21968
+ }
21969
+ const terms = (text) => words(text).filter((word) => word.length > 1 && !FUNCTION_WORDS.has(word) && !/^\d+$/.test(word)).map(stem);
21970
+ const slugWords = (url) => {
21971
+ try {
21972
+ return new URL(url).pathname.split(/[/._-]+/).filter(Boolean).join(" ");
21973
+ } catch {
21974
+ return "";
21975
+ }
21976
+ };
21977
+ function keyTerms(page) {
21978
+ const key = /* @__PURE__ */ new Map();
21979
+ const title = page.title.replace(/\s+[|\u2013\u2014-]\s+[^|\u2013\u2014]*$/u, "");
21980
+ for (const term of terms(page.description)) key.set(term, 1);
21981
+ for (const term of terms(`${title} ${page.h1} ${lastSegmentWords(page.url)}`)) key.set(term, NAME_WEIGHT);
21982
+ for (const term of terms(siteName(page.url))) key.delete(term);
21983
+ return key;
21984
+ }
21985
+ function lastSegmentWords(url) {
21986
+ try {
21987
+ return (new URL(url).pathname.split("/").filter(Boolean).at(-1) ?? "").split(/[._-]+/).join(" ");
21988
+ } catch {
21989
+ return "";
21990
+ }
21868
21991
  }
21869
- function internalLinksNumberOption(input) {
21870
- const value = input.value ?? input.fallback;
21871
- 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}.`);
21872
- return value;
21992
+ function siteName(url) {
21993
+ try {
21994
+ return new URL(url).hostname.replace(/^www\./u, "").split(".")[0] ?? "";
21995
+ } catch {
21996
+ return "";
21997
+ }
21873
21998
  }
21874
- function internalLinksReportRange(days, now) {
21875
- return finalGscDateRange(days, now);
21999
+ function termCounts(page) {
22000
+ const counts = {};
22001
+ const add = (text, weight) => {
22002
+ for (const term of terms(text)) counts[term] = (counts[term] ?? 0) + weight;
22003
+ };
22004
+ add(page.title, 3);
22005
+ add(page.h1, 3);
22006
+ add(slugWords(page.url), 3);
22007
+ add(page.description, 2);
22008
+ for (const heading of page.headings) add(heading, 2);
22009
+ for (const sentence of page.sentences) add(sentence.text, 1);
22010
+ return counts;
22011
+ }
22012
+ const SKIP = /* @__PURE__ */ new Set([
22013
+ "script",
22014
+ "style",
22015
+ "noscript",
22016
+ "svg",
22017
+ "template",
22018
+ "iframe",
22019
+ "select"
22020
+ ]);
22021
+ const CHROME = /* @__PURE__ */ new Set([
22022
+ "nav",
22023
+ "header",
22024
+ "footer",
22025
+ "aside"
22026
+ ]);
22027
+ const MAIN = /* @__PURE__ */ new Set(["main", "article"]);
22028
+ 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(" "));
22029
+ const VOID = /* @__PURE__ */ new Set([
22030
+ "br",
22031
+ "hr",
22032
+ "img",
22033
+ "input",
22034
+ "meta",
22035
+ "link",
22036
+ "wbr",
22037
+ "source",
22038
+ "col",
22039
+ "embed"
22040
+ ]);
22041
+ const ENTITIES = {
22042
+ amp: "&",
22043
+ lt: "<",
22044
+ gt: ">",
22045
+ quot: "\"",
22046
+ apos: "'",
22047
+ nbsp: " ",
22048
+ hellip: "…",
22049
+ mdash: "—",
22050
+ ndash: "–",
22051
+ rsquo: "’",
22052
+ lsquo: "‘",
22053
+ rdquo: "”",
22054
+ ldquo: "“"
22055
+ };
22056
+ const TAG_ATTRS = String.raw`(?:[^>"'=]|=\s*"[^"]*"|=\s*'[^']*'|=(?!\s*["'])|["'])*`;
22057
+ const A_OPEN = "";
22058
+ const UNSPACED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
22059
+ function decode(text) {
22060
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, code) => {
22061
+ if (code[0] === "#") {
22062
+ const hex = code[1] === "x" || code[1] === "X";
22063
+ const point = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
22064
+ return point > 0 && point <= 1114111 ? String.fromCodePoint(point) : "";
22065
+ }
22066
+ return ENTITIES[code.toLowerCase()] ?? match;
22067
+ });
22068
+ }
22069
+ const clean$1 = (value) => value.split(A_OPEN).join("").replace(/\s+/g, " ").trim();
22070
+ function splitSentences(block) {
22071
+ return block.replace(/\s+/g, " ").split(/(?<=[.!?…]["”’)]?)\s+(?=["“‘(]?[A-Z0-9])|(?<=[。!?])/).map((sentence) => sentence.trim()).filter((sentence) => sentence.length <= 500);
22072
+ }
22073
+ function pageSentences(html) {
22074
+ const sentences = [];
22075
+ const hasMain = /<(main|article)[\s>]|role=["']main["']/i.test(html);
22076
+ const stack = [];
22077
+ let skip = 0;
22078
+ let chrome = 0;
22079
+ let mainDepth = 0;
22080
+ let main = hasMain ? 0 : 1;
22081
+ let section = "";
22082
+ let buffer = "";
22083
+ let inAnchor = false;
22084
+ const flushRaw = (tag) => {
22085
+ const raw = buffer;
22086
+ const text = clean$1(raw);
22087
+ buffer = "";
22088
+ if (!text || sentences.length >= 250) return;
22089
+ if (/^h[1-6]$/.test(tag)) {
22090
+ if (tag !== "h1") section = text;
22091
+ return;
22092
+ }
22093
+ for (const part of tag === "li" ? [raw] : splitSentences(raw)) {
22094
+ const sentence = clean$1(part);
22095
+ if (sentence.length >= 25 && sentence.split(" ").length >= 4 || UNSPACED.test(sentence) && sentence.length >= 10) {
22096
+ if (sentences.length >= 250) return;
22097
+ sentences.push({
22098
+ text: sentence,
22099
+ section,
22100
+ hasLink: part.includes(A_OPEN)
22101
+ });
22102
+ }
22103
+ }
22104
+ };
22105
+ const closeTo = (tag) => {
22106
+ if (!stack.some((entry) => entry.tag === tag)) return;
22107
+ while (stack.length) {
22108
+ const entry = stack.pop();
22109
+ if (!entry) break;
22110
+ flushRaw(entry.tag);
22111
+ if (entry.main) {
22112
+ main -= 1;
22113
+ mainDepth -= 1;
22114
+ }
22115
+ if (entry.chrome) chrome -= 1;
22116
+ if (entry.tag === tag) break;
22117
+ }
22118
+ };
22119
+ const tokens = html.matchAll(new RegExp(String.raw`<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<(\/?)([a-zA-Z][\w:-]*)(${TAG_ATTRS})>|([^<]+)`, "g"));
22120
+ for (const match of tokens) {
22121
+ const [whole, close, rawTag, rawAttrs = "", text] = match;
22122
+ if (text !== void 0) {
22123
+ if (!skip && main > 0 && chrome === 0) buffer += decode(text);
22124
+ continue;
22125
+ }
22126
+ if (!rawTag) continue;
22127
+ const tag = rawTag.toLowerCase();
22128
+ if (skip) {
22129
+ if (close && SKIP.has(tag)) skip -= 1;
22130
+ continue;
22131
+ }
22132
+ if (close) {
22133
+ if (tag === "a") inAnchor = false;
22134
+ else if (BLOCK.has(tag)) closeTo(tag);
22135
+ continue;
22136
+ }
22137
+ if (SKIP.has(tag)) {
22138
+ if (!whole.endsWith("/>")) skip += 1;
22139
+ continue;
22140
+ }
22141
+ if (tag === "br") buffer += " ";
22142
+ if (tag === "a" && !inAnchor && /\bhref\s*=/i.test(rawAttrs)) {
22143
+ inAnchor = true;
22144
+ if (main > 0 && chrome === 0) buffer += A_OPEN;
22145
+ }
22146
+ if (VOID.has(tag) || !BLOCK.has(tag)) continue;
22147
+ const parent = stack[stack.length - 1];
22148
+ if (parent) flushRaw(parent.tag);
22149
+ const role = /\brole\s*=\s*["']?main/i.test(rawAttrs);
22150
+ const isMain = MAIN.has(tag) || role;
22151
+ const isChrome = tag === "nav" || tag === "aside" || CHROME.has(tag) && mainDepth === 0;
22152
+ if (isMain) {
22153
+ main += 1;
22154
+ mainDepth += 1;
22155
+ }
22156
+ if (isChrome) chrome += 1;
22157
+ stack.push({
22158
+ tag,
22159
+ main: isMain,
22160
+ chrome: isChrome
22161
+ });
22162
+ }
22163
+ while (stack.length) {
22164
+ const last = stack[stack.length - 1];
22165
+ if (!last) break;
22166
+ closeTo(last.tag);
22167
+ }
22168
+ flushRaw("");
22169
+ return sentences;
22170
+ }
22171
+ var LinkCorpus = class {
22172
+ df = /* @__PURE__ */ new Map();
22173
+ size;
22174
+ constructor(pages) {
22175
+ this.size = pages.length;
22176
+ for (const counts of pages) for (const term in counts) this.df.set(term, (this.df.get(term) ?? 0) + 1);
22177
+ }
22178
+ idf(term) {
22179
+ return Math.log((this.size + 1) / ((this.df.get(term) ?? 1) + 1)) + 1;
22180
+ }
22181
+ vector(counts) {
22182
+ let norm = 0;
22183
+ const shared = [];
22184
+ for (const term in counts) {
22185
+ const weight = (1 + Math.log(counts[term] ?? 1)) * this.idf(term);
22186
+ norm += weight * weight;
22187
+ if ((this.df.get(term) ?? 0) >= 2) shared.push([term, weight]);
22188
+ }
22189
+ norm = Math.sqrt(norm) || 1;
22190
+ shared.sort((left, right) => right[1] - left[1] || (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0));
22191
+ return new Map(shared.slice(0, MAX_TERMS).map(([term, weight]) => [term, weight / norm]));
22192
+ }
22193
+ similarity(left, right) {
22194
+ let sum = 0;
22195
+ for (const [term, weight] of left) sum += weight * (right.get(term) ?? 0);
22196
+ return sum;
22197
+ }
22198
+ sentenceScore(sentence, key) {
22199
+ if (!key.size) return 0;
22200
+ const seen = new Set(terms(sentence));
22201
+ let hit = 0;
22202
+ let total = 0;
22203
+ for (const [term, place] of key) {
22204
+ const weight = this.idf(term) * place;
22205
+ total += weight;
22206
+ if (seen.has(term)) hit += weight;
22207
+ }
22208
+ return total ? hit / total : 0;
22209
+ }
22210
+ };
22211
+ function anchorCandidates(sentence, key, corpus, limit) {
22212
+ const scored = /* @__PURE__ */ new Map();
22213
+ const parts = sentence.split(/\s+/).map((word) => word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "")).filter(Boolean);
22214
+ for (let size = 2; size <= 4; size += 1) for (let start = 0; start + size <= parts.length; start += 1) {
22215
+ const gram = parts.slice(start, start + size);
22216
+ const first = (gram[0] ?? "").toLowerCase();
22217
+ const last = (gram[size - 1] ?? "").toLowerCase();
22218
+ if (FUNCTION_WORDS.has(first) || FUNCTION_WORDS.has(last) || /^\d+$/.test(first) || /^\d+$/.test(last)) continue;
22219
+ const stems = terms(gram.join(" "));
22220
+ if (stems.length < 2) continue;
22221
+ const hits = stems.filter((term) => key.has(term)).length;
22222
+ const named = stems.reduce((sum, term) => sum + (key.get(term) ?? 0) * corpus.idf(term), 0);
22223
+ if (!stems.some((term) => key.get(term) === NAME_WEIGHT)) continue;
22224
+ if (Number(key.has(terms(first)[0] ?? "")) + Number(key.has(terms(last)[0] ?? "")) < 2) continue;
22225
+ const text = gram.join(" ");
22226
+ const lower = text.toLowerCase();
22227
+ if (GENERIC_ANCHORS.test(lower)) continue;
22228
+ const rarity = stems.reduce((sum, term) => sum + corpus.idf(term), 0);
22229
+ const score = named * (1 + hits / key.size) + rarity * .05 - (size === 4 ? .2 : 0);
22230
+ const existing = scored.get(lower);
22231
+ if (!existing || existing.score < score) scored.set(lower, {
22232
+ text,
22233
+ score
22234
+ });
22235
+ }
22236
+ 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);
22237
+ }
22238
+ function linkPlacement(source, target, corpus) {
22239
+ const key = keyTerms(target);
22240
+ const contentSimilarity = corpus.similarity(corpus.vector(termCounts(source)), corpus.vector(termCounts(target)));
22241
+ const seen = /* @__PURE__ */ new Set();
22242
+ const sentences = source.sentences.filter((sentence) => {
22243
+ if (seen.has(sentence.text)) return false;
22244
+ seen.add(sentence.text);
22245
+ return !sentence.hasLink && sentence.text.split(/\s+/).length >= MIN_SENTENCE_WORDS;
22246
+ }).map((sentence, index) => ({
22247
+ sentence,
22248
+ index,
22249
+ match: corpus.sentenceScore(sentence.text, key)
22250
+ })).filter((item) => item.match > 0).sort((left, right) => right.match - left.match || left.index - right.index).slice(0, PLACEMENT_SENTENCES).map(({ sentence, match }) => ({
22251
+ text: sentence.text,
22252
+ section: sentence.section,
22253
+ match: Math.round(match * 100) / 100,
22254
+ anchors: anchorCandidates(sentence.text, key, corpus, ANCHORS_PER_SENTENCE)
22255
+ })).filter((item) => item.anchors.length > 0);
22256
+ return {
22257
+ contentSimilarity: Math.round(contentSimilarity * 100) / 100,
22258
+ sentences
22259
+ };
22260
+ }
22261
+ function placementPage(input) {
22262
+ return {
22263
+ url: input.url,
22264
+ title: input.title ?? "",
22265
+ h1: input.headings.find((heading) => heading.level === 1)?.text ?? "",
22266
+ description: input.metaDescription ?? "",
22267
+ headings: input.headings.filter((heading) => heading.level === 2 || heading.level === 3).slice(0, 8).map((heading) => heading.text),
22268
+ sentences: pageSentences(input.html)
22269
+ };
22270
+ }
22271
+ //#endregion
22272
+ //#region packages/core/src/analyze/internal-links-report.ts
22273
+ const DAY$1 = 864e5;
22274
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
22275
+ function internalLinksReportRange(input, fallbackDays, now) {
22276
+ if (!input.startDate && !input.endDate) return {
22277
+ ...finalGscDateRange(fallbackDays, now),
22278
+ days: fallbackDays
22279
+ };
22280
+ const { startDate, endDate } = input;
22281
+ 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.");
22282
+ return {
22283
+ startDate,
22284
+ endDate,
22285
+ days: Math.round((Date.parse(`${endDate}T00:00:00Z`) - Date.parse(`${startDate}T00:00:00Z`)) / DAY$1) + 1
22286
+ };
21876
22287
  }
21877
22288
  function completeInternalLinksSelection(analysis, runtime) {
21878
22289
  return {
@@ -21930,9 +22341,8 @@ async function verifyInternalLinkTarget(input) {
21930
22341
  let fetched;
21931
22342
  try {
21932
22343
  fetched = await input.dependencies.fetch(input.targetUrl, {
21933
- js: input.js ?? "auto",
21934
- refresh: input.refresh,
21935
- rate: input.rate
22344
+ js: "auto",
22345
+ refresh: input.refresh
21936
22346
  });
21937
22347
  } catch (error) {
21938
22348
  return {
@@ -22002,7 +22412,12 @@ async function verifyInternalLinkTarget(input) {
22002
22412
  verification: fatalSignals.length ? "technical-issue" : "verified",
22003
22413
  technicalSignals: signals,
22004
22414
  fetchDiagnostics: fetched.diagnostics,
22005
- warnings
22415
+ warnings,
22416
+ text: placementPage({
22417
+ ...page,
22418
+ url: page.finalUrl,
22419
+ html: fetched.html
22420
+ })
22006
22421
  };
22007
22422
  }
22008
22423
  function linksToAliases(page, aliases) {
@@ -22036,9 +22451,8 @@ async function verifyInternalLinkCandidate(input) {
22036
22451
  let fetched;
22037
22452
  try {
22038
22453
  fetched = await input.dependencies.fetch(input.candidate.sourceUrl, {
22039
- js: input.js ?? "auto",
22040
- refresh: input.refresh,
22041
- rate: input.rate
22454
+ js: "auto",
22455
+ refresh: input.refresh
22042
22456
  });
22043
22457
  } catch (error) {
22044
22458
  return {
@@ -22128,12 +22542,21 @@ async function verifyInternalLinkCandidate(input) {
22128
22542
  confidence
22129
22543
  }
22130
22544
  },
22545
+ text: placementPage({
22546
+ ...page,
22547
+ url: page.finalUrl,
22548
+ html: fetched.html
22549
+ }),
22131
22550
  warnings
22132
22551
  };
22133
22552
  }
22134
22553
  //#endregion
22135
22554
  //#region packages/core/src/analyze/internal-links.ts
22136
22555
  const MAX_GSC_ROWS = 1e5;
22556
+ const DEFAULT_DAYS$1 = 90;
22557
+ const RESULT_LIMIT = 20;
22558
+ const CHECK_LIMIT = 40;
22559
+ const MIN_IMPRESSIONS = 1;
22137
22560
  const defaultDependencies$2 = {
22138
22561
  searchAnalytics: querySearchAnalytics,
22139
22562
  fetch: fetchPage,
@@ -22144,43 +22567,17 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
22144
22567
  const targetPage = new URL(assertUrlMatchesGscProperty(input.site, input.targetUrl));
22145
22568
  targetPage.hash = "";
22146
22569
  const targetUrl = targetPage.toString();
22147
- const days = internalLinksIntegerOption({
22148
- value: input.days,
22149
- fallback: 28,
22150
- minimum: 1,
22151
- maximum: 548,
22152
- label: "days"
22153
- });
22154
- const limit = internalLinksIntegerOption({
22155
- value: input.limit,
22156
- fallback: 20,
22157
- minimum: 1,
22158
- maximum: 100,
22159
- label: "limit"
22160
- });
22161
- const checkLimit = internalLinksIntegerOption({
22162
- value: input.checkLimit,
22163
- fallback: Math.max(limit, 40),
22164
- minimum: 1,
22165
- maximum: 200,
22166
- label: "checkLimit"
22167
- });
22168
- const minImpressions = internalLinksNumberOption({
22169
- value: input.minImpressions,
22170
- fallback: 1,
22171
- minimum: 0,
22172
- maximum: 1e9,
22173
- label: "minImpressions"
22174
- });
22570
+ const limit = RESULT_LIMIT;
22571
+ const checkLimit = CHECK_LIMIT;
22572
+ const minImpressions = MIN_IMPRESSIONS;
22175
22573
  const now = dependencies.now();
22176
- const range = internalLinksReportRange(days, now);
22574
+ const { days, ...range } = internalLinksReportRange(input, DEFAULT_DAYS$1, now);
22575
+ const brandTerms = deriveBrandTerms({ siteUrl: input.site });
22177
22576
  const ledger = new SessionLedger();
22178
22577
  const target = await verifyInternalLinkTarget({
22179
22578
  site: input.site,
22180
22579
  targetUrl,
22181
- js: input.js,
22182
22580
  refresh: input.refresh,
22183
- rate: input.rate,
22184
22581
  dependencies
22185
22582
  });
22186
22583
  const targetRequests = await Promise.all(target.aliases.map(async (alias) => {
@@ -22212,8 +22609,7 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
22212
22609
  site: input.site,
22213
22610
  targetAliases: target.aliases,
22214
22611
  minImpressions,
22215
- brandTerms: input.brandTerms,
22216
- includeBrand: input.includeBrand
22612
+ brandTerms
22217
22613
  });
22218
22614
  const queryCandidates = target.verification === "verified" && preliminary.selection.targetEligibleQueries > 0;
22219
22615
  const sourceResult = queryCandidates ? await dependencies.searchAnalytics(input.site, {
@@ -22234,10 +22630,9 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
22234
22630
  site: input.site,
22235
22631
  targetAliases: target.aliases,
22236
22632
  minImpressions,
22237
- brandTerms: input.brandTerms,
22238
- includeBrand: input.includeBrand
22633
+ brandTerms
22239
22634
  });
22240
- const items = [];
22635
+ const found = [];
22241
22636
  const warnings = [...target.warnings];
22242
22637
  let attemptedSources = 0;
22243
22638
  let checkedSources = 0;
@@ -22246,25 +22641,27 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
22246
22641
  let selfAliasExclusions = 0;
22247
22642
  let failedChecks = 0;
22248
22643
  for (const candidate of analysis.candidates) {
22249
- if (attemptedSources >= checkLimit || items.length >= limit) break;
22644
+ if (attemptedSources >= checkLimit || found.length >= limit) break;
22250
22645
  attemptedSources += 1;
22251
22646
  const verified = await verifyInternalLinkCandidate({
22252
22647
  site: input.site,
22253
22648
  candidate,
22254
22649
  target,
22255
- js: input.js,
22256
22650
  refresh: input.refresh,
22257
- rate: input.rate,
22258
22651
  dependencies
22259
22652
  });
22260
22653
  warnings.push(...verified.warnings);
22261
- if (verified.item) items.push(verified.item);
22654
+ if (verified.item) found.push({
22655
+ item: verified.item,
22656
+ text: verified.text
22657
+ });
22262
22658
  if (verified.exclusion === "existing-link") existingLinkExclusions += 1;
22263
22659
  if (verified.exclusion === "technical") technicalExclusions += 1;
22264
22660
  if (verified.exclusion === "self-alias") selfAliasExclusions += 1;
22265
22661
  if (verified.exclusion === "failed") failedChecks += 1;
22266
22662
  else checkedSources += 1;
22267
22663
  }
22664
+ const items = placeLinks(found, target.text);
22268
22665
  const uncheckedCandidates = Math.max(0, analysis.candidates.length - attemptedSources);
22269
22666
  const targetTruncated = targetRequests.some(({ result }) => result.rowsFetched >= MAX_GSC_ROWS);
22270
22667
  const sourceTruncated = sourceResult.rowsFetched >= MAX_GSC_ROWS;
@@ -22326,13 +22723,14 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
22326
22723
  },
22327
22724
  methodology: {
22328
22725
  id: "gsc_internal_link_candidates",
22329
- version: 3,
22726
+ version: 4,
22330
22727
  lexicalTargetLimit: 50,
22331
22728
  matchedQueryEvidenceLimit: 10,
22332
22729
  observedLinkEvidenceLimit: 20,
22333
22730
  matching: "pairwise_exact_then_precision_lexical",
22334
22731
  ranking: "exact_matches_then_matched_query_impressions_then_relevance",
22335
- contextualPlacementVerified: true
22732
+ contextualPlacementVerified: true,
22733
+ placement: "tfidf_sentence_match_then_phrase_anchors"
22336
22734
  },
22337
22735
  filters: {
22338
22736
  minImpressions,
@@ -22364,18 +22762,20 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
22364
22762
  failedChecks,
22365
22763
  uncheckedCandidates,
22366
22764
  matchedQueryImpressions: items.reduce((sum, item) => sum + item.matchedQueryImpressions, 0),
22367
- brandFiltering: input.includeBrand ? "included" : "excluded",
22765
+ brandFiltering: "excluded",
22368
22766
  verdict: summaryVerdict
22369
22767
  },
22370
22768
  items,
22371
22769
  warnings: structuredWarnings,
22372
22770
  caveats: [
22373
- `Date window: ${range.startDate} to ${range.endDate} (${days} days), using final GSC data where available.`,
22771
+ `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.`}`,
22772
+ `Limits: up to ${checkLimit} source pages are checked live and up to ${limit} are returned. Queries need at least ${minImpressions} impression.`,
22374
22773
  `Selection: ${analysis.selection.targetEligibleQueries} eligible target queries, ${analysis.selection.candidateUrls} matched source pages, ${attemptedSources} attempted, ${checkedSources} successfully checked, ${items.length} returned.`,
22375
- `Minimum retained query impressions: ${minImpressions}. Brand queries were ${input.includeBrand ? "included" : "excluded when detected or configured"}.`,
22774
+ `Brand queries were left out. Brand terms found from the domain: ${brandTerms.join(", ") || "none"}.`,
22376
22775
  "GSC query rows omit anonymized queries and lower-volume rows. A 100,000-row cap is reported separately when reached.",
22377
22776
  "Exact query overlap is medium-confidence affinity evidence; lexical matches are low-confidence review evidence. Neither predicts traffic lift or link equity.",
22378
22777
  "The fetch checks HTML link placement, but it cannot prove editorial relevance. Review intent and reader usefulness before adding a link.",
22778
+ "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.",
22379
22779
  uncheckedCandidates ? `${uncheckedCandidates} matched source page${uncheckedCandidates === 1 ? " was" : "s were"} not checked because the output or check limit was reached.` : "",
22380
22780
  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.` : ""
22381
22781
  ].filter(Boolean),
@@ -22383,6 +22783,29 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
22383
22783
  ledgerSummary: ledger.summary()
22384
22784
  };
22385
22785
  }
22786
+ function placeLinks(found, target) {
22787
+ const corpus = new LinkCorpus([target, ...found.map((entry) => entry.text)].filter((page) => Boolean(page)).map(termCounts));
22788
+ const placed = found.map(({ item, text }) => {
22789
+ const placement = target && text ? linkPlacement(text, target, corpus) : {
22790
+ contentSimilarity: 0,
22791
+ sentences: []
22792
+ };
22793
+ const best = placement.sentences[0];
22794
+ const recommendation = best ? {
22795
+ ...item.recommendation,
22796
+ action: `${item.recommendation.action} It fits in: "${best.text}" Anchor text could be ${best.anchors.map((anchor) => `"${anchor}"`).join(" or ")}.`
22797
+ } : {
22798
+ ...item.recommendation,
22799
+ action: `${item.recommendation.action} No sentence on this page names the target, so a link here needs new text.`
22800
+ };
22801
+ return {
22802
+ ...item,
22803
+ placement,
22804
+ recommendation
22805
+ };
22806
+ });
22807
+ return [...placed.filter((item) => item.placement.sentences.length > 0), ...placed.filter((item) => item.placement.sentences.length === 0)];
22808
+ }
22386
22809
  //#endregion
22387
22810
  //#region packages/core/src/analyze/url-templates.ts
22388
22811
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -26316,6 +26739,16 @@ const SECTIONS = {
26316
26739
  bing: "Bing’s top queries from Bing Webmaster Tools with Google’s clicks, impressions and position for the same query beside them, and the same for pages."
26317
26740
  };
26318
26741
  const SECTION_IDS = Object.keys(SECTIONS);
26742
+ new Map(`
26743
+ ae:ar al:sq am:hy ao:pt ar:es at:de au:en az:az ba:bs bd:bn be:nl bf:fr
26744
+ bg:bg bh:ar bo:es br:pt ca:en ch:de ci:fr cl:es cm:fr co:es cr:es cy:el
26745
+ cz:cs de:de dk:da dz:ar ec:es ee:et eg:ar es:es fi:fi fr:fr gb:en gh:en
26746
+ gr:el gt:es hk:en hr:hr hu:hu id:id ie:en il:he in:en it:it jo:ar jp:ja
26747
+ ke:en kh:en kr:ko kz:ru lk:en lt:lt lv:lv ma:ar mc:fr md:ro mk:mk mm:en
26748
+ mt:en mx:es my:en ng:en ni:es nl:nl no:nb nz:en pa:es pe:es ph:en pk:en
26749
+ pl:pl pt:pt py:es ro:ro rs:sr sa:ar se:sv sg:en si:sl sk:sk sn:fr sv:es
26750
+ th:th tn:ar tr:tr tw:zh-TW ua:uk us:en uy:es ve:es vn:vi za:en
26751
+ `.trim().split(/\s+/).map((pair) => pair.split(":")));
26319
26752
  //#endregion
26320
26753
  //#region packages/core/src/commands/research.ts
26321
26754
  const RESEARCH_ACTIONS = [
@@ -26601,6 +27034,7 @@ function nodeCommandDeps(options) {
26601
27034
  return null;
26602
27035
  }
26603
27036
  },
27037
+ pageMarkdown: async (url) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh })),
26604
27038
  call: options.call,
26605
27039
  bing: localBing,
26606
27040
  bingConnect: LOCAL_BING_CONNECT