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/index.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, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
- import { arch, homedir, platform } from "node:os";
5
+ import { arch, homedir, platform, totalmem } from "node:os";
6
6
  import { basename, dirname, join } from "node:path";
7
7
  import { z } from "zod";
8
8
  import lockfile from "proper-lockfile";
@@ -1082,15 +1082,15 @@ async function exchangeCode(input) {
1082
1082
  }
1083
1083
  //#endregion
1084
1084
  //#region packages/core/src/storage/cache-maintenance.ts
1085
- const MEBIBYTE$1 = 1024 * 1024;
1086
- const CACHE_MAX_BYTES = 256 * MEBIBYTE$1;
1085
+ const MEBIBYTE$2 = 1024 * 1024;
1086
+ const CACHE_MAX_BYTES = 256 * MEBIBYTE$2;
1087
1087
  const CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
1088
1088
  const CACHE_LIMITS = {
1089
- gsc_cache: 48 * MEBIBYTE$1,
1090
- ga4_cache: 24 * MEBIBYTE$1,
1091
- semrush_cache: 16 * MEBIBYTE$1,
1092
- http_cache: 160 * MEBIBYTE$1,
1093
- performance_reports: 8 * MEBIBYTE$1
1089
+ gsc_cache: 48 * MEBIBYTE$2,
1090
+ ga4_cache: 24 * MEBIBYTE$2,
1091
+ semrush_cache: 16 * MEBIBYTE$2,
1092
+ http_cache: 160 * MEBIBYTE$2,
1093
+ performance_reports: 8 * MEBIBYTE$2
1094
1094
  };
1095
1095
  const CACHE_POLICIES = [
1096
1096
  {
@@ -2963,7 +2963,7 @@ function isValidStructuredDate(value) {
2963
2963
  const parsed = new Date(trimmed);
2964
2964
  return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === datePart;
2965
2965
  }
2966
- function parseJsonLd(blocks, baseUrl) {
2966
+ function parseJsonLd(blocks, baseUrl, retainJsonLd) {
2967
2967
  const jsonLd = [];
2968
2968
  const invalidJsonLdSamples = [];
2969
2969
  const unrecognizedJsonLdTypes = [];
@@ -3035,7 +3035,7 @@ function parseJsonLd(blocks, baseUrl) {
3035
3035
  blocks.forEach((block, index) => {
3036
3036
  try {
3037
3037
  const parsed = JSON.parse(block);
3038
- jsonLd.push(parsed);
3038
+ if (retainJsonLd) jsonLd.push(parsed);
3039
3039
  visit(parsed, { prefixes: /* @__PURE__ */ new Map() }, index, "$");
3040
3040
  } catch (error) {
3041
3041
  invalidJsonLdSamples.push({
@@ -3096,8 +3096,8 @@ function usableElementValue($, selector) {
3096
3096
  return ($(element).attr("content") ?? $(element).attr("datetime") ?? $(element).text()).trim().length > 0;
3097
3097
  });
3098
3098
  }
3099
- function extractStructuredData($, baseUrl) {
3100
- const parsed = parseJsonLd($("script[type=\"application/ld+json\"]").toArray().map((element) => $(element).html() ?? ""), baseUrl);
3099
+ function extractStructuredData($, baseUrl, options = {}) {
3100
+ const parsed = parseJsonLd($("script[type=\"application/ld+json\"]").toArray().map((element) => $(element).html() ?? ""), baseUrl, options.retainJsonLd ?? true);
3101
3101
  const microdata = microdataTypes($);
3102
3102
  const rdfa = rdfaTypes($);
3103
3103
  const schemaTypes = [.../* @__PURE__ */ new Set([
@@ -3131,6 +3131,16 @@ function extractStructuredData($, baseUrl) {
3131
3131
  }
3132
3132
  //#endregion
3133
3133
  //#region packages/core/src/extract/page-extractor.ts
3134
+ const SOCIAL_PROFILE_HOSTS = [
3135
+ "facebook.com",
3136
+ "instagram.com",
3137
+ "linkedin.com",
3138
+ "pinterest.com",
3139
+ "tiktok.com",
3140
+ "twitter.com",
3141
+ "x.com",
3142
+ "youtube.com"
3143
+ ];
3134
3144
  function safeText$1(value) {
3135
3145
  const trimmed = value?.replace(/\s+/g, " ").trim();
3136
3146
  return trimmed ? trimmed : void 0;
@@ -3203,6 +3213,28 @@ function imageDimensionsFromUrl(src) {
3203
3213
  height
3204
3214
  };
3205
3215
  }
3216
+ function addAnchorSample(samples, seen, href, text) {
3217
+ if (samples.length >= 25 || !text) return;
3218
+ const sampleText = text.length > 120 ? text.slice(0, 120).trimEnd() : text;
3219
+ const key = `${href}\n${sampleText}`;
3220
+ if (seen.has(key)) return;
3221
+ seen.add(key);
3222
+ samples.push({
3223
+ href,
3224
+ text: sampleText
3225
+ });
3226
+ }
3227
+ function addSocialProfile(profiles, href) {
3228
+ if (profiles.size >= 50) return;
3229
+ try {
3230
+ const url = new URL(href);
3231
+ const host = url.hostname.replace(/^www\./, "").toLowerCase();
3232
+ if (SOCIAL_PROFILE_HOSTS.some((value) => host === value || host.endsWith(`.${value}`))) {
3233
+ url.hash = "";
3234
+ profiles.add(url.toString());
3235
+ }
3236
+ } catch {}
3237
+ }
3206
3238
  async function extractPage(fetchResult, extractor = "defuddle", dependencies = {}) {
3207
3239
  const $ = extractor === "crawler" && /<html(?:\s|>)/i.test(fetchResult.html) ? load(fetchResult.html, { xml: {
3208
3240
  xmlMode: false,
@@ -3210,32 +3242,69 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3210
3242
  } }) : load(fetchResult.html);
3211
3243
  const url = new URL(fetchResult.finalUrl);
3212
3244
  const canonicalEvidence = extractCanonicalEvidence($, fetchResult.headers, fetchResult.finalUrl);
3213
- const headings = $("h1, h2, h3, h4, h5, h6").toArray().map((element) => ({
3214
- level: Number.parseInt(element.tagName.slice(1), 10),
3215
- text: $(element).text().replace(/\s+/g, " ").trim()
3216
- })).filter((heading) => heading.text.length > 0);
3245
+ const headings = [];
3246
+ let crawlerH1;
3247
+ let crawlerH1Count = 0;
3248
+ let crawlerH2Count = 0;
3249
+ let crawlerH3Count = 0;
3250
+ let questionHeadings = 0;
3251
+ $("h1, h2, h3, h4, h5, h6").each((_index, element) => {
3252
+ const level = Number.parseInt(element.tagName.slice(1), 10);
3253
+ const text = $(element).text().replace(/\s+/g, " ").trim();
3254
+ if (!text) return;
3255
+ if (text.trimEnd().endsWith("?")) questionHeadings += 1;
3256
+ if (level === 1) {
3257
+ crawlerH1 ??= text;
3258
+ crawlerH1Count += 1;
3259
+ } else if (level === 2) crawlerH2Count += 1;
3260
+ else if (level === 3) crawlerH3Count += 1;
3261
+ if (extractor !== "crawler") headings.push({
3262
+ level,
3263
+ text
3264
+ });
3265
+ });
3217
3266
  const links = [];
3267
+ const crawlerInternalLinks = /* @__PURE__ */ new Set();
3268
+ const crawlerExternalLinks = /* @__PURE__ */ new Set();
3269
+ const internalAnchorSamples = [];
3270
+ const externalAnchorSamples = [];
3271
+ const internalAnchorKeys = /* @__PURE__ */ new Set();
3272
+ const externalAnchorKeys = /* @__PURE__ */ new Set();
3273
+ const socialProfileLinks = /* @__PURE__ */ new Set();
3218
3274
  let invalidLinkCount = 0;
3219
3275
  let unsupportedLinkCount = 0;
3220
- for (const element of $("a[href]").toArray()) {
3276
+ $("a[href]").each((_index, element) => {
3221
3277
  const href = $(element).attr("href") ?? "";
3222
3278
  try {
3223
3279
  const absolute = new URL(href, fetchResult.finalUrl);
3224
3280
  if (!["http:", "https:"].includes(absolute.protocol)) {
3225
3281
  unsupportedLinkCount += 1;
3226
- continue;
3282
+ return;
3227
3283
  }
3228
- links.push({
3229
- href: absolute.toString(),
3230
- text: $(element).text().replace(/\s+/g, " ").trim(),
3284
+ const absoluteHref = absolute.toString();
3285
+ const text = $(element).text().replace(/\s+/g, " ").trim();
3286
+ const internal = absolute.host === url.host;
3287
+ if (extractor === "crawler") {
3288
+ if (absolute.origin === url.origin) {
3289
+ absolute.hash = "";
3290
+ crawlerInternalLinks.add(absolute.toString());
3291
+ }
3292
+ if (!internal) {
3293
+ crawlerExternalLinks.add(absoluteHref);
3294
+ addSocialProfile(socialProfileLinks, absoluteHref);
3295
+ }
3296
+ addAnchorSample(internal ? internalAnchorSamples : externalAnchorSamples, internal ? internalAnchorKeys : externalAnchorKeys, absoluteHref, text);
3297
+ } else links.push({
3298
+ href: absoluteHref,
3299
+ text,
3231
3300
  rel: ($(element).attr("rel") ?? "").split(/\s+/).filter(Boolean),
3232
- internal: absolute.host === url.host,
3301
+ internal,
3233
3302
  location: $(element).closest("nav, header").length ? "navigation" : $(element).closest("footer").length ? "footer" : $(element).closest("main, article").length ? "main-content" : "other"
3234
3303
  });
3235
3304
  } catch {
3236
3305
  invalidLinkCount += 1;
3237
3306
  }
3238
- }
3307
+ });
3239
3308
  const hreflang = [];
3240
3309
  let invalidHreflangCount = 0;
3241
3310
  for (const element of $("link[rel~=\"alternate\"][hreflang][href]").toArray()) {
@@ -3250,25 +3319,29 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3250
3319
  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();
3251
3320
  const openGraph = Object.fromEntries($("meta[property^=\"og:\"]").toArray().map((element) => [$(element).attr("property") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
3252
3321
  const twitter = Object.fromEntries($("meta[name^=\"twitter:\"]").toArray().map((element) => [$(element).attr("name") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
3253
- const structuredData = extractStructuredData($, fetchResult.finalUrl);
3322
+ const structuredData = extractStructuredData($, fetchResult.finalUrl, { retainJsonLd: extractor !== "crawler" });
3254
3323
  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")));
3255
3324
  const author = safeText$1($("meta[name=\"author\"]").attr("content"));
3256
3325
  const hasAuthor = Boolean(author) || structuredData.hasAuthor || $("[rel~=author], .author, .byline").toArray().some((element) => Boolean(safeText$1($(element).text())));
3257
3326
  const semanticHtml = $("main, article").length > 0;
3258
- const questionHeadings = headings.filter((heading) => heading.text.trimEnd().endsWith("?")).length;
3259
3327
  const listCount = $("ul, ol").length;
3260
3328
  const tableCount = $("table").length;
3261
3329
  const structuredBlocks = listCount + tableCount;
3262
3330
  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);
3263
- const imageElements = $("img").toArray();
3264
- const oversizedImageCandidates = imageElements.map((element) => {
3331
+ let imagesTotal = 0;
3332
+ let imagesMissingAlt = 0;
3333
+ const oversizedImageCandidates = [];
3334
+ $("img").each((_index, element) => {
3335
+ imagesTotal += 1;
3336
+ if ($(element).attr("alt") === void 0) imagesMissingAlt += 1;
3337
+ if (oversizedImageCandidates.length >= 25) return;
3265
3338
  const src = absoluteUrl$2($(element).attr("src"), fetchResult.finalUrl);
3266
- if (!src) return void 0;
3339
+ if (!src) return;
3267
3340
  const width = numericAttribute($(element).attr("width"));
3268
3341
  const height = numericAttribute($(element).attr("height"));
3269
3342
  const srcsetWidth = largestSrcsetWidth($(element).attr("srcset"));
3270
3343
  const filenameDimensions = imageDimensionsFromUrl(src);
3271
- if (Math.max(width ?? 0, height ?? 0, srcsetWidth ?? 0, filenameDimensions?.width ?? 0, filenameDimensions?.height ?? 0) < 2e3) return void 0;
3344
+ if (Math.max(width ?? 0, height ?? 0, srcsetWidth ?? 0, filenameDimensions?.width ?? 0, filenameDimensions?.height ?? 0) < 2e3) return;
3272
3345
  const detectedFrom = [
3273
3346
  width && width >= 2e3 ? "width" : void 0,
3274
3347
  height && height >= 2e3 ? "height" : void 0,
@@ -3277,13 +3350,13 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3277
3350
  ].filter(Boolean).join(",");
3278
3351
  const candidateWidth = Math.max(width ?? 0, srcsetWidth ?? 0, filenameDimensions?.width ?? 0);
3279
3352
  const candidateHeight = Math.max(height ?? 0, filenameDimensions?.height ?? 0);
3280
- return {
3353
+ oversizedImageCandidates.push({
3281
3354
  src,
3282
3355
  detectedFrom,
3283
3356
  ...candidateWidth > 0 ? { width: candidateWidth } : {},
3284
3357
  ...candidateHeight > 0 ? { height: candidateHeight } : {}
3285
- };
3286
- }).filter((candidate) => Boolean(candidate));
3358
+ });
3359
+ });
3287
3360
  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:"))] : [];
3288
3361
  const metaRobots = combineRobotsValues($("meta[name]").toArray().filter((element) => {
3289
3362
  const name = $(element).attr("name")?.trim().toLowerCase();
@@ -3307,6 +3380,18 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3307
3380
  hasViewport: Boolean($("meta[name=\"viewport\"]").attr("content")),
3308
3381
  headings,
3309
3382
  links,
3383
+ ...extractor === "crawler" ? { crawlerEvidence: {
3384
+ h1: crawlerH1,
3385
+ h1Count: crawlerH1Count,
3386
+ h2Count: crawlerH2Count,
3387
+ h3Count: crawlerH3Count,
3388
+ questionHeadings,
3389
+ internalLinks: [...crawlerInternalLinks],
3390
+ externalLinks: [...crawlerExternalLinks],
3391
+ internalAnchorSamples,
3392
+ externalAnchorSamples,
3393
+ socialProfileLinks: [...socialProfileLinks]
3394
+ } } : {},
3310
3395
  markdownAlternates,
3311
3396
  hreflang,
3312
3397
  jsonLd: structuredData.jsonLd,
@@ -3315,19 +3400,17 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3315
3400
  unrecognizedJsonLdTypes: structuredData.unrecognizedJsonLdTypes.slice(0, 25),
3316
3401
  structuredDataFormats: structuredData.formats,
3317
3402
  googleRichResults: structuredData.googleRichResults,
3318
- schemaSameAsEvidence: structuredData.sameAs,
3319
- invalidSchemaSameAs: structuredData.invalidSameAs,
3403
+ schemaSameAsEvidence: extractor === "crawler" ? structuredData.sameAs.slice(0, 50) : structuredData.sameAs,
3404
+ invalidSchemaSameAs: extractor === "crawler" ? structuredData.invalidSameAs.slice(0, 25) : structuredData.invalidSameAs,
3320
3405
  schemaTypes: structuredData.schemaTypes,
3321
3406
  openGraph,
3322
3407
  twitter,
3323
3408
  author,
3324
3409
  hasAuthor,
3325
3410
  hasDate,
3326
- imagesTotal: imageElements.length,
3327
- imagesMissingAlt: imageElements.filter((element) => {
3328
- return $(element).attr("alt") === void 0;
3329
- }).length,
3330
- oversizedImageCandidates: oversizedImageCandidates.slice(0, 25),
3411
+ imagesTotal,
3412
+ imagesMissingAlt,
3413
+ oversizedImageCandidates,
3331
3414
  mixedContentUrls,
3332
3415
  semanticHtml,
3333
3416
  questionHeadings,
@@ -3335,7 +3418,7 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
3335
3418
  tableCount,
3336
3419
  structuredBlocks,
3337
3420
  answerable,
3338
- contentText: text.replace(/\s+/g, " ").trim(),
3421
+ contentText: extractor === "crawler" ? text : text.replace(/\s+/g, " ").trim(),
3339
3422
  excerpt,
3340
3423
  wordCount: content.wordCount,
3341
3424
  contentExtraction: content.diagnostics,
@@ -3867,7 +3950,7 @@ function decodePageFetchCacheEvidence(value) {
3867
3950
  return;
3868
3951
  }
3869
3952
  }
3870
- async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, respectRobots = false) {
3953
+ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, respectRobots = false, writeCache = true) {
3871
3954
  const startedAt = Date.now();
3872
3955
  const db = getDb();
3873
3956
  const key = hashKey(["page", url]);
@@ -3979,7 +4062,7 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
3979
4062
  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}.`] : []],
3980
4063
  robotsTxt: pageRobotsEvidence(robots)
3981
4064
  };
3982
- if (!accessBlock) {
4065
+ if (!accessBlock && writeCache) {
3983
4066
  db.prepare(`INSERT OR REPLACE INTO http_cache
3984
4067
  (url_hash, url, status, headers_json, body_blob, metadata_json, etag, fetched_at, expires_at)
3985
4068
  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);
@@ -4547,7 +4630,7 @@ async function fetchPage(url, opts = {}, dependencies = {}) {
4547
4630
  const makeRenderer = dependencies.createPageRenderer ?? createPageRenderer;
4548
4631
  const rate = normalizeRateControls(opts.rate);
4549
4632
  return await queueForHost(new URL(url).host, rate).add(async () => {
4550
- const first = await plainFetch(url, opts.refresh, opts.timeoutMs, rate, opts.signal, opts.respectRobots);
4633
+ const first = await plainFetch(url, opts.refresh, opts.timeoutMs, rate, opts.signal, opts.respectRobots, opts.writeCache);
4551
4634
  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");
4552
4635
  const ownsRenderer = !opts.renderer;
4553
4636
  const renderer = opts.renderer ?? makeRenderer();
@@ -10958,6 +11041,7 @@ const CRAWL_SKIP_REASONS = [
10958
11041
  "configured-exclusion",
10959
11042
  "duplicate-normalization",
10960
11043
  "invalid-url",
11044
+ "memory-pressure",
10961
11045
  "off-origin",
10962
11046
  "origin-backpressure",
10963
11047
  "queue-safety-limit",
@@ -10970,6 +11054,7 @@ const SKIP_IMPACT = {
10970
11054
  "configured-exclusion": "non-impacting",
10971
11055
  "duplicate-normalization": "non-impacting",
10972
11056
  "invalid-url": "non-impacting",
11057
+ "memory-pressure": "coverage-affecting",
10973
11058
  "off-origin": "non-impacting",
10974
11059
  "origin-backpressure": "coverage-affecting",
10975
11060
  "queue-safety-limit": "coverage-affecting",
@@ -11148,17 +11233,44 @@ function sanitizeTenantString(value) {
11148
11233
  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]");
11149
11234
  }
11150
11235
  function sanitizeTenantValue(value, key) {
11151
- if (key && isSensitiveFieldName(key)) return "[redacted]";
11236
+ if (key && isSensitiveFieldName(key)) return value === "[redacted]" ? value : "[redacted]";
11152
11237
  if (typeof value === "string") return sanitizeTenantString(value);
11153
- if (Array.isArray(value)) return value.map((item) => sanitizeTenantValue(item));
11238
+ if (Array.isArray(value)) {
11239
+ let sanitized;
11240
+ for (let index = 0; index < value.length; index += 1) {
11241
+ const item = sanitizeTenantValue(value[index]);
11242
+ if (sanitized) sanitized.push(item);
11243
+ else if (item !== value[index]) sanitized = [...value.slice(0, index), item];
11244
+ }
11245
+ return sanitized ?? value;
11246
+ }
11154
11247
  if (!value || typeof value !== "object") return value;
11155
- return Object.fromEntries(Object.entries(value).map(([entryKey, entry]) => [entryKey, sanitizeTenantValue(entry, entryKey)]));
11248
+ const record = value;
11249
+ let sanitized;
11250
+ for (const [entryKey, entry] of Object.entries(record)) {
11251
+ const safeEntry = sanitizeTenantValue(entry, entryKey);
11252
+ if (safeEntry === entry) continue;
11253
+ sanitized ??= { ...record };
11254
+ sanitized[entryKey] = safeEntry;
11255
+ }
11256
+ return sanitized ?? value;
11156
11257
  }
11157
11258
  function sanitizeCrawlConfig(config) {
11158
11259
  return sanitizeTenantValue(config);
11159
11260
  }
11160
11261
  function sanitizePages(pages) {
11161
- return sanitizeTenantValue(pages).map(({ seoScore: _seoScore, geoScore: _geoScore, ...page }) => page);
11262
+ const safePages = sanitizeTenantValue(pages);
11263
+ let normalized;
11264
+ for (let index = 0; index < safePages.length; index += 1) {
11265
+ const page = safePages[index];
11266
+ if (!page) continue;
11267
+ if ("seoScore" in page || "geoScore" in page) {
11268
+ normalized ??= safePages.slice(0, index);
11269
+ const { seoScore: _seoScore, geoScore: _geoScore, ...current } = page;
11270
+ normalized.push(current);
11271
+ } else if (normalized) normalized.push(page);
11272
+ }
11273
+ return normalized ?? safePages;
11162
11274
  }
11163
11275
  function sanitizeIssues(issues) {
11164
11276
  return sanitizeTenantValue(issues);
@@ -11187,18 +11299,28 @@ function summarizeCrawlReport(input) {
11187
11299
  const byCategory = {};
11188
11300
  let responseMs = 0;
11189
11301
  let responseCount = 0;
11302
+ let statusOnlyPages = 0;
11303
+ let indexablePages = 0;
11304
+ let nonIndexablePages = 0;
11305
+ let statusErrors = 0;
11190
11306
  for (const page of input.pages) {
11191
11307
  byStatus[String(page.status)] = (byStatus[String(page.status)] ?? 0) + 1;
11192
11308
  if (typeof page.responseTimeMs === "number") {
11193
11309
  responseMs += page.responseTimeMs;
11194
11310
  responseCount += 1;
11195
11311
  }
11312
+ if (page.auditScope === "status") statusOnlyPages += 1;
11313
+ else if (page.indexable) indexablePages += 1;
11314
+ else nonIndexablePages += 1;
11315
+ if (page.status === 0 || page.status >= 400) statusErrors += 1;
11196
11316
  }
11197
11317
  const requests = input.requests ?? [];
11198
- const failedRequests = requests.filter((request) => request.outcome === "failure" && request.failureKind !== "aborted");
11199
- const abortedRequests = requests.filter((request) => request.outcome === "failure" && request.failureKind === "aborted");
11200
11318
  let requestMs = 0;
11201
11319
  let requestMsCount = 0;
11320
+ let responseRequests = 0;
11321
+ let failedRequests = 0;
11322
+ let abortedRequests = 0;
11323
+ let extractionFailures = 0;
11202
11324
  for (const request of requests) {
11203
11325
  const status = request.outcome === "response" ? String(request.status) : request.outcome === "skipped" ? request.reason : request.failureKind === "aborted" ? "aborted" : "no-response";
11204
11326
  requestByStatus[status] = (requestByStatus[status] ?? 0) + 1;
@@ -11206,27 +11328,39 @@ function summarizeCrawlReport(input) {
11206
11328
  requestMs += request.durationMs;
11207
11329
  requestMsCount += 1;
11208
11330
  }
11331
+ if (request.outcome === "response") {
11332
+ responseRequests += 1;
11333
+ if (request.extraction === "failed") extractionFailures += 1;
11334
+ } else if (request.outcome === "failure") if (request.failureKind === "aborted") abortedRequests += 1;
11335
+ else failedRequests += 1;
11336
+ }
11337
+ let highIssues = 0;
11338
+ let mediumIssues = 0;
11339
+ let lowIssues = 0;
11340
+ for (const issue of input.issues) {
11341
+ byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
11342
+ if (issue.severity === "high") highIssues += 1;
11343
+ else if (issue.severity === "medium") mediumIssues += 1;
11344
+ else lowIssues += 1;
11209
11345
  }
11210
- for (const issue of input.issues) byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
11211
11346
  const runStats = crawlRunStats(input.pages, input.stats);
11212
- const indexabilityPages = input.pages.filter((page) => page.auditScope !== "status");
11213
11347
  return {
11214
11348
  totalPages: input.pages.length,
11215
- statusOnlyPages: input.pages.filter((page) => page.auditScope === "status").length,
11216
- indexablePages: indexabilityPages.filter((page) => page.indexable).length,
11217
- nonIndexablePages: indexabilityPages.filter((page) => !page.indexable).length,
11218
- statusErrors: input.pages.filter((page) => page.status === 0 || page.status >= 400).length,
11349
+ statusOnlyPages,
11350
+ indexablePages,
11351
+ nonIndexablePages,
11352
+ statusErrors,
11219
11353
  ...runStats,
11220
11354
  attemptedRequests: input.requestEvidenceStatus === "unavailable" ? 0 : requests.length,
11221
- responseRequests: requests.filter((request) => request.outcome === "response").length,
11222
- failedRequests: failedRequests.length,
11223
- abortedRequests: abortedRequests.length,
11224
- extractionFailures: requests.filter((request) => request.outcome === "response" && request.extraction === "failed").length,
11355
+ responseRequests,
11356
+ failedRequests,
11357
+ abortedRequests,
11358
+ extractionFailures,
11225
11359
  requestByStatus,
11226
11360
  avgRequestMs: requestMsCount ? Math.round(requestMs / requestMsCount) : void 0,
11227
- highIssues: input.issues.filter((issue) => issue.severity === "high").length,
11228
- mediumIssues: input.issues.filter((issue) => issue.severity === "medium").length,
11229
- lowIssues: input.issues.filter((issue) => issue.severity === "low").length,
11361
+ highIssues,
11362
+ mediumIssues,
11363
+ lowIssues,
11230
11364
  avgResponseMs: responseCount ? Math.round(responseMs / responseCount) : void 0,
11231
11365
  byStatus,
11232
11366
  byCategory
@@ -11234,42 +11368,55 @@ function summarizeCrawlReport(input) {
11234
11368
  }
11235
11369
  const ACCESS_BLOCK_SAMPLE_LIMIT = 10;
11236
11370
  function crawlAccessSummary(requests) {
11237
- const blocked = requests.flatMap((request) => request.outcome === "response" && request.accessBlock ? [{
11238
- url: request.requestedUrl,
11239
- evidence: request.accessBlock
11240
- }] : []);
11241
11371
  const providers = {};
11242
- for (const item of blocked) providers[item.evidence.provider] = (providers[item.evidence.provider] ?? 0) + 1;
11372
+ const samples = [];
11373
+ let blockedRequests = 0;
11374
+ for (const request of requests) {
11375
+ if (request.outcome !== "response" || !request.accessBlock) continue;
11376
+ blockedRequests += 1;
11377
+ providers[request.accessBlock.provider] = (providers[request.accessBlock.provider] ?? 0) + 1;
11378
+ if (samples.length < ACCESS_BLOCK_SAMPLE_LIMIT) samples.push({
11379
+ url: request.requestedUrl,
11380
+ evidence: request.accessBlock
11381
+ });
11382
+ }
11243
11383
  return {
11244
11384
  crawler: SEO_CRAWLER_IDENTITY,
11245
- blockedRequests: blocked.length,
11385
+ blockedRequests,
11246
11386
  providers,
11247
- samples: blocked.slice(0, ACCESS_BLOCK_SAMPLE_LIMIT),
11387
+ samples,
11248
11388
  sampleLimit: ACCESS_BLOCK_SAMPLE_LIMIT,
11249
- truncated: blocked.length > ACCESS_BLOCK_SAMPLE_LIMIT
11389
+ truncated: blockedRequests > ACCESS_BLOCK_SAMPLE_LIMIT
11250
11390
  };
11251
11391
  }
11252
11392
  function crawlRunStats(pages, stats = {}) {
11253
- const discovered = /* @__PURE__ */ new Set();
11393
+ const needsDiscoveredUrls = stats.discoveredUrls === void 0;
11394
+ const needsObservedInternalLinks = stats.observedInternalLinks === void 0;
11395
+ const needsFailedUrls = stats.failedUrls === void 0;
11396
+ const discovered = needsDiscoveredUrls ? /* @__PURE__ */ new Set() : void 0;
11254
11397
  let observedInternalLinks = 0;
11255
- for (const page of pages) {
11256
- discovered.add(page.url);
11257
- for (const url of page.sampleInternalLinks ?? []) discovered.add(url);
11258
- for (const url of page.sampleExternalLinks ?? []) discovered.add(url);
11259
- observedInternalLinks += page.outgoingInternalCount;
11398
+ let failedUrls = 0;
11399
+ if (needsDiscoveredUrls || needsObservedInternalLinks || needsFailedUrls) for (const page of pages) {
11400
+ if (discovered) {
11401
+ discovered.add(page.url);
11402
+ for (const url of page.sampleInternalLinks ?? []) discovered.add(url);
11403
+ for (const url of page.sampleExternalLinks ?? []) discovered.add(url);
11404
+ }
11405
+ if (needsObservedInternalLinks) observedInternalLinks += page.outgoingInternalCount;
11406
+ if (needsFailedUrls && (page.status === 0 || page.status >= 400)) failedUrls += 1;
11260
11407
  }
11261
11408
  const skips = normalizeCrawlSkipReasonCounts({
11262
11409
  skippedUrls: stats.skippedUrls,
11263
11410
  skipReasons: stats.skipReasons ?? crawlSkipReasonCountsFromRecord(stats.skipReasonCounts)
11264
11411
  });
11265
11412
  return {
11266
- discoveredUrls: stats.discoveredUrls ?? discovered.size,
11413
+ discoveredUrls: stats.discoveredUrls ?? discovered?.size ?? 0,
11267
11414
  queuedUrls: stats.queuedUrls ?? pages.length,
11268
11415
  crawledUrls: stats.crawledUrls ?? pages.length,
11269
11416
  skippedUrls: skips.skippedUrls,
11270
11417
  skipReasons: skips.skipReasons,
11271
11418
  skippedUrlsByImpact: skips.skippedUrlsByImpact,
11272
- failedUrls: stats.failedUrls ?? pages.filter((page) => page.status === 0 || page.status >= 400).length,
11419
+ failedUrls: stats.failedUrls ?? failedUrls,
11273
11420
  observedInternalLinks: stats.observedInternalLinks ?? observedInternalLinks,
11274
11421
  pageLimitReached: stats.pageLimitReached ?? false
11275
11422
  };
@@ -11286,11 +11433,14 @@ function normalizeLinkUrl(value) {
11286
11433
  function deriveInternalLinkAuthority(pages, linkGraph) {
11287
11434
  const hasLinkGraph = linkGraph && Object.keys(linkGraph).length > 0;
11288
11435
  const hasStoredScores = pages.some((page) => page.internalInlinkCount !== void 0 || page.internalLinkAuthorityScore !== void 0);
11289
- if (!hasLinkGraph && hasStoredScores) return pages.map((page) => ({
11290
- ...page,
11291
- internalInlinkCount: page.internalInlinkCount ?? 0,
11292
- internalLinkAuthorityScore: page.internalLinkAuthorityScore ?? 0
11293
- }));
11436
+ if (!hasLinkGraph && hasStoredScores) {
11437
+ if (pages.every((page) => page.internalInlinkCount !== void 0 && page.internalLinkAuthorityScore !== void 0)) return pages;
11438
+ return pages.map((page) => page.internalInlinkCount !== void 0 && page.internalLinkAuthorityScore !== void 0 ? page : {
11439
+ ...page,
11440
+ internalInlinkCount: page.internalInlinkCount ?? 0,
11441
+ internalLinkAuthorityScore: page.internalLinkAuthorityScore ?? 0
11442
+ });
11443
+ }
11294
11444
  const pageByUrl = /* @__PURE__ */ new Map();
11295
11445
  for (const page of pages) for (const value of [page.url, page.finalUrl]) {
11296
11446
  const normalized = normalizeLinkUrl(value);
@@ -11309,7 +11459,8 @@ function deriveInternalLinkAuthority(pages, linkGraph) {
11309
11459
  }
11310
11460
  for (const linkedPage of linkedPages) inlinks.set(linkedPage, (inlinks.get(linkedPage) ?? 0) + 1);
11311
11461
  }
11312
- const maxInlinks = Math.max(0, ...inlinks.values());
11462
+ let maxInlinks = 0;
11463
+ for (const count of inlinks.values()) maxInlinks = Math.max(maxInlinks, count);
11313
11464
  return pages.map((page) => {
11314
11465
  const internalInlinkCount = inlinks.get(page.url) ?? 0;
11315
11466
  return {
@@ -11567,6 +11718,7 @@ function crawlCaveats(input) {
11567
11718
  const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
11568
11719
  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.`);
11569
11720
  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.`);
11721
+ 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.`);
11570
11722
  return caveats;
11571
11723
  }
11572
11724
  //#endregion
@@ -11593,6 +11745,26 @@ function cancellationRace(signal) {
11593
11745
  });
11594
11746
  }
11595
11747
  //#endregion
11748
+ //#region packages/core/src/analyze/crawler/crawl-memory.ts
11749
+ const MEBIBYTE$1 = 1024 * 1024;
11750
+ const MIN_CRAWL_MEMORY_LIMIT = 384 * MEBIBYTE$1;
11751
+ const MAX_CRAWL_MEMORY_LIMIT = 640 * MEBIBYTE$1;
11752
+ const MIN_CRAWL_RSS_LIMIT = 512 * MEBIBYTE$1;
11753
+ const MAX_CRAWL_RSS_LIMIT = 896 * MEBIBYTE$1;
11754
+ const SYSTEM_MEMORY_FRACTION = .1;
11755
+ const SYSTEM_RSS_FRACTION = .2;
11756
+ function crawlMemoryLimitBytes(totalMemoryBytes) {
11757
+ if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_MEMORY_LIMIT;
11758
+ return Math.min(MAX_CRAWL_MEMORY_LIMIT, Math.max(MIN_CRAWL_MEMORY_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_MEMORY_FRACTION)));
11759
+ }
11760
+ function crawlRssLimitBytes(totalMemoryBytes) {
11761
+ if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_RSS_LIMIT;
11762
+ return Math.min(MAX_CRAWL_RSS_LIMIT, Math.max(MIN_CRAWL_RSS_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_RSS_FRACTION)));
11763
+ }
11764
+ function crawlMemoryPressure(input) {
11765
+ return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
11766
+ }
11767
+ //#endregion
11596
11768
  //#region packages/core/src/analyze/crawler/crawl-url-queue.ts
11597
11769
  function before(left, right) {
11598
11770
  return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
@@ -12310,6 +12482,7 @@ function crawlProviderLimits(input) {
12310
12482
  }
12311
12483
  async function crawlDataSources(input) {
12312
12484
  const window = crawlMetricsWindow(input.now());
12485
+ const skippedForMemory = input.skipReason === "memory-pressure";
12313
12486
  const dataSources = {
12314
12487
  searchConsole: {
12315
12488
  status: "skipped",
@@ -12320,7 +12493,7 @@ async function crawlDataSources(input) {
12320
12493
  pageLimit: input.searchMetricsLimit,
12321
12494
  pageLimitReached: false,
12322
12495
  retainedRowLimitReached: false,
12323
- warning: input.cancelled ? "Search Console join skipped because the crawl was cancelled." : "Search Console join skipped because no property was selected."
12496
+ 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."
12324
12497
  },
12325
12498
  analytics: {
12326
12499
  status: "skipped",
@@ -12329,10 +12502,10 @@ async function crawlDataSources(input) {
12329
12502
  joinedPages: 0,
12330
12503
  retainedRowLimit: input.analyticsLimit,
12331
12504
  retainedRowLimitReached: false,
12332
- warning: input.cancelled ? "Google Analytics join skipped because the crawl was cancelled." : "Google Analytics join skipped because no property was selected."
12505
+ 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."
12333
12506
  }
12334
12507
  };
12335
- if (!input.cancelled && input.site) dataSources.searchConsole = await joinSearchMetrics({
12508
+ if (!input.skipReason && input.site) dataSources.searchConsole = await joinSearchMetrics({
12336
12509
  site: input.site,
12337
12510
  pages: input.pages,
12338
12511
  warnings: input.warnings,
@@ -12345,7 +12518,7 @@ async function crawlDataSources(input) {
12345
12518
  queryPagesMetricsBatch: input.queryPagesMetricsBatch,
12346
12519
  queryPagesTopQueriesBatch: input.queryPagesTopQueriesBatch
12347
12520
  });
12348
- if (!input.cancelled && input.googleAnalyticsPropertyId) dataSources.analytics = await joinAnalytics({
12521
+ if (!input.skipReason && input.googleAnalyticsPropertyId) dataSources.analytics = await joinAnalytics({
12349
12522
  propertyId: input.googleAnalyticsPropertyId,
12350
12523
  pages: input.pages,
12351
12524
  warnings: input.warnings,
@@ -12358,17 +12531,6 @@ async function crawlDataSources(input) {
12358
12531
  }
12359
12532
  //#endregion
12360
12533
  //#region packages/core/src/analyze/monitoring/crawl-page.ts
12361
- function sameOriginUrl(href, base) {
12362
- try {
12363
- const url = new URL(href, base);
12364
- url.hash = "";
12365
- if (url.origin !== base.origin) return void 0;
12366
- if (!["http:", "https:"].includes(url.protocol)) return void 0;
12367
- return url.toString();
12368
- } catch {
12369
- return;
12370
- }
12371
- }
12372
12534
  function resolvedUrl(href, base) {
12373
12535
  if (!href) return void 0;
12374
12536
  try {
@@ -12398,49 +12560,6 @@ function safeResponseHeaders(headers) {
12398
12560
  function truncate$1(value, max) {
12399
12561
  return value.length > max ? value.slice(0, max).trimEnd() : value;
12400
12562
  }
12401
- function anchorSamples(links, internal) {
12402
- const seen = /* @__PURE__ */ new Set();
12403
- const samples = [];
12404
- for (const link of links) {
12405
- if (link.internal !== internal) continue;
12406
- const text = truncate$1(link.text.replace(/\s+/g, " ").trim(), 120);
12407
- if (!text) continue;
12408
- const key = `${link.href}\n${text}`;
12409
- if (seen.has(key)) continue;
12410
- seen.add(key);
12411
- samples.push({
12412
- href: link.href,
12413
- text
12414
- });
12415
- if (samples.length >= 25) break;
12416
- }
12417
- return samples;
12418
- }
12419
- function socialProfileLinks(links) {
12420
- const hosts = [
12421
- "facebook.com",
12422
- "instagram.com",
12423
- "linkedin.com",
12424
- "pinterest.com",
12425
- "tiktok.com",
12426
- "twitter.com",
12427
- "x.com",
12428
- "youtube.com"
12429
- ];
12430
- const profiles = /* @__PURE__ */ new Set();
12431
- for (const link of links) {
12432
- if (link.internal) continue;
12433
- try {
12434
- const url = new URL(link.href);
12435
- const host = url.hostname.replace(/^www\./, "").toLowerCase();
12436
- if (hosts.some((value) => host === value || host.endsWith(`.${value}`))) {
12437
- url.hash = "";
12438
- profiles.add(url.toString());
12439
- }
12440
- } catch {}
12441
- }
12442
- return [...profiles].slice(0, 50);
12443
- }
12444
12563
  function sameDocumentUrl(left, right) {
12445
12564
  if (!left || !right) return false;
12446
12565
  try {
@@ -12540,17 +12659,9 @@ function statusOnlyPage(fetched) {
12540
12659
  finalUrl: fetched.finalUrl,
12541
12660
  status: fetched.status,
12542
12661
  contentType,
12543
- responseHeaders: safeResponseHeaders(fetched.headers),
12544
12662
  responseTimeMs: fetched.diagnostics.durationMs,
12545
- usedJs: false,
12546
- fetchSource: "network",
12547
- cacheState: "bypass",
12548
- fetchDiagnostics: fetched.diagnostics,
12549
12663
  auditScope: "status",
12550
- accessBlock: fetched.diagnostics.accessBlock,
12551
- blocked: fetched.diagnostics.blocked,
12552
12664
  contentAuditAllowed: false,
12553
- robotsTxt: fetched.robotsTxt,
12554
12665
  indexable: false,
12555
12666
  indexability: "Not evaluated by status-only health probe",
12556
12667
  declaredIndexability: "unknown",
@@ -12558,8 +12669,7 @@ function statusOnlyPage(fetched) {
12558
12669
  wordCount: 0,
12559
12670
  contentHash: "",
12560
12671
  outgoingInternalCount: 0,
12561
- outgoingExternalCount: 0,
12562
- isHttps: new URL(fetched.finalUrl).protocol === "https:"
12672
+ outgoingExternalCount: 0
12563
12673
  };
12564
12674
  }
12565
12675
  function indexabilityReason(page) {
@@ -12635,12 +12745,11 @@ async function crawlOne(url, opts = {}) {
12635
12745
  };
12636
12746
  try {
12637
12747
  const extracted = await extractPage(fetched, "crawler");
12638
- const base = new URL(extracted.finalUrl);
12639
- const internalLinks = extracted.links.map((link) => sameOriginUrl(link.href, base)).filter((value) => Boolean(value));
12640
- const externalLinks = extracted.links.filter((link) => !link.internal).map((link) => link.href).filter((href) => /^https?:\/\//.test(href));
12641
- const h1 = extracted.headings.find((heading) => heading.level === 1)?.text;
12642
- const uniqueInternalLinks = [...new Set(internalLinks)];
12643
- const uniqueExternalLinks = [...new Set(externalLinks)];
12748
+ const crawlerEvidence = extracted.crawlerEvidence;
12749
+ if (!crawlerEvidence) throw new Error("Crawler extraction did not return crawl evidence.");
12750
+ const h1 = crawlerEvidence.h1;
12751
+ const uniqueInternalLinks = crawlerEvidence.internalLinks;
12752
+ const uniqueExternalLinks = crawlerEvidence.externalLinks;
12644
12753
  const canonical = extracted.canonicalEvidence?.selectedUrl ?? resolvedUrl(extracted.canonical, extracted.finalUrl);
12645
12754
  const richResults = selectGoogleRichResultAssessments(extracted.googleRichResults ?? []);
12646
12755
  const page = {
@@ -12666,9 +12775,9 @@ async function crawlOne(url, opts = {}) {
12666
12775
  metaRobots: extracted.metaRobots,
12667
12776
  xRobotsTag: extracted.xRobotsTag,
12668
12777
  h1,
12669
- h1Count: extracted.headings.filter((heading) => heading.level === 1).length,
12670
- h2Count: extracted.headings.filter((heading) => heading.level === 2).length,
12671
- h3Count: extracted.headings.filter((heading) => heading.level === 3).length,
12778
+ h1Count: crawlerEvidence.h1Count,
12779
+ h2Count: crawlerEvidence.h2Count,
12780
+ h3Count: crawlerEvidence.h3Count,
12672
12781
  indexable: false,
12673
12782
  extractionStatus: "complete",
12674
12783
  wordCount: extracted.wordCount,
@@ -12704,8 +12813,8 @@ async function crawlOne(url, opts = {}) {
12704
12813
  outgoingExternalCount: uniqueExternalLinks.length,
12705
12814
  sampleInternalLinks: uniqueInternalLinks.slice(0, 25),
12706
12815
  sampleExternalLinks: uniqueExternalLinks.slice(0, 25),
12707
- internalAnchorSamples: anchorSamples(extracted.links, true),
12708
- externalAnchorSamples: anchorSamples(extracted.links, false),
12816
+ internalAnchorSamples: crawlerEvidence.internalAnchorSamples,
12817
+ externalAnchorSamples: crawlerEvidence.externalAnchorSamples,
12709
12818
  markdownAlternates: extracted.markdownAlternates,
12710
12819
  schemaTypes: extracted.schemaTypes,
12711
12820
  structuredDataFormats: extracted.structuredDataFormats ?? [],
@@ -12714,7 +12823,7 @@ async function crawlOne(url, opts = {}) {
12714
12823
  schemaSameAs: [...new Set((extracted.schemaSameAsEvidence ?? []).map((evidence) => evidence.url))].slice(0, 50),
12715
12824
  schemaSameAsEvidence: (extracted.schemaSameAsEvidence ?? []).slice(0, 50),
12716
12825
  invalidSchemaSameAs: (extracted.invalidSchemaSameAs ?? []).slice(0, 25),
12717
- socialProfileLinks: socialProfileLinks(extracted.links),
12826
+ socialProfileLinks: crawlerEvidence.socialProfileLinks,
12718
12827
  invalidJsonLdCount: extracted.invalidJsonLdCount,
12719
12828
  invalidJsonLdSamples: extracted.invalidJsonLdSamples,
12720
12829
  unrecognizedJsonLdTypes: extracted.unrecognizedJsonLdTypes ?? [],
@@ -13367,7 +13476,9 @@ function resolveCrawlSiteDependencies(dependencies = {}) {
13367
13476
  fetchLandingPageValues: dependencies.fetchLandingPageValues ?? fetchLandingPageValues,
13368
13477
  landingValueForUrl: dependencies.landingValueForUrl ?? landingValueForUrl,
13369
13478
  collectAgentDiscovery: dependencies.collectAgentDiscovery ?? collectAgentDiscovery,
13370
- now: dependencies.now ?? (() => /* @__PURE__ */ new Date())
13479
+ now: dependencies.now ?? (() => /* @__PURE__ */ new Date()),
13480
+ memoryUsage: dependencies.memoryUsage ?? (() => process.memoryUsage()),
13481
+ totalMemory: dependencies.totalMemory ?? totalmem
13371
13482
  };
13372
13483
  }
13373
13484
  //#endregion
@@ -13523,15 +13634,26 @@ async function crawlSite(input, dependencies) {
13523
13634
  let queuedUrls = 0;
13524
13635
  let skippedUrls = 0;
13525
13636
  const skipReasonCounts = {};
13526
- const recordSkip = (reason) => {
13527
- skippedUrls += 1;
13528
- skipReasonCounts[reason] = (skipReasonCounts[reason] ?? 0) + 1;
13637
+ const recordSkips = (reason, count = 1) => {
13638
+ if (count <= 0) return;
13639
+ skippedUrls += count;
13640
+ skipReasonCounts[reason] = (skipReasonCounts[reason] ?? 0) + count;
13529
13641
  };
13642
+ const recordSkip = (reason) => recordSkips(reason);
13530
13643
  let robotsDeferredUrls = 0;
13531
13644
  let originBackpressureSkippedUrls = 0;
13532
13645
  let queueSafetySkippedUrls = 0;
13533
13646
  let failedUrls = 0;
13534
13647
  let observedInternalLinks = 0;
13648
+ let memoryLimited = false;
13649
+ const memoryDeferredUrls = /* @__PURE__ */ new Set();
13650
+ const checkMemoryPressure = () => {
13651
+ if (memoryLimited) return;
13652
+ memoryLimited = crawlMemoryPressure({
13653
+ memoryUsage: deps.memoryUsage(),
13654
+ totalMemoryBytes: deps.totalMemory()
13655
+ });
13656
+ };
13535
13657
  let sitemapDiscovery;
13536
13658
  let externalLinkVerification;
13537
13659
  let statusEventChain = Promise.resolve();
@@ -13661,6 +13783,7 @@ async function crawlSite(input, dependencies) {
13661
13783
  for (const url of sitemap.urls) enqueue(url, 0);
13662
13784
  sitemapDiscovery = sitemap.discovery;
13663
13785
  }
13786
+ checkMemoryPressure();
13664
13787
  const nextQueueItem = () => {
13665
13788
  while (queue.size) {
13666
13789
  const item = queue.take();
@@ -13686,6 +13809,7 @@ async function crawlSite(input, dependencies) {
13686
13809
  timeoutMs: config.timeoutMs,
13687
13810
  rate: config.fetchRate,
13688
13811
  signal,
13812
+ writeCache: false,
13689
13813
  respectRobots: config.respectRobots,
13690
13814
  robotsResolver,
13691
13815
  renderer
@@ -13713,8 +13837,8 @@ async function crawlSite(input, dependencies) {
13713
13837
  depth: item.depth
13714
13838
  });
13715
13839
  };
13716
- while (!isCancelled() && (queue.size || inFlight.size) && pages.length < config.maxPages) {
13717
- while (!isCancelled() && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
13840
+ while (!isCancelled() && (queue.size || inFlight.size) && (!memoryLimited || inFlight.size > 0) && pages.length < config.maxPages) {
13841
+ while (!isCancelled() && !memoryLimited && inFlight.size < activeConcurrency && pages.length + inFlight.size < config.maxPages) {
13718
13842
  const item = nextQueueItem();
13719
13843
  if (!item) break;
13720
13844
  startTask(item);
@@ -13742,6 +13866,7 @@ async function crawlSite(input, dependencies) {
13742
13866
  healthyProbeStreak = 0;
13743
13867
  activeConcurrency = 1;
13744
13868
  }
13869
+ checkMemoryPressure();
13745
13870
  if (request.outcome === "skipped") {
13746
13871
  recordSkip(request.reason === "robots-disallowed" ? "robots-disallowed" : request.reason === "robots-deferred" ? "robots-uncertain" : "origin-backpressure");
13747
13872
  if (request.reason === "robots-deferred") {
@@ -13872,10 +13997,24 @@ async function crawlSite(input, dependencies) {
13872
13997
  depth: task.depth,
13873
13998
  statusCode: page.status
13874
13999
  });
13875
- if (followLinks && task.depth < config.maxDepth) for (const url of result.urls) enqueue(url, task.depth + 1);
14000
+ if (followLinks && task.depth < config.maxDepth) if (memoryLimited) for (const value of result.urls) {
14001
+ const normalized = normalizeUrl$1(value, finalUrl);
14002
+ if (normalized && sameOrigin(normalized, origin) && !isLikelyAsset(normalized) && passesFilters(normalized, config.include, config.exclude) && !visited.has(normalized) && !queue.has(normalized)) {
14003
+ discovered.add(normalized);
14004
+ memoryDeferredUrls.add(normalized);
14005
+ }
14006
+ }
14007
+ else for (const url of result.urls) enqueue(url, task.depth + 1);
14008
+ }
14009
+ const memoryPressureSkippedUrls = memoryLimited ? queue.size + memoryDeferredUrls.size : 0;
14010
+ const stoppedForMemory = memoryPressureSkippedUrls > 0;
14011
+ if (stoppedForMemory) {
14012
+ recordSkips("memory-pressure", memoryPressureSkippedUrls);
14013
+ warnings.push("Crawl stopped before local memory use could become unsafe. The retained report is partial.");
13876
14014
  }
13877
14015
  const resolvedInlinkCounts = resolveLinkCountAliases(incomingLinkCounts, fetchedAliases);
13878
- const maxInlinks = Math.max(0, ...pages.map((page) => resolvedInlinkCounts.get(page.finalUrl) ?? 0));
14016
+ let maxInlinks = 0;
14017
+ for (const page of pages) maxInlinks = Math.max(maxInlinks, resolvedInlinkCounts.get(page.finalUrl) ?? 0);
13879
14018
  for (const page of pages) {
13880
14019
  const internalInlinkCount = resolvedInlinkCounts.get(page.finalUrl) ?? 0;
13881
14020
  page.internalInlinkCount = internalInlinkCount;
@@ -13895,11 +14034,11 @@ async function crawlSite(input, dependencies) {
13895
14034
  const sitemapEvidencePartial = sitemapDiscovery?.dataStatus === "partial";
13896
14035
  const accessEvidencePartial = requests.some((request) => request.outcome === "response" && request.accessBlock);
13897
14036
  const requiredSitemapUnavailable = config.mode === "sitemap" && sitemapDiscovery?.dataStatus === "unavailable";
13898
- const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.size > 0 || inFlight.size > 0 || sitemapEvidencePartial || accessEvidencePartial || requiredSitemapUnavailable;
14037
+ const partial = cancelled || failedUrls > 0 || pageLimitReached || robotsDeferredUrls > 0 || originBackpressureSkippedUrls > 0 || queueSafetySkippedUrls > 0 || queue.size > 0 || inFlight.size > 0 || stoppedForMemory || sitemapEvidencePartial || accessEvidencePartial || requiredSitemapUnavailable;
13899
14038
  const failed = !cancelled && pages.length === 0 && (requiredSitemapUnavailable || requests.length > 0 && requests.every((request) => request.outcome === "failure"));
13900
14039
  const requestEvidenceStatus = cancelled && inFlight.size > 0 ? "partial" : "available";
13901
14040
  const dataSources = await crawlDataSources({
13902
- cancelled,
14041
+ skipReason: cancelled ? "cancelled" : stoppedForMemory ? "memory-pressure" : void 0,
13903
14042
  site: healthStrategy ? void 0 : site,
13904
14043
  googleAnalyticsPropertyId: healthStrategy ? void 0 : input.googleAnalyticsPropertyId,
13905
14044
  pages,
@@ -13918,7 +14057,7 @@ async function crawlSite(input, dependencies) {
13918
14057
  });
13919
14058
  const sourceEvidencePartial = [dataSources.searchConsole.status, dataSources.analytics.status].some((status) => status === "partial" || status === "unavailable");
13920
14059
  const reportStatus = failed ? "failed" : partial || sourceEvidencePartial ? "partial" : "completed";
13921
- if (!cancelled && config.checkExternal) {
14060
+ if (!cancelled && !stoppedForMemory && config.checkExternal) {
13922
14061
  emitStatus("external_links_started", {
13923
14062
  url: config.url,
13924
14063
  message: "Started external link checks."
@@ -13935,7 +14074,7 @@ async function crawlSite(input, dependencies) {
13935
14074
  message: "Finished external link checks."
13936
14075
  });
13937
14076
  }
13938
- const agentDiscovery = !cancelled && config.checkAgentDiscovery ? await deps.collectAgentDiscovery({
14077
+ const agentDiscovery = !cancelled && !stoppedForMemory && config.checkAgentDiscovery ? await deps.collectAgentDiscovery({
13939
14078
  startUrl: config.url,
13940
14079
  pages,
13941
14080
  timeoutMs: config.timeoutMs,
@@ -13976,7 +14115,8 @@ async function crawlSite(input, dependencies) {
13976
14115
  pageLimitReached,
13977
14116
  maxPages: config.maxPages,
13978
14117
  queueSafetySkippedUrls,
13979
- originBackpressureSkippedUrls
14118
+ originBackpressureSkippedUrls,
14119
+ memoryPressureSkippedUrls
13980
14120
  }),
13981
14121
  stats: {
13982
14122
  discoveredUrls: discovered.size,