doc2vec 2.17.3 → 2.17.4

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/README.md CHANGED
@@ -257,7 +257,7 @@ Configuration is managed through two files:
257
257
  Common configuration for all types:
258
258
  * `product_name`: A string identifying the product (used in metadata).
259
259
  * `version`: A string identifying the product version (used in metadata).
260
- * `max_size`: Maximum raw content size (in characters). For websites, this limits the raw HTML fetched by Puppeteer. Recommending 1MB (1048576).
260
+ * `max_size`: Maximum raw content size (in characters). For websites, this limits the raw HTML fetched by Puppeteer. Recommending 1MB (1048576). Pages whose uncompressed HTML is more than 4× `max_size` (checked with a HEAD request) are skipped without being rendered, so a multi-MB page can't exhaust the browser's memory; their links are still followed.
261
261
  * `database_config`: Configuration for the database.
262
262
  * `type`: Specifies the storage backend (`'sqlite'` or `'qdrant'`).
263
263
  * `params`: Parameters specific to the chosen database type.
@@ -548,6 +548,8 @@ Notes:
548
548
  - A crawl holds a browser for as long as it runs, so disable or raise the browser service's session timeout (browserless: `TIMEOUT=0`).
549
549
  - Each connection gets its own browser session; doc2vec closes it when the crawl finishes and every 50 pages.
550
550
  - Launch flags such as `--no-sandbox` belong to the process that starts the browser and are not sent to a remote one.
551
+ - If the browser service is unreachable (e.g. its pod was OOMKilled and is restarting), doc2vec retries the connection after 5s, 15s, 30s and 60s before failing the source, and reports the actual connection error (`Unexpected server response: 429`, `ECONNREFUSED`, …).
552
+ - Size the browser service's memory for the largest pages you crawl. doc2vec skips pages over 4× `max_size` before rendering, but pages just under that can still be heavy. A memory-backed `/dev/shm` (`emptyDir: { medium: Memory }`) counts against the same limit.
551
553
  - With `BROWSER_WS_ENDPOINT` unset, doc2vec launches a local Chrome: `PUPPETEER_EXECUTABLE_PATH` if set, else Puppeteer's downloaded Chrome for Testing, else a system `chromium`.
552
554
 
553
555
  ## Controller Mode
@@ -36,9 +36,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.ContentProcessor = exports.BROWSER_WS_ENDPOINT_ENV = exports.BrowserLaunchError = void 0;
39
+ exports.ContentProcessor = exports.OVERSIZED_PAGE_FACTOR = exports.BROWSER_WS_ENDPOINT_ENV = exports.BrowserLaunchError = void 0;
40
40
  exports.remoteBrowserEndpoint = remoteBrowserEndpoint;
41
41
  exports.openBrowser = openBrowser;
42
+ exports.describeError = describeError;
43
+ exports.browserLaunchRetryDelaysMs = browserLaunchRetryDelaysMs;
42
44
  exports.resolveBrowserExecutablePath = resolveBrowserExecutablePath;
43
45
  const readability_1 = require("@mozilla/readability");
44
46
  const axios_1 = __importDefault(require("axios"));
@@ -94,6 +96,14 @@ const LOCAL_LAUNCH_ARGS = [
94
96
  '--disable-extensions',
95
97
  ];
96
98
  const BROWSER_PROTOCOL_TIMEOUT_MS = 60000;
99
+ /**
100
+ * Pages whose uncompressed HTML exceeds this multiple of a source's max_size are
101
+ * skipped before rendering. The max_size check after rendering measures only
102
+ * the main content element, so the whole page is always somewhat larger. The
103
+ * margin keeps pages with a lot of chrome around valid content from being lost.
104
+ * The pages that OOM-killed browserless were 5.6–12.7 MB, against a 1 MB max_size.
105
+ */
106
+ exports.OVERSIZED_PAGE_FACTOR = 4;
97
107
  /**
98
108
  * Open a browser: connect to the remote one named by BROWSER_WS_ENDPOINT, or
99
109
  * launch a local one. Callers close it with browser.close() in both cases —
@@ -103,10 +113,19 @@ const BROWSER_PROTOCOL_TIMEOUT_MS = 60000;
103
113
  async function openBrowser() {
104
114
  const endpoint = remoteBrowserEndpoint();
105
115
  if (endpoint) {
106
- return puppeteer_1.default.connect({
107
- browserWSEndpoint: endpoint,
108
- protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
109
- });
116
+ try {
117
+ return await puppeteer_1.default.connect({
118
+ browserWSEndpoint: endpoint,
119
+ protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
120
+ });
121
+ }
122
+ catch (error) {
123
+ // puppeteer rejects a failed WebSocket handshake with the raw `ws`
124
+ // ErrorEvent (not an Error), which stringifies to "[object Object]".
125
+ // Re-throw a real Error naming the browser host (never the full
126
+ // URL: it carries the token).
127
+ throw new Error(`cannot connect to remote browser at ${describeEndpoint(endpoint)}: ${describeError(error)}`);
128
+ }
110
129
  }
111
130
  return puppeteer_1.default.launch({
112
131
  executablePath: await resolveBrowserExecutablePath(),
@@ -114,6 +133,47 @@ async function openBrowser() {
114
133
  protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
115
134
  });
116
135
  }
136
+ /** `ws://host:port` of a CDP endpoint, without path, query (token) or credentials. */
137
+ function describeEndpoint(endpoint) {
138
+ try {
139
+ const url = new URL(endpoint);
140
+ return `${url.protocol}//${url.host}`;
141
+ }
142
+ catch {
143
+ return '<unparseable endpoint>';
144
+ }
145
+ }
146
+ /**
147
+ * Human-readable message for anything thrown or rejected. Handles Error, the
148
+ * `ws` ErrorEvent shape ({ message, error }) and other plain objects, which
149
+ * String() would render as "[object Object]".
150
+ */
151
+ function describeError(error) {
152
+ if (error instanceof Error)
153
+ return error.message || error.name;
154
+ if (error && typeof error === 'object') {
155
+ const obj = error;
156
+ if (obj.error instanceof Error && obj.error.message)
157
+ return obj.error.message;
158
+ if (typeof obj.message === 'string' && obj.message)
159
+ return obj.message;
160
+ if (typeof obj.type === 'string' && obj.type)
161
+ return `${obj.type} event`;
162
+ // Deliberately not JSON-serialized: event objects reference the socket,
163
+ // whose URL may carry a token.
164
+ return `non-Error rejection (${error.constructor?.name ?? 'Object'})`;
165
+ }
166
+ return String(error);
167
+ }
168
+ /**
169
+ * Pauses between browser launch/connect attempts. A remote browser service
170
+ * (browserless) gets a longer window: when its pod is OOMKilled and restarts,
171
+ * it refuses connections for ~30s, and a single 5s retry turned that into
172
+ * several consecutive sources failing outright.
173
+ */
174
+ function browserLaunchRetryDelaysMs() {
175
+ return remoteBrowserEndpoint() ? [5000, 15000, 30000, 60000] : [5000];
176
+ }
117
177
  /**
118
178
  * Resolve which browser binary to launch, in order of preference:
119
179
  * 1. PUPPETEER_EXECUTABLE_PATH (explicit override)
@@ -473,26 +533,39 @@ class ContentProcessor {
473
533
  let browser = null;
474
534
  let page = null;
475
535
  const launchBrowser = async () => {
476
- let b;
477
- try {
478
- b = await openBrowser();
479
- }
480
- catch (firstError) {
481
- // One retry after a pause — the failure may be transient (e.g.
482
- // memory pressure from a page that just closed). If the browser
483
- // cannot launch at all, no page in this crawl can be processed,
484
- // so escalate as fatal instead of failing every URL one by one.
485
- logger.warn(`Browser launch failed (${memoryDiagnostics()}), retrying once in 5s...`, firstError);
486
- await new Promise(resolve => setTimeout(resolve, 5000));
536
+ // Retry after pauses — the failure may be transient (memory
537
+ // pressure from a page that just closed, or a remote browser
538
+ // service restarting). If the browser cannot launch at all, no page
539
+ // in this crawl can be processed, so escalate as fatal instead of
540
+ // failing every URL one by one.
541
+ const delays = browserLaunchRetryDelaysMs();
542
+ const attempts = delays.length + 1;
543
+ for (let attempt = 1;; attempt++) {
544
+ let b;
487
545
  try {
488
546
  b = await openBrowser();
547
+ const p = await b.newPage();
548
+ if (attempt > 1)
549
+ logger.info(`Browser launched on attempt ${attempt}/${attempts}`);
550
+ return { browser: b, page: p };
489
551
  }
490
- catch (secondError) {
491
- throw new BrowserLaunchError(`browser failed to launch twice (${memoryDiagnostics()}): ${secondError instanceof Error ? secondError.message : String(secondError)}`);
552
+ catch (error) {
553
+ // Don't leak a remote session whose first page failed to open
554
+ if (b) {
555
+ try {
556
+ await b.close();
557
+ }
558
+ catch { /* already gone */ }
559
+ }
560
+ const reason = describeError(error);
561
+ if (attempt >= attempts) {
562
+ throw new BrowserLaunchError(`browser failed to launch after ${attempts} attempts (${memoryDiagnostics()}): ${reason}`);
563
+ }
564
+ const delayMs = delays[attempt - 1];
565
+ logger.warn(`Browser launch failed (attempt ${attempt}/${attempts}, ${memoryDiagnostics()}): ${reason} — retrying in ${Math.round(delayMs / 1000)}s...`);
566
+ await new Promise(resolve => setTimeout(resolve, delayMs));
492
567
  }
493
568
  }
494
- const p = await b.newPage();
495
- return { browser: b, page: p };
496
569
  };
497
570
  // Track whether the page needs to be recreated (e.g., after a timeout error)
498
571
  let pageNeedsRecreation = false;
@@ -744,16 +817,33 @@ class ContentProcessor {
744
817
  try {
745
818
  logger.info(`Crawling: ${url}`);
746
819
  const sources = referrers.get(url) ?? new Set([baseUrl]);
820
+ // A page far larger than max_size would be discarded after
821
+ // rendering anyway, and rendering multi-MB pages is what
822
+ // OOM-kills the browser. Skip it before Chromium sees it, but
823
+ // still harvest its links from the raw HTML so the crawl
824
+ // discovers the same pages as before.
825
+ const oversized = utils_1.Utils.isPdfUrl(url)
826
+ ? null
827
+ : await this.probeOversizedPage(url, sourceConfig.max_size, logger);
747
828
  // For HTML pages, ensure the browser is running and pass the shared page
748
829
  // For PDFs, processPage handles them without Puppeteer
749
- const currentPage = utils_1.Utils.isPdfUrl(url) ? undefined : await ensureBrowser();
750
- const result = await this.processPage(url, sourceConfig, (reportedUrl, status) => {
751
- if (status === 404) {
752
- for (const source of sources) {
753
- addBrokenLink(source, reportedUrl);
830
+ let currentPage;
831
+ let result;
832
+ if (oversized) {
833
+ logger.warn(`Page is ${oversized.bytes} bytes, over ${exports.OVERSIZED_PAGE_FACTOR}x max size (${sourceConfig.max_size}). Skipping without rendering: ${url}`);
834
+ const raw = await this.extractLinksWithoutBrowser(oversized.finalUrl, logger);
835
+ result = { content: null, links: raw.links, finalUrl: raw.finalUrl };
836
+ }
837
+ else {
838
+ currentPage = utils_1.Utils.isPdfUrl(url) ? undefined : await ensureBrowser();
839
+ result = await this.processPage(url, sourceConfig, (reportedUrl, status) => {
840
+ if (status === 404) {
841
+ for (const source of sources) {
842
+ addBrokenLink(source, reportedUrl);
843
+ }
754
844
  }
755
- }
756
- }, currentPage);
845
+ }, currentPage);
846
+ }
757
847
  let contentProcessedSuccessfully = false;
758
848
  if (result.content !== null) {
759
849
  contentProcessedSuccessfully = await processPageContent(url, result.content);
@@ -814,7 +904,7 @@ class ContentProcessor {
814
904
  // Page processed successfully — reset protocol error counter and
815
905
  // increment the periodic restart counter
816
906
  consecutiveProtocolErrors = 0;
817
- if (!utils_1.Utils.isPdfUrl(url)) {
907
+ if (currentPage) {
818
908
  pagesSinceRestart++;
819
909
  }
820
910
  // Navigate to about:blank to clear any lingering JS, timers, or
@@ -965,6 +1055,77 @@ class ContentProcessor {
965
1055
  }
966
1056
  return false;
967
1057
  }
1058
+ /**
1059
+ * HEAD the page asking for an UNCOMPRESSED body and report its size when it
1060
+ * is more than OVERSIZED_PAGE_FACTOR x maxSize. Compressed lengths are
1061
+ * useless here (a 7 MB docs page is 27 KB of brotli), so a response that is
1062
+ * still encoded, has no Content-Length, fails, or isn't 2xx returns null and
1063
+ * the page is rendered as usual.
1064
+ */
1065
+ async probeOversizedPage(url, maxSize, logger) {
1066
+ try {
1067
+ const response = await axios_1.default.request({
1068
+ method: 'HEAD',
1069
+ url,
1070
+ timeout: 10000,
1071
+ maxRedirects: 5,
1072
+ headers: {
1073
+ 'User-Agent': 'Mozilla/5.0 (compatible; doc2vec crawler)',
1074
+ 'Accept-Encoding': 'identity',
1075
+ },
1076
+ validateStatus: () => true,
1077
+ // Otherwise axios deletes Content-Encoding from HEAD responses,
1078
+ // hiding that a server ignored `identity` and sent a compressed length
1079
+ decompress: false,
1080
+ });
1081
+ if (response.status < 200 || response.status >= 300)
1082
+ return null;
1083
+ const encoding = String(response.headers['content-encoding'] ?? 'identity').toLowerCase();
1084
+ if (encoding !== 'identity')
1085
+ return null;
1086
+ const bytes = Number(response.headers['content-length']);
1087
+ if (!Number.isFinite(bytes) || bytes <= maxSize * exports.OVERSIZED_PAGE_FACTOR)
1088
+ return null;
1089
+ const finalUrl = response.request?.res?.responseUrl || url;
1090
+ return { bytes, finalUrl };
1091
+ }
1092
+ catch (error) {
1093
+ logger.debug(`Size probe failed for ${url}: ${describeError(error)}. Rendering normally.`);
1094
+ return null;
1095
+ }
1096
+ }
1097
+ /**
1098
+ * Collect a page's links from its raw HTML, without a browser. Used for
1099
+ * pages too large to render, so skipping them doesn't cut off the pages
1100
+ * only they link to. JS-inserted links are missed — acceptable for the
1101
+ * static documentation pages that reach this size.
1102
+ */
1103
+ async extractLinksWithoutBrowser(url, logger) {
1104
+ try {
1105
+ const response = await axios_1.default.get(url, {
1106
+ timeout: 60000,
1107
+ maxRedirects: 5,
1108
+ responseType: 'text',
1109
+ maxContentLength: 64 * 1024 * 1024,
1110
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; doc2vec crawler)' },
1111
+ });
1112
+ const $ = (0, cheerio_1.load)(response.data);
1113
+ const links = [];
1114
+ $('a[href]').each((_, el) => {
1115
+ const href = $(el).attr('href');
1116
+ if (href && !href.startsWith('#') && !href.startsWith('mailto:')) {
1117
+ links.push(href);
1118
+ }
1119
+ });
1120
+ logger.debug(`Extracted ${links.length} links from raw HTML of ${url}`);
1121
+ const finalUrl = response.request?.res?.responseUrl || url;
1122
+ return { links, finalUrl };
1123
+ }
1124
+ catch (error) {
1125
+ logger.warn(`Could not fetch ${url} to extract its links: ${describeError(error)}`);
1126
+ return { links: [], finalUrl: url };
1127
+ }
1128
+ }
968
1129
  async processPage(url, sourceConfig, onHttpStatus, existingPage) {
969
1130
  const logger = this.logger.child('page-processor');
970
1131
  logger.debug(`Processing content from ${url}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc2vec",
3
- "version": "2.17.3",
3
+ "version": "2.17.4",
4
4
  "type": "commonjs",
5
5
  "engines": {
6
6
  "node": ">=22"