seo 0.2.9 → 0.2.10

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,8 +1,8 @@
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-C51oI-6o.js";
2
+ import { n as load, t as version } from "./package-92W1Zc-o.js";
3
3
  import { Headers, fetch as fetch$1 } from "undici";
4
4
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
- import { arch, homedir, platform } from "node:os";
5
+ import { arch, homedir, platform, totalmem } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { z } from "zod";
8
8
  import lockfile from "proper-lockfile";
@@ -769,14 +769,14 @@ async function createGoogleAccessTokenClient() {
769
769
  }
770
770
  //#endregion
771
771
  //#region packages/core/dist/storage/cache-maintenance.js
772
- const MEBIBYTE$1 = 1024 * 1024;
772
+ const MEBIBYTE$2 = 1024 * 1024;
773
773
  const CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
774
774
  const CACHE_LIMITS = {
775
- gsc_cache: 48 * MEBIBYTE$1,
776
- ga4_cache: 24 * MEBIBYTE$1,
777
- semrush_cache: 16 * MEBIBYTE$1,
778
- http_cache: 160 * MEBIBYTE$1,
779
- performance_reports: 8 * MEBIBYTE$1
775
+ gsc_cache: 48 * MEBIBYTE$2,
776
+ ga4_cache: 24 * MEBIBYTE$2,
777
+ semrush_cache: 16 * MEBIBYTE$2,
778
+ http_cache: 160 * MEBIBYTE$2,
779
+ performance_reports: 8 * MEBIBYTE$2
780
780
  };
781
781
  const CACHE_POLICIES = [
782
782
  {
@@ -2571,7 +2571,7 @@ function isValidStructuredDate(value) {
2571
2571
  const parsed = new Date(trimmed);
2572
2572
  return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === datePart;
2573
2573
  }
2574
- function parseJsonLd(blocks, baseUrl) {
2574
+ function parseJsonLd(blocks, baseUrl, retainJsonLd) {
2575
2575
  const jsonLd = [];
2576
2576
  const invalidJsonLdSamples = [];
2577
2577
  const unrecognizedJsonLdTypes = [];
@@ -2643,7 +2643,7 @@ function parseJsonLd(blocks, baseUrl) {
2643
2643
  blocks.forEach((block, index) => {
2644
2644
  try {
2645
2645
  const parsed = JSON.parse(block);
2646
- jsonLd.push(parsed);
2646
+ if (retainJsonLd) jsonLd.push(parsed);
2647
2647
  visit(parsed, { prefixes: /* @__PURE__ */ new Map() }, index, "$");
2648
2648
  } catch (error) {
2649
2649
  invalidJsonLdSamples.push({
@@ -2704,8 +2704,8 @@ function usableElementValue($, selector) {
2704
2704
  return ($(element).attr("content") ?? $(element).attr("datetime") ?? $(element).text()).trim().length > 0;
2705
2705
  });
2706
2706
  }
2707
- function extractStructuredData($, baseUrl) {
2708
- const parsed = parseJsonLd($("script[type=\"application/ld+json\"]").toArray().map((element) => $(element).html() ?? ""), baseUrl);
2707
+ function extractStructuredData($, baseUrl, options = {}) {
2708
+ const parsed = parseJsonLd($("script[type=\"application/ld+json\"]").toArray().map((element) => $(element).html() ?? ""), baseUrl, options.retainJsonLd ?? true);
2709
2709
  const microdata = microdataTypes($);
2710
2710
  const rdfa = rdfaTypes($);
2711
2711
  const schemaTypes = [.../* @__PURE__ */ new Set([
@@ -2739,6 +2739,16 @@ function extractStructuredData($, baseUrl) {
2739
2739
  }
2740
2740
  //#endregion
2741
2741
  //#region packages/core/dist/extract/page-extractor.js
2742
+ const SOCIAL_PROFILE_HOSTS = [
2743
+ "facebook.com",
2744
+ "instagram.com",
2745
+ "linkedin.com",
2746
+ "pinterest.com",
2747
+ "tiktok.com",
2748
+ "twitter.com",
2749
+ "x.com",
2750
+ "youtube.com"
2751
+ ];
2742
2752
  function safeText$1(value) {
2743
2753
  const trimmed = value?.replace(/\s+/g, " ").trim();
2744
2754
  return trimmed ? trimmed : void 0;
@@ -2811,6 +2821,28 @@ function imageDimensionsFromUrl(src) {
2811
2821
  height
2812
2822
  };
2813
2823
  }
2824
+ function addAnchorSample(samples, seen, href, text) {
2825
+ if (samples.length >= 25 || !text) return;
2826
+ const sampleText = text.length > 120 ? text.slice(0, 120).trimEnd() : text;
2827
+ const key = `${href}\n${sampleText}`;
2828
+ if (seen.has(key)) return;
2829
+ seen.add(key);
2830
+ samples.push({
2831
+ href,
2832
+ text: sampleText
2833
+ });
2834
+ }
2835
+ function addSocialProfile(profiles, href) {
2836
+ if (profiles.size >= 50) return;
2837
+ try {
2838
+ const url = new URL(href);
2839
+ const host = url.hostname.replace(/^www\./, "").toLowerCase();
2840
+ if (SOCIAL_PROFILE_HOSTS.some((value) => host === value || host.endsWith(`.${value}`))) {
2841
+ url.hash = "";
2842
+ profiles.add(url.toString());
2843
+ }
2844
+ } catch {}
2845
+ }
2814
2846
  async function extractPage(fetchResult, extractor = "defuddle", dependencies = {}) {
2815
2847
  const $ = extractor === "crawler" && /<html(?:\s|>)/i.test(fetchResult.html) ? load(fetchResult.html, { xml: {
2816
2848
  xmlMode: false,
@@ -2818,32 +2850,69 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
2818
2850
  } }) : load(fetchResult.html);
2819
2851
  const url = new URL(fetchResult.finalUrl);
2820
2852
  const canonicalEvidence = extractCanonicalEvidence($, fetchResult.headers, fetchResult.finalUrl);
2821
- const headings = $("h1, h2, h3, h4, h5, h6").toArray().map((element) => ({
2822
- level: Number.parseInt(element.tagName.slice(1), 10),
2823
- text: $(element).text().replace(/\s+/g, " ").trim()
2824
- })).filter((heading) => heading.text.length > 0);
2853
+ const headings = [];
2854
+ let crawlerH1;
2855
+ let crawlerH1Count = 0;
2856
+ let crawlerH2Count = 0;
2857
+ let crawlerH3Count = 0;
2858
+ let questionHeadings = 0;
2859
+ $("h1, h2, h3, h4, h5, h6").each((_index, element) => {
2860
+ const level = Number.parseInt(element.tagName.slice(1), 10);
2861
+ const text = $(element).text().replace(/\s+/g, " ").trim();
2862
+ if (!text) return;
2863
+ if (text.trimEnd().endsWith("?")) questionHeadings += 1;
2864
+ if (level === 1) {
2865
+ crawlerH1 ??= text;
2866
+ crawlerH1Count += 1;
2867
+ } else if (level === 2) crawlerH2Count += 1;
2868
+ else if (level === 3) crawlerH3Count += 1;
2869
+ if (extractor !== "crawler") headings.push({
2870
+ level,
2871
+ text
2872
+ });
2873
+ });
2825
2874
  const links = [];
2875
+ const crawlerInternalLinks = /* @__PURE__ */ new Set();
2876
+ const crawlerExternalLinks = /* @__PURE__ */ new Set();
2877
+ const internalAnchorSamples = [];
2878
+ const externalAnchorSamples = [];
2879
+ const internalAnchorKeys = /* @__PURE__ */ new Set();
2880
+ const externalAnchorKeys = /* @__PURE__ */ new Set();
2881
+ const socialProfileLinks = /* @__PURE__ */ new Set();
2826
2882
  let invalidLinkCount = 0;
2827
2883
  let unsupportedLinkCount = 0;
2828
- for (const element of $("a[href]").toArray()) {
2884
+ $("a[href]").each((_index, element) => {
2829
2885
  const href = $(element).attr("href") ?? "";
2830
2886
  try {
2831
2887
  const absolute = new URL(href, fetchResult.finalUrl);
2832
2888
  if (!["http:", "https:"].includes(absolute.protocol)) {
2833
2889
  unsupportedLinkCount += 1;
2834
- continue;
2890
+ return;
2835
2891
  }
2836
- links.push({
2837
- href: absolute.toString(),
2838
- text: $(element).text().replace(/\s+/g, " ").trim(),
2892
+ const absoluteHref = absolute.toString();
2893
+ const text = $(element).text().replace(/\s+/g, " ").trim();
2894
+ const internal = absolute.host === url.host;
2895
+ if (extractor === "crawler") {
2896
+ if (absolute.origin === url.origin) {
2897
+ absolute.hash = "";
2898
+ crawlerInternalLinks.add(absolute.toString());
2899
+ }
2900
+ if (!internal) {
2901
+ crawlerExternalLinks.add(absoluteHref);
2902
+ addSocialProfile(socialProfileLinks, absoluteHref);
2903
+ }
2904
+ addAnchorSample(internal ? internalAnchorSamples : externalAnchorSamples, internal ? internalAnchorKeys : externalAnchorKeys, absoluteHref, text);
2905
+ } else links.push({
2906
+ href: absoluteHref,
2907
+ text,
2839
2908
  rel: ($(element).attr("rel") ?? "").split(/\s+/).filter(Boolean),
2840
- internal: absolute.host === url.host,
2909
+ internal,
2841
2910
  location: $(element).closest("nav, header").length ? "navigation" : $(element).closest("footer").length ? "footer" : $(element).closest("main, article").length ? "main-content" : "other"
2842
2911
  });
2843
2912
  } catch {
2844
2913
  invalidLinkCount += 1;
2845
2914
  }
2846
- }
2915
+ });
2847
2916
  const hreflang = [];
2848
2917
  let invalidHreflangCount = 0;
2849
2918
  for (const element of $("link[rel~=\"alternate\"][hreflang][href]").toArray()) {
@@ -2858,25 +2927,29 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
2858
2927
  const markdownAlternates = [...new Set($("head link[rel~=\"alternate\"][type][href]").toArray().filter((element) => $(element).attr("type")?.trim().toLowerCase() === "text/markdown").map((element) => httpUrl($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
2859
2928
  const openGraph = Object.fromEntries($("meta[property^=\"og:\"]").toArray().map((element) => [$(element).attr("property") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
2860
2929
  const twitter = Object.fromEntries($("meta[name^=\"twitter:\"]").toArray().map((element) => [$(element).attr("name") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
2861
- const structuredData = extractStructuredData($, fetchResult.finalUrl);
2930
+ const structuredData = extractStructuredData($, fetchResult.finalUrl, { retainJsonLd: extractor !== "crawler" });
2862
2931
  const hasDate = structuredData.hasDate || isValidStructuredDate($("meta[property=\"article:published_time\"]").attr("content")) || isValidStructuredDate($("meta[property=\"article:modified_time\"]").attr("content")) || $("article time[datetime], main time[datetime]").toArray().some((element) => isValidStructuredDate($(element).attr("datetime")));
2863
2932
  const author = safeText$1($("meta[name=\"author\"]").attr("content"));
2864
2933
  const hasAuthor = Boolean(author) || structuredData.hasAuthor || $("[rel~=author], .author, .byline").toArray().some((element) => Boolean(safeText$1($(element).text())));
2865
2934
  const semanticHtml = $("main, article").length > 0;
2866
- const questionHeadings = headings.filter((heading) => heading.text.trimEnd().endsWith("?")).length;
2867
2935
  const listCount = $("ul, ol").length;
2868
2936
  const tableCount = $("table").length;
2869
2937
  const structuredBlocks = listCount + tableCount;
2870
2938
  const answerable = $("main p, article p, p").toArray().slice(0, 3).some((element) => $(element).text().replace(/\s+/g, " ").trim().split(/\s+/).filter(Boolean).length >= 25);
2871
- const imageElements = $("img").toArray();
2872
- const oversizedImageCandidates = imageElements.map((element) => {
2939
+ let imagesTotal = 0;
2940
+ let imagesMissingAlt = 0;
2941
+ const oversizedImageCandidates = [];
2942
+ $("img").each((_index, element) => {
2943
+ imagesTotal += 1;
2944
+ if ($(element).attr("alt") === void 0) imagesMissingAlt += 1;
2945
+ if (oversizedImageCandidates.length >= 25) return;
2873
2946
  const src = absoluteUrl$2($(element).attr("src"), fetchResult.finalUrl);
2874
- if (!src) return void 0;
2947
+ if (!src) return;
2875
2948
  const width = numericAttribute($(element).attr("width"));
2876
2949
  const height = numericAttribute($(element).attr("height"));
2877
2950
  const srcsetWidth = largestSrcsetWidth($(element).attr("srcset"));
2878
2951
  const filenameDimensions = imageDimensionsFromUrl(src);
2879
- if (Math.max(width ?? 0, height ?? 0, srcsetWidth ?? 0, filenameDimensions?.width ?? 0, filenameDimensions?.height ?? 0) < 2e3) return void 0;
2952
+ if (Math.max(width ?? 0, height ?? 0, srcsetWidth ?? 0, filenameDimensions?.width ?? 0, filenameDimensions?.height ?? 0) < 2e3) return;
2880
2953
  const detectedFrom = [
2881
2954
  width && width >= 2e3 ? "width" : void 0,
2882
2955
  height && height >= 2e3 ? "height" : void 0,
@@ -2885,13 +2958,13 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
2885
2958
  ].filter(Boolean).join(",");
2886
2959
  const candidateWidth = Math.max(width ?? 0, srcsetWidth ?? 0, filenameDimensions?.width ?? 0);
2887
2960
  const candidateHeight = Math.max(height ?? 0, filenameDimensions?.height ?? 0);
2888
- return {
2961
+ oversizedImageCandidates.push({
2889
2962
  src,
2890
2963
  detectedFrom,
2891
2964
  ...candidateWidth > 0 ? { width: candidateWidth } : {},
2892
2965
  ...candidateHeight > 0 ? { height: candidateHeight } : {}
2893
- };
2894
- }).filter((candidate) => Boolean(candidate));
2966
+ });
2967
+ });
2895
2968
  const mixedContentUrls = url.protocol === "https:" ? [...new Set($("img[src], script[src], iframe[src], source[src], video[src], audio[src], link[href]").toArray().map((element) => absoluteUrl$2($(element).attr("src") ?? $(element).attr("href"), fetchResult.finalUrl)).filter((value) => typeof value === "string" && new URL(value).protocol === "http:"))] : [];
2896
2969
  const metaRobots = combineRobotsValues($("meta[name]").toArray().filter((element) => {
2897
2970
  const name = $(element).attr("name")?.trim().toLowerCase();
@@ -2915,6 +2988,18 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
2915
2988
  hasViewport: Boolean($("meta[name=\"viewport\"]").attr("content")),
2916
2989
  headings,
2917
2990
  links,
2991
+ ...extractor === "crawler" ? { crawlerEvidence: {
2992
+ h1: crawlerH1,
2993
+ h1Count: crawlerH1Count,
2994
+ h2Count: crawlerH2Count,
2995
+ h3Count: crawlerH3Count,
2996
+ questionHeadings,
2997
+ internalLinks: [...crawlerInternalLinks],
2998
+ externalLinks: [...crawlerExternalLinks],
2999
+ internalAnchorSamples,
3000
+ externalAnchorSamples,
3001
+ socialProfileLinks: [...socialProfileLinks]
3002
+ } } : {},
2918
3003
  markdownAlternates,
2919
3004
  hreflang,
2920
3005
  jsonLd: structuredData.jsonLd,
@@ -2923,19 +3008,17 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
2923
3008
  unrecognizedJsonLdTypes: structuredData.unrecognizedJsonLdTypes.slice(0, 25),
2924
3009
  structuredDataFormats: structuredData.formats,
2925
3010
  googleRichResults: structuredData.googleRichResults,
2926
- schemaSameAsEvidence: structuredData.sameAs,
2927
- invalidSchemaSameAs: structuredData.invalidSameAs,
3011
+ schemaSameAsEvidence: extractor === "crawler" ? structuredData.sameAs.slice(0, 50) : structuredData.sameAs,
3012
+ invalidSchemaSameAs: extractor === "crawler" ? structuredData.invalidSameAs.slice(0, 25) : structuredData.invalidSameAs,
2928
3013
  schemaTypes: structuredData.schemaTypes,
2929
3014
  openGraph,
2930
3015
  twitter,
2931
3016
  author,
2932
3017
  hasAuthor,
2933
3018
  hasDate,
2934
- imagesTotal: imageElements.length,
2935
- imagesMissingAlt: imageElements.filter((element) => {
2936
- return $(element).attr("alt") === void 0;
2937
- }).length,
2938
- oversizedImageCandidates: oversizedImageCandidates.slice(0, 25),
3019
+ imagesTotal,
3020
+ imagesMissingAlt,
3021
+ oversizedImageCandidates,
2939
3022
  mixedContentUrls,
2940
3023
  semanticHtml,
2941
3024
  questionHeadings,
@@ -2943,7 +3026,7 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
2943
3026
  tableCount,
2944
3027
  structuredBlocks,
2945
3028
  answerable,
2946
- contentText: text.replace(/\s+/g, " ").trim(),
3029
+ contentText: extractor === "crawler" ? text : text.replace(/\s+/g, " ").trim(),
2947
3030
  excerpt,
2948
3031
  wordCount: content.wordCount,
2949
3032
  contentExtraction: content.diagnostics,
@@ -3473,7 +3556,7 @@ function decodePageFetchCacheEvidence(value) {
3473
3556
  return;
3474
3557
  }
3475
3558
  }
3476
- async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, respectRobots = false) {
3559
+ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, respectRobots = false, writeCache = true) {
3477
3560
  const startedAt = Date.now();
3478
3561
  const db = getDb();
3479
3562
  const key = hashKey(["page", url]);
@@ -3585,7 +3668,7 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3585
3668
  warnings: [...robots.allowed === null ? [`robots.txt availability is unknown${robots.status ? ` after HTTP ${robots.status}` : ""}; this fetch does not prove crawler access is allowed.`] : [], ...accessBlock ? [`${accessBlock.guidance.summary} Identify the crawler as ${accessBlock.crawler.userAgent}.`] : []],
3586
3669
  robotsTxt: pageRobotsEvidence(robots)
3587
3670
  };
3588
- if (!accessBlock) {
3671
+ if (!accessBlock && writeCache) {
3589
3672
  db.prepare(`INSERT OR REPLACE INTO http_cache
3590
3673
  (url_hash, url, status, headers_json, body_blob, metadata_json, etag, fetched_at, expires_at)
3591
3674
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(key, url, response.response.status, JSON.stringify(headerMap), Buffer.from(html), encodePageFetchCacheEvidence(result), response.response.headers.get("etag"), Date.now(), Date.now() + 36e5);
@@ -4153,7 +4236,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
4153
4236
  const makeRenderer = dependencies.createPageRenderer ?? createPageRenderer;
4154
4237
  const rate = normalizeRateControls(opts.rate);
4155
4238
  return await queueForHost(new URL(url).host, rate).add(async () => {
4156
- const first = await plainFetch(url, opts.refresh, opts.timeoutMs, rate, opts.signal, opts.respectRobots);
4239
+ const first = await plainFetch(url, opts.refresh, opts.timeoutMs, rate, opts.signal, opts.respectRobots, opts.writeCache);
4157
4240
  if (!(!first.diagnostics.accessBlock && (renderingMode === "on" || renderingMode === "auto" && looksLikeSpa(first.html))) || opts.signal?.aborted) return withRenderingState(first, renderingMode, opts.signal?.aborted ? "skipped" : renderingMode === "off" ? "not-requested" : "not-needed");
4158
4241
  const ownsRenderer = !opts.renderer;
4159
4242
  const renderer = opts.renderer ?? makeRenderer();
@@ -10431,6 +10514,7 @@ const CRAWL_SKIP_REASONS = [
10431
10514
  "configured-exclusion",
10432
10515
  "duplicate-normalization",
10433
10516
  "invalid-url",
10517
+ "memory-pressure",
10434
10518
  "off-origin",
10435
10519
  "origin-backpressure",
10436
10520
  "queue-safety-limit",
@@ -10443,6 +10527,7 @@ const SKIP_IMPACT = {
10443
10527
  "configured-exclusion": "non-impacting",
10444
10528
  "duplicate-normalization": "non-impacting",
10445
10529
  "invalid-url": "non-impacting",
10530
+ "memory-pressure": "coverage-affecting",
10446
10531
  "off-origin": "non-impacting",
10447
10532
  "origin-backpressure": "coverage-affecting",
10448
10533
  "queue-safety-limit": "coverage-affecting",
@@ -10620,17 +10705,44 @@ function sanitizeTenantString(value) {
10620
10705
  return value.replace(/https?:\/\/[^\s"'<>`]+/g, (match) => sanitizeUrlString(match)).replace(/\b(access_?token|api_?key|auth|client_?secret|jwt|password|refresh_?token|secret|session|signature|token)(\s*[=:]\s*)[^\s,;&]+/gi, (_match, key, separator) => `${key}${separator}[redacted]`).replace(/\b[A-Za-z]:\\[^\s"'<>`]+/g, "[local-path]").replace(/\/(?:Users|home|tmp|var\/folders)\/[^\s"'<>`]+/g, "[local-path]");
10621
10706
  }
10622
10707
  function sanitizeTenantValue(value, key) {
10623
- if (key && isSensitiveFieldName(key)) return "[redacted]";
10708
+ if (key && isSensitiveFieldName(key)) return value === "[redacted]" ? value : "[redacted]";
10624
10709
  if (typeof value === "string") return sanitizeTenantString(value);
10625
- if (Array.isArray(value)) return value.map((item) => sanitizeTenantValue(item));
10710
+ if (Array.isArray(value)) {
10711
+ let sanitized;
10712
+ for (let index = 0; index < value.length; index += 1) {
10713
+ const item = sanitizeTenantValue(value[index]);
10714
+ if (sanitized) sanitized.push(item);
10715
+ else if (item !== value[index]) sanitized = [...value.slice(0, index), item];
10716
+ }
10717
+ return sanitized ?? value;
10718
+ }
10626
10719
  if (!value || typeof value !== "object") return value;
10627
- return Object.fromEntries(Object.entries(value).map(([entryKey, entry]) => [entryKey, sanitizeTenantValue(entry, entryKey)]));
10720
+ const record = value;
10721
+ let sanitized;
10722
+ for (const [entryKey, entry] of Object.entries(record)) {
10723
+ const safeEntry = sanitizeTenantValue(entry, entryKey);
10724
+ if (safeEntry === entry) continue;
10725
+ sanitized ??= { ...record };
10726
+ sanitized[entryKey] = safeEntry;
10727
+ }
10728
+ return sanitized ?? value;
10628
10729
  }
10629
10730
  function sanitizeCrawlConfig(config) {
10630
10731
  return sanitizeTenantValue(config);
10631
10732
  }
10632
10733
  function sanitizePages(pages) {
10633
- return sanitizeTenantValue(pages).map(({ seoScore: _seoScore, geoScore: _geoScore, ...page }) => page);
10734
+ const safePages = sanitizeTenantValue(pages);
10735
+ let normalized;
10736
+ for (let index = 0; index < safePages.length; index += 1) {
10737
+ const page = safePages[index];
10738
+ if (!page) continue;
10739
+ if ("seoScore" in page || "geoScore" in page) {
10740
+ normalized ??= safePages.slice(0, index);
10741
+ const { seoScore: _seoScore, geoScore: _geoScore, ...current } = page;
10742
+ normalized.push(current);
10743
+ } else if (normalized) normalized.push(page);
10744
+ }
10745
+ return normalized ?? safePages;
10634
10746
  }
10635
10747
  function sanitizeIssues(issues) {
10636
10748
  return sanitizeTenantValue(issues);
@@ -10659,18 +10771,28 @@ function summarizeCrawlReport(input) {
10659
10771
  const byCategory = {};
10660
10772
  let responseMs = 0;
10661
10773
  let responseCount = 0;
10774
+ let statusOnlyPages = 0;
10775
+ let indexablePages = 0;
10776
+ let nonIndexablePages = 0;
10777
+ let statusErrors = 0;
10662
10778
  for (const page of input.pages) {
10663
10779
  byStatus[String(page.status)] = (byStatus[String(page.status)] ?? 0) + 1;
10664
10780
  if (typeof page.responseTimeMs === "number") {
10665
10781
  responseMs += page.responseTimeMs;
10666
10782
  responseCount += 1;
10667
10783
  }
10784
+ if (page.auditScope === "status") statusOnlyPages += 1;
10785
+ else if (page.indexable) indexablePages += 1;
10786
+ else nonIndexablePages += 1;
10787
+ if (page.status === 0 || page.status >= 400) statusErrors += 1;
10668
10788
  }
10669
10789
  const requests = input.requests ?? [];
10670
- const failedRequests = requests.filter((request) => request.outcome === "failure" && request.failureKind !== "aborted");
10671
- const abortedRequests = requests.filter((request) => request.outcome === "failure" && request.failureKind === "aborted");
10672
10790
  let requestMs = 0;
10673
10791
  let requestMsCount = 0;
10792
+ let responseRequests = 0;
10793
+ let failedRequests = 0;
10794
+ let abortedRequests = 0;
10795
+ let extractionFailures = 0;
10674
10796
  for (const request of requests) {
10675
10797
  const status = request.outcome === "response" ? String(request.status) : request.outcome === "skipped" ? request.reason : request.failureKind === "aborted" ? "aborted" : "no-response";
10676
10798
  requestByStatus[status] = (requestByStatus[status] ?? 0) + 1;
@@ -10678,27 +10800,39 @@ function summarizeCrawlReport(input) {
10678
10800
  requestMs += request.durationMs;
10679
10801
  requestMsCount += 1;
10680
10802
  }
10803
+ if (request.outcome === "response") {
10804
+ responseRequests += 1;
10805
+ if (request.extraction === "failed") extractionFailures += 1;
10806
+ } else if (request.outcome === "failure") if (request.failureKind === "aborted") abortedRequests += 1;
10807
+ else failedRequests += 1;
10808
+ }
10809
+ let highIssues = 0;
10810
+ let mediumIssues = 0;
10811
+ let lowIssues = 0;
10812
+ for (const issue of input.issues) {
10813
+ byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
10814
+ if (issue.severity === "high") highIssues += 1;
10815
+ else if (issue.severity === "medium") mediumIssues += 1;
10816
+ else lowIssues += 1;
10681
10817
  }
10682
- for (const issue of input.issues) byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
10683
10818
  const runStats = crawlRunStats(input.pages, input.stats);
10684
- const indexabilityPages = input.pages.filter((page) => page.auditScope !== "status");
10685
10819
  return {
10686
10820
  totalPages: input.pages.length,
10687
- statusOnlyPages: input.pages.filter((page) => page.auditScope === "status").length,
10688
- indexablePages: indexabilityPages.filter((page) => page.indexable).length,
10689
- nonIndexablePages: indexabilityPages.filter((page) => !page.indexable).length,
10690
- statusErrors: input.pages.filter((page) => page.status === 0 || page.status >= 400).length,
10821
+ statusOnlyPages,
10822
+ indexablePages,
10823
+ nonIndexablePages,
10824
+ statusErrors,
10691
10825
  ...runStats,
10692
10826
  attemptedRequests: input.requestEvidenceStatus === "unavailable" ? 0 : requests.length,
10693
- responseRequests: requests.filter((request) => request.outcome === "response").length,
10694
- failedRequests: failedRequests.length,
10695
- abortedRequests: abortedRequests.length,
10696
- extractionFailures: requests.filter((request) => request.outcome === "response" && request.extraction === "failed").length,
10827
+ responseRequests,
10828
+ failedRequests,
10829
+ abortedRequests,
10830
+ extractionFailures,
10697
10831
  requestByStatus,
10698
10832
  avgRequestMs: requestMsCount ? Math.round(requestMs / requestMsCount) : void 0,
10699
- highIssues: input.issues.filter((issue) => issue.severity === "high").length,
10700
- mediumIssues: input.issues.filter((issue) => issue.severity === "medium").length,
10701
- lowIssues: input.issues.filter((issue) => issue.severity === "low").length,
10833
+ highIssues,
10834
+ mediumIssues,
10835
+ lowIssues,
10702
10836
  avgResponseMs: responseCount ? Math.round(responseMs / responseCount) : void 0,
10703
10837
  byStatus,
10704
10838
  byCategory
@@ -10706,42 +10840,55 @@ function summarizeCrawlReport(input) {
10706
10840
  }
10707
10841
  const ACCESS_BLOCK_SAMPLE_LIMIT = 10;
10708
10842
  function crawlAccessSummary(requests) {
10709
- const blocked = requests.flatMap((request) => request.outcome === "response" && request.accessBlock ? [{
10710
- url: request.requestedUrl,
10711
- evidence: request.accessBlock
10712
- }] : []);
10713
10843
  const providers = {};
10714
- for (const item of blocked) providers[item.evidence.provider] = (providers[item.evidence.provider] ?? 0) + 1;
10844
+ const samples = [];
10845
+ let blockedRequests = 0;
10846
+ for (const request of requests) {
10847
+ if (request.outcome !== "response" || !request.accessBlock) continue;
10848
+ blockedRequests += 1;
10849
+ providers[request.accessBlock.provider] = (providers[request.accessBlock.provider] ?? 0) + 1;
10850
+ if (samples.length < ACCESS_BLOCK_SAMPLE_LIMIT) samples.push({
10851
+ url: request.requestedUrl,
10852
+ evidence: request.accessBlock
10853
+ });
10854
+ }
10715
10855
  return {
10716
10856
  crawler: SEO_CRAWLER_IDENTITY,
10717
- blockedRequests: blocked.length,
10857
+ blockedRequests,
10718
10858
  providers,
10719
- samples: blocked.slice(0, ACCESS_BLOCK_SAMPLE_LIMIT),
10859
+ samples,
10720
10860
  sampleLimit: ACCESS_BLOCK_SAMPLE_LIMIT,
10721
- truncated: blocked.length > ACCESS_BLOCK_SAMPLE_LIMIT
10861
+ truncated: blockedRequests > ACCESS_BLOCK_SAMPLE_LIMIT
10722
10862
  };
10723
10863
  }
10724
10864
  function crawlRunStats(pages, stats = {}) {
10725
- const discovered = /* @__PURE__ */ new Set();
10865
+ const needsDiscoveredUrls = stats.discoveredUrls === void 0;
10866
+ const needsObservedInternalLinks = stats.observedInternalLinks === void 0;
10867
+ const needsFailedUrls = stats.failedUrls === void 0;
10868
+ const discovered = needsDiscoveredUrls ? /* @__PURE__ */ new Set() : void 0;
10726
10869
  let observedInternalLinks = 0;
10727
- for (const page of pages) {
10728
- discovered.add(page.url);
10729
- for (const url of page.sampleInternalLinks ?? []) discovered.add(url);
10730
- for (const url of page.sampleExternalLinks ?? []) discovered.add(url);
10731
- observedInternalLinks += page.outgoingInternalCount;
10870
+ let failedUrls = 0;
10871
+ if (needsDiscoveredUrls || needsObservedInternalLinks || needsFailedUrls) for (const page of pages) {
10872
+ if (discovered) {
10873
+ discovered.add(page.url);
10874
+ for (const url of page.sampleInternalLinks ?? []) discovered.add(url);
10875
+ for (const url of page.sampleExternalLinks ?? []) discovered.add(url);
10876
+ }
10877
+ if (needsObservedInternalLinks) observedInternalLinks += page.outgoingInternalCount;
10878
+ if (needsFailedUrls && (page.status === 0 || page.status >= 400)) failedUrls += 1;
10732
10879
  }
10733
10880
  const skips = normalizeCrawlSkipReasonCounts({
10734
10881
  skippedUrls: stats.skippedUrls,
10735
10882
  skipReasons: stats.skipReasons ?? crawlSkipReasonCountsFromRecord(stats.skipReasonCounts)
10736
10883
  });
10737
10884
  return {
10738
- discoveredUrls: stats.discoveredUrls ?? discovered.size,
10885
+ discoveredUrls: stats.discoveredUrls ?? discovered?.size ?? 0,
10739
10886
  queuedUrls: stats.queuedUrls ?? pages.length,
10740
10887
  crawledUrls: stats.crawledUrls ?? pages.length,
10741
10888
  skippedUrls: skips.skippedUrls,
10742
10889
  skipReasons: skips.skipReasons,
10743
10890
  skippedUrlsByImpact: skips.skippedUrlsByImpact,
10744
- failedUrls: stats.failedUrls ?? pages.filter((page) => page.status === 0 || page.status >= 400).length,
10891
+ failedUrls: stats.failedUrls ?? failedUrls,
10745
10892
  observedInternalLinks: stats.observedInternalLinks ?? observedInternalLinks,
10746
10893
  pageLimitReached: stats.pageLimitReached ?? false
10747
10894
  };
@@ -10758,11 +10905,14 @@ function normalizeLinkUrl(value) {
10758
10905
  function deriveInternalLinkAuthority(pages, linkGraph) {
10759
10906
  const hasLinkGraph = linkGraph && Object.keys(linkGraph).length > 0;
10760
10907
  const hasStoredScores = pages.some((page) => page.internalInlinkCount !== void 0 || page.internalLinkAuthorityScore !== void 0);
10761
- if (!hasLinkGraph && hasStoredScores) return pages.map((page) => ({
10762
- ...page,
10763
- internalInlinkCount: page.internalInlinkCount ?? 0,
10764
- internalLinkAuthorityScore: page.internalLinkAuthorityScore ?? 0
10765
- }));
10908
+ if (!hasLinkGraph && hasStoredScores) {
10909
+ if (pages.every((page) => page.internalInlinkCount !== void 0 && page.internalLinkAuthorityScore !== void 0)) return pages;
10910
+ return pages.map((page) => page.internalInlinkCount !== void 0 && page.internalLinkAuthorityScore !== void 0 ? page : {
10911
+ ...page,
10912
+ internalInlinkCount: page.internalInlinkCount ?? 0,
10913
+ internalLinkAuthorityScore: page.internalLinkAuthorityScore ?? 0
10914
+ });
10915
+ }
10766
10916
  const pageByUrl = /* @__PURE__ */ new Map();
10767
10917
  for (const page of pages) for (const value of [page.url, page.finalUrl]) {
10768
10918
  const normalized = normalizeLinkUrl(value);
@@ -10781,7 +10931,8 @@ function deriveInternalLinkAuthority(pages, linkGraph) {
10781
10931
  }
10782
10932
  for (const linkedPage of linkedPages) inlinks.set(linkedPage, (inlinks.get(linkedPage) ?? 0) + 1);
10783
10933
  }
10784
- const maxInlinks = Math.max(0, ...inlinks.values());
10934
+ let maxInlinks = 0;
10935
+ for (const count of inlinks.values()) maxInlinks = Math.max(maxInlinks, count);
10785
10936
  return pages.map((page) => {
10786
10937
  const internalInlinkCount = inlinks.get(page.url) ?? 0;
10787
10938
  return {
@@ -11030,6 +11181,7 @@ function crawlCaveats(input) {
11030
11181
  const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
11031
11182
  if (input.queueSafetySkippedUrls > 0) caveats.push(`Left ${input.queueSafetySkippedUrls} eligible same-origin URLs unqueued to keep this crawl bounded. Increase --max-pages to inspect more.`);
11032
11183
  if (input.originBackpressureSkippedUrls > 0) caveats.push(`Stopped ${input.originBackpressureSkippedUrls} queued URL${input.originBackpressureSkippedUrls === 1 ? "" : "s"} because the origin stayed slow. This is incomplete crawl evidence, not a failed URL check.`);
11184
+ if (input.memoryPressureSkippedUrls > 0) caveats.push(`Left ${input.memoryPressureSkippedUrls} eligible URL${input.memoryPressureSkippedUrls === 1 ? "" : "s"} unchecked after the local memory safety limit was reached. Start with --health or lower --max-pages.`);
11033
11185
  return caveats;
11034
11186
  }
11035
11187
  //#endregion
@@ -11056,6 +11208,26 @@ function cancellationRace(signal) {
11056
11208
  });
11057
11209
  }
11058
11210
  //#endregion
11211
+ //#region packages/core/dist/analyze/crawler/crawl-memory.js
11212
+ const MEBIBYTE$1 = 1024 * 1024;
11213
+ const MIN_CRAWL_MEMORY_LIMIT = 384 * MEBIBYTE$1;
11214
+ const MAX_CRAWL_MEMORY_LIMIT = 640 * MEBIBYTE$1;
11215
+ const MIN_CRAWL_RSS_LIMIT = 512 * MEBIBYTE$1;
11216
+ const MAX_CRAWL_RSS_LIMIT = 896 * MEBIBYTE$1;
11217
+ const SYSTEM_MEMORY_FRACTION = .1;
11218
+ const SYSTEM_RSS_FRACTION = .2;
11219
+ function crawlMemoryLimitBytes(totalMemoryBytes) {
11220
+ if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_MEMORY_LIMIT;
11221
+ return Math.min(MAX_CRAWL_MEMORY_LIMIT, Math.max(MIN_CRAWL_MEMORY_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_MEMORY_FRACTION)));
11222
+ }
11223
+ function crawlRssLimitBytes(totalMemoryBytes) {
11224
+ if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_RSS_LIMIT;
11225
+ return Math.min(MAX_CRAWL_RSS_LIMIT, Math.max(MIN_CRAWL_RSS_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_RSS_FRACTION)));
11226
+ }
11227
+ function crawlMemoryPressure(input) {
11228
+ return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
11229
+ }
11230
+ //#endregion
11059
11231
  //#region packages/core/dist/analyze/crawler/crawl-url-queue.js
11060
11232
  function before(left, right) {
11061
11233
  return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
@@ -11773,6 +11945,7 @@ function crawlProviderLimits(input) {
11773
11945
  }
11774
11946
  async function crawlDataSources(input) {
11775
11947
  const window = crawlMetricsWindow(input.now());
11948
+ const skippedForMemory = input.skipReason === "memory-pressure";
11776
11949
  const dataSources = {
11777
11950
  searchConsole: {
11778
11951
  status: "skipped",
@@ -11783,7 +11956,7 @@ async function crawlDataSources(input) {
11783
11956
  pageLimit: input.searchMetricsLimit,
11784
11957
  pageLimitReached: false,
11785
11958
  retainedRowLimitReached: false,
11786
- warning: input.cancelled ? "Search Console join skipped because the crawl was cancelled." : "Search Console join skipped because no property was selected."
11959
+ warning: skippedForMemory ? "Search Console join skipped after the local memory safety limit was reached." : input.skipReason === "cancelled" ? "Search Console join skipped because the crawl was cancelled." : "Search Console join skipped because no property was selected."
11787
11960
  },
11788
11961
  analytics: {
11789
11962
  status: "skipped",
@@ -11792,10 +11965,10 @@ async function crawlDataSources(input) {
11792
11965
  joinedPages: 0,
11793
11966
  retainedRowLimit: input.analyticsLimit,
11794
11967
  retainedRowLimitReached: false,
11795
- warning: input.cancelled ? "Google Analytics join skipped because the crawl was cancelled." : "Google Analytics join skipped because no property was selected."
11968
+ warning: skippedForMemory ? "Google Analytics join skipped after the local memory safety limit was reached." : input.skipReason === "cancelled" ? "Google Analytics join skipped because the crawl was cancelled." : "Google Analytics join skipped because no property was selected."
11796
11969
  }
11797
11970
  };
11798
- if (!input.cancelled && input.site) dataSources.searchConsole = await joinSearchMetrics({
11971
+ if (!input.skipReason && input.site) dataSources.searchConsole = await joinSearchMetrics({
11799
11972
  site: input.site,
11800
11973
  pages: input.pages,
11801
11974
  warnings: input.warnings,
@@ -11808,7 +11981,7 @@ async function crawlDataSources(input) {
11808
11981
  queryPagesMetricsBatch: input.queryPagesMetricsBatch,
11809
11982
  queryPagesTopQueriesBatch: input.queryPagesTopQueriesBatch
11810
11983
  });
11811
- if (!input.cancelled && input.googleAnalyticsPropertyId) dataSources.analytics = await joinAnalytics({
11984
+ if (!input.skipReason && input.googleAnalyticsPropertyId) dataSources.analytics = await joinAnalytics({
11812
11985
  propertyId: input.googleAnalyticsPropertyId,
11813
11986
  pages: input.pages,
11814
11987
  warnings: input.warnings,
@@ -11821,17 +11994,6 @@ async function crawlDataSources(input) {
11821
11994
  }
11822
11995
  //#endregion
11823
11996
  //#region packages/core/dist/analyze/monitoring/crawl-page.js
11824
- function sameOriginUrl(href, base) {
11825
- try {
11826
- const url = new URL(href, base);
11827
- url.hash = "";
11828
- if (url.origin !== base.origin) return void 0;
11829
- if (!["http:", "https:"].includes(url.protocol)) return void 0;
11830
- return url.toString();
11831
- } catch {
11832
- return;
11833
- }
11834
- }
11835
11997
  function resolvedUrl(href, base) {
11836
11998
  if (!href) return void 0;
11837
11999
  try {
@@ -11861,49 +12023,6 @@ function safeResponseHeaders(headers) {
11861
12023
  function truncate$1(value, max) {
11862
12024
  return value.length > max ? value.slice(0, max).trimEnd() : value;
11863
12025
  }
11864
- function anchorSamples(links, internal) {
11865
- const seen = /* @__PURE__ */ new Set();
11866
- const samples = [];
11867
- for (const link of links) {
11868
- if (link.internal !== internal) continue;
11869
- const text = truncate$1(link.text.replace(/\s+/g, " ").trim(), 120);
11870
- if (!text) continue;
11871
- const key = `${link.href}\n${text}`;
11872
- if (seen.has(key)) continue;
11873
- seen.add(key);
11874
- samples.push({
11875
- href: link.href,
11876
- text
11877
- });
11878
- if (samples.length >= 25) break;
11879
- }
11880
- return samples;
11881
- }
11882
- function socialProfileLinks(links) {
11883
- const hosts = [
11884
- "facebook.com",
11885
- "instagram.com",
11886
- "linkedin.com",
11887
- "pinterest.com",
11888
- "tiktok.com",
11889
- "twitter.com",
11890
- "x.com",
11891
- "youtube.com"
11892
- ];
11893
- const profiles = /* @__PURE__ */ new Set();
11894
- for (const link of links) {
11895
- if (link.internal) continue;
11896
- try {
11897
- const url = new URL(link.href);
11898
- const host = url.hostname.replace(/^www\./, "").toLowerCase();
11899
- if (hosts.some((value) => host === value || host.endsWith(`.${value}`))) {
11900
- url.hash = "";
11901
- profiles.add(url.toString());
11902
- }
11903
- } catch {}
11904
- }
11905
- return [...profiles].slice(0, 50);
11906
- }
11907
12026
  function sameDocumentUrl(left, right) {
11908
12027
  if (!left || !right) return false;
11909
12028
  try {
@@ -12003,17 +12122,9 @@ function statusOnlyPage(fetched) {
12003
12122
  finalUrl: fetched.finalUrl,
12004
12123
  status: fetched.status,
12005
12124
  contentType,
12006
- responseHeaders: safeResponseHeaders(fetched.headers),
12007
12125
  responseTimeMs: fetched.diagnostics.durationMs,
12008
- usedJs: false,
12009
- fetchSource: "network",
12010
- cacheState: "bypass",
12011
- fetchDiagnostics: fetched.diagnostics,
12012
12126
  auditScope: "status",
12013
- accessBlock: fetched.diagnostics.accessBlock,
12014
- blocked: fetched.diagnostics.blocked,
12015
12127
  contentAuditAllowed: false,
12016
- robotsTxt: fetched.robotsTxt,
12017
12128
  indexable: false,
12018
12129
  indexability: "Not evaluated by status-only health probe",
12019
12130
  declaredIndexability: "unknown",
@@ -12021,8 +12132,7 @@ function statusOnlyPage(fetched) {
12021
12132
  wordCount: 0,
12022
12133
  contentHash: "",
12023
12134
  outgoingInternalCount: 0,
12024
- outgoingExternalCount: 0,
12025
- isHttps: new URL(fetched.finalUrl).protocol === "https:"
12135
+ outgoingExternalCount: 0
12026
12136
  };
12027
12137
  }
12028
12138
  function indexabilityReason(page) {
@@ -12098,12 +12208,11 @@ async function crawlOne(url, opts = {}) {
12098
12208
  };
12099
12209
  try {
12100
12210
  const extracted = await extractPage(fetched, "crawler");
12101
- const base = new URL(extracted.finalUrl);
12102
- const internalLinks = extracted.links.map((link) => sameOriginUrl(link.href, base)).filter((value) => Boolean(value));
12103
- const externalLinks = extracted.links.filter((link) => !link.internal).map((link) => link.href).filter((href) => /^https?:\/\//.test(href));
12104
- const h1 = extracted.headings.find((heading) => heading.level === 1)?.text;
12105
- const uniqueInternalLinks = [...new Set(internalLinks)];
12106
- const uniqueExternalLinks = [...new Set(externalLinks)];
12211
+ const crawlerEvidence = extracted.crawlerEvidence;
12212
+ if (!crawlerEvidence) throw new Error("Crawler extraction did not return crawl evidence.");
12213
+ const h1 = crawlerEvidence.h1;
12214
+ const uniqueInternalLinks = crawlerEvidence.internalLinks;
12215
+ const uniqueExternalLinks = crawlerEvidence.externalLinks;
12107
12216
  const canonical = extracted.canonicalEvidence?.selectedUrl ?? resolvedUrl(extracted.canonical, extracted.finalUrl);
12108
12217
  const richResults = selectGoogleRichResultAssessments(extracted.googleRichResults ?? []);
12109
12218
  const page = {
@@ -12129,9 +12238,9 @@ async function crawlOne(url, opts = {}) {
12129
12238
  metaRobots: extracted.metaRobots,
12130
12239
  xRobotsTag: extracted.xRobotsTag,
12131
12240
  h1,
12132
- h1Count: extracted.headings.filter((heading) => heading.level === 1).length,
12133
- h2Count: extracted.headings.filter((heading) => heading.level === 2).length,
12134
- h3Count: extracted.headings.filter((heading) => heading.level === 3).length,
12241
+ h1Count: crawlerEvidence.h1Count,
12242
+ h2Count: crawlerEvidence.h2Count,
12243
+ h3Count: crawlerEvidence.h3Count,
12135
12244
  indexable: false,
12136
12245
  extractionStatus: "complete",
12137
12246
  wordCount: extracted.wordCount,
@@ -12167,8 +12276,8 @@ async function crawlOne(url, opts = {}) {
12167
12276
  outgoingExternalCount: uniqueExternalLinks.length,
12168
12277
  sampleInternalLinks: uniqueInternalLinks.slice(0, 25),
12169
12278
  sampleExternalLinks: uniqueExternalLinks.slice(0, 25),
12170
- internalAnchorSamples: anchorSamples(extracted.links, true),
12171
- externalAnchorSamples: anchorSamples(extracted.links, false),
12279
+ internalAnchorSamples: crawlerEvidence.internalAnchorSamples,
12280
+ externalAnchorSamples: crawlerEvidence.externalAnchorSamples,
12172
12281
  markdownAlternates: extracted.markdownAlternates,
12173
12282
  schemaTypes: extracted.schemaTypes,
12174
12283
  structuredDataFormats: extracted.structuredDataFormats ?? [],
@@ -12177,7 +12286,7 @@ async function crawlOne(url, opts = {}) {
12177
12286
  schemaSameAs: [...new Set((extracted.schemaSameAsEvidence ?? []).map((evidence) => evidence.url))].slice(0, 50),
12178
12287
  schemaSameAsEvidence: (extracted.schemaSameAsEvidence ?? []).slice(0, 50),
12179
12288
  invalidSchemaSameAs: (extracted.invalidSchemaSameAs ?? []).slice(0, 25),
12180
- socialProfileLinks: socialProfileLinks(extracted.links),
12289
+ socialProfileLinks: crawlerEvidence.socialProfileLinks,
12181
12290
  invalidJsonLdCount: extracted.invalidJsonLdCount,
12182
12291
  invalidJsonLdSamples: extracted.invalidJsonLdSamples,
12183
12292
  unrecognizedJsonLdTypes: extracted.unrecognizedJsonLdTypes ?? [],
@@ -12830,7 +12939,9 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
12830
12939
  fetchLandingPageValues: dependencies.fetchLandingPageValues ?? fetchLandingPageValues,
12831
12940
  landingValueForUrl: dependencies.landingValueForUrl ?? landingValueForUrl,
12832
12941
  collectAgentDiscovery: dependencies.collectAgentDiscovery ?? collectAgentDiscovery,
12833
- now: dependencies.now ?? (() => /* @__PURE__ */ new Date())
12942
+ now: dependencies.now ?? (() => /* @__PURE__ */ new Date()),
12943
+ memoryUsage: dependencies.memoryUsage ?? (() => process.memoryUsage()),
12944
+ totalMemory: dependencies.totalMemory ?? totalmem
12834
12945
  };
12835
12946
  }
12836
12947
  //#endregion
@@ -12986,15 +13097,26 @@ async function crawlSite(input, dependencies) {
12986
13097
  let queuedUrls = 0;
12987
13098
  let skippedUrls = 0;
12988
13099
  const skipReasonCounts = {};
12989
- const recordSkip = (reason) => {
12990
- skippedUrls += 1;
12991
- skipReasonCounts[reason] = (skipReasonCounts[reason] ?? 0) + 1;
13100
+ const recordSkips = (reason, count = 1) => {
13101
+ if (count <= 0) return;
13102
+ skippedUrls += count;
13103
+ skipReasonCounts[reason] = (skipReasonCounts[reason] ?? 0) + count;
12992
13104
  };
13105
+ const recordSkip = (reason) => recordSkips(reason);
12993
13106
  let robotsDeferredUrls = 0;
12994
13107
  let originBackpressureSkippedUrls = 0;
12995
13108
  let queueSafetySkippedUrls = 0;
12996
13109
  let failedUrls = 0;
12997
13110
  let observedInternalLinks = 0;
13111
+ let memoryLimited = false;
13112
+ const memoryDeferredUrls = /* @__PURE__ */ new Set();
13113
+ const checkMemoryPressure = () => {
13114
+ if (memoryLimited) return;
13115
+ memoryLimited = crawlMemoryPressure({
13116
+ memoryUsage: deps.memoryUsage(),
13117
+ totalMemoryBytes: deps.totalMemory()
13118
+ });
13119
+ };
12998
13120
  let sitemapDiscovery;
12999
13121
  let externalLinkVerification;
13000
13122
  let statusEventChain = Promise.resolve();
@@ -13124,6 +13246,7 @@ async function crawlSite(input, dependencies) {
13124
13246
  for (const url of sitemap.urls) enqueue(url, 0);
13125
13247
  sitemapDiscovery = sitemap.discovery;
13126
13248
  }
13249
+ checkMemoryPressure();
13127
13250
  const nextQueueItem = () => {
13128
13251
  while (queue.size) {
13129
13252
  const item = queue.take();
@@ -13149,6 +13272,7 @@ async function crawlSite(input, dependencies) {
13149
13272
  timeoutMs: config.timeoutMs,
13150
13273
  rate: config.fetchRate,
13151
13274
  signal,
13275
+ writeCache: false,
13152
13276
  respectRobots: config.respectRobots,
13153
13277
  robotsResolver,
13154
13278
  renderer
@@ -13176,8 +13300,8 @@ async function crawlSite(input, dependencies) {
13176
13300
  depth: item.depth
13177
13301
  });
13178
13302
  };
13179
- while (!isCancelled() && (queue.size || inFlight.size) && pages.length < config.maxPages) {
13180
- while (!isCancelled() && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
13303
+ while (!isCancelled() && (queue.size || inFlight.size) && (!memoryLimited || inFlight.size > 0) && pages.length < config.maxPages) {
13304
+ while (!isCancelled() && !memoryLimited && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
13181
13305
  const item = nextQueueItem();
13182
13306
  if (!item) break;
13183
13307
  startTask(item);
@@ -13205,6 +13329,7 @@ async function crawlSite(input, dependencies) {
13205
13329
  healthyProbeStreak = 0;
13206
13330
  activeConcurrency = 1;
13207
13331
  }
13332
+ checkMemoryPressure();
13208
13333
  if (request.outcome === "skipped") {
13209
13334
  recordSkip(request.reason === "robots-disallowed" ? "robots-disallowed" : request.reason === "robots-deferred" ? "robots-uncertain" : "origin-backpressure");
13210
13335
  if (request.reason === "robots-deferred") {
@@ -13335,10 +13460,24 @@ async function crawlSite(input, dependencies) {
13335
13460
  depth: task.depth,
13336
13461
  statusCode: page.status
13337
13462
  });
13338
- if (followLinks && task.depth < config.maxDepth) for (const url of result.urls) enqueue(url, task.depth + 1);
13463
+ if (followLinks && task.depth < config.maxDepth) if (memoryLimited) for (const value of result.urls) {
13464
+ const normalized = normalizeUrl$1(value, finalUrl);
13465
+ if (normalized && sameOrigin(normalized, origin) && !isLikelyAsset(normalized) && passesFilters(normalized, config.include, config.exclude) && !visited.has(normalized) && !queue.has(normalized)) {
13466
+ discovered.add(normalized);
13467
+ memoryDeferredUrls.add(normalized);
13468
+ }
13469
+ }
13470
+ else for (const url of result.urls) enqueue(url, task.depth + 1);
13471
+ }
13472
+ const memoryPressureSkippedUrls = memoryLimited ? queue.size + memoryDeferredUrls.size : 0;
13473
+ const stoppedForMemory = memoryPressureSkippedUrls > 0;
13474
+ if (stoppedForMemory) {
13475
+ recordSkips("memory-pressure", memoryPressureSkippedUrls);
13476
+ warnings.push("Crawl stopped before local memory use could become unsafe. The retained report is partial.");
13339
13477
  }
13340
13478
  const resolvedInlinkCounts = resolveLinkCountAliases(incomingLinkCounts, fetchedAliases);
13341
- const maxInlinks = Math.max(0, ...pages.map((page) => resolvedInlinkCounts.get(page.finalUrl) ?? 0));
13479
+ let maxInlinks = 0;
13480
+ for (const page of pages) maxInlinks = Math.max(maxInlinks, resolvedInlinkCounts.get(page.finalUrl) ?? 0);
13342
13481
  for (const page of pages) {
13343
13482
  const internalInlinkCount = resolvedInlinkCounts.get(page.finalUrl) ?? 0;
13344
13483
  page.internalInlinkCount = internalInlinkCount;
@@ -13358,11 +13497,11 @@ async function crawlSite(input, dependencies) {
13358
13497
  const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
13359
13498
  const accessEvidencePartial = requests.some((request) => request.outcome === "response" && request.accessBlock);
13360
13499
  const requiredSitemapUnavailable = config.mode === "sitemap" && sitemapDiscovery?.dataStatus === "unavailable";
13361
- const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.size > 0 || inFlight.size > 0 || sitemapEvidencePartial || accessEvidencePartial || requiredSitemapUnavailable;
13500
+ const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.size > 0 || inFlight.size > 0 || stoppedForMemory || sitemapEvidencePartial || accessEvidencePartial || requiredSitemapUnavailable;
13362
13501
  const failed = !cancelled && pages.length === 0 && (requiredSitemapUnavailable || requests.length > 0 && requests.every((request) => request.outcome === "failure"));
13363
13502
  const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
13364
13503
  const dataSources = await crawlDataSources({
13365
- cancelled,
13504
+ skipReason: cancelled ? "cancelled" : stoppedForMemory ? "memory-pressure" : void 0,
13366
13505
  site: healthStrategy ? void 0 : site,
13367
13506
  googleAnalyticsPropertyId: healthStrategy ? void 0 : input.googleAnalyticsPropertyId,
13368
13507
  pages,
@@ -13381,7 +13520,7 @@ async function crawlSite(input, dependencies) {
13381
13520
  });
13382
13521
  const sourceEvidencePartial = [dataSources.searchConsole.status, dataSources.analytics.status].some((status) => status === "partial" || status === "unavailable");
13383
13522
  const reportStatus = failed ? "failed" : partial || sourceEvidencePartial ? "partial" : "completed";
13384
- if (!cancelled && config.checkExternal) {
13523
+ if (!cancelled && !stoppedForMemory && config.checkExternal) {
13385
13524
  emitStatus("external_links_started", {
13386
13525
  url: config.url,
13387
13526
  message: "Started external link checks."
@@ -13398,7 +13537,7 @@ async function crawlSite(input, dependencies) {
13398
13537
  message: "Finished external link checks."
13399
13538
  });
13400
13539
  }
13401
- const agentDiscovery = !cancelled && config.checkAgentDiscovery ? await deps.collectAgentDiscovery({
13540
+ const agentDiscovery = !cancelled && !stoppedForMemory && config.checkAgentDiscovery ? await deps.collectAgentDiscovery({
13402
13541
  startUrl: config.url,
13403
13542
  pages,
13404
13543
  timeoutMs: config.timeoutMs,
@@ -13439,7 +13578,8 @@ async function crawlSite(input, dependencies) {
13439
13578
  pageLimitReached,
13440
13579
  maxPages: config.maxPages,
13441
13580
  queueSafetySkippedUrls,
13442
- originBackpressureSkippedUrls
13581
+ originBackpressureSkippedUrls,
13582
+ memoryPressureSkippedUrls
13443
13583
  }),
13444
13584
  stats: {
13445
13585
  discoveredUrls: discovered.size,