doc2vec 2.17.2 → 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 +3 -1
- package/dist/content-processor.js +189 -28
- package/package.json +2 -2
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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 (
|
|
491
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
}
|
|
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 (
|
|
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
|
+
"version": "2.17.4",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22"
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"license": "ISC",
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@aws-sdk/client-s3": "^3.1161.0",
|
|
37
|
-
"@chonkiejs/core": "^0.0.
|
|
37
|
+
"@chonkiejs/core": "^0.0.7",
|
|
38
38
|
"@mozilla/readability": "^0.6.0",
|
|
39
39
|
"@qdrant/js-client-rest": "^1.19.0",
|
|
40
40
|
"@qdrant/qdrant-js": "^1.19.0",
|