doc2vec 2.15.0 → 2.16.0

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
@@ -485,6 +485,42 @@ The script will then:
485
485
  7. Cleanup obsolete chunks.
486
486
  8. Output detailed logs.
487
487
 
488
+ ## Docker Images and the Browser
489
+
490
+ Website sources are rendered with a real browser (Puppeteer). The `Dockerfile` builds two images so you can choose where that browser runs:
491
+
492
+ | Target | Contents | Use it when |
493
+ |---|---|---|
494
+ | `runtime` | doc2vec only, no browser | You run a browser elsewhere and point doc2vec at it (see below). Recommended for clusters: the browser is the one component that renders untrusted input, and it can be updated on its own schedule. |
495
+ | `runtime-chrome` | doc2vec plus a bundled, version-matched Chrome | You want a single self-contained container. `linux/amd64` only. |
496
+
497
+ ```bash
498
+ docker build --target runtime -t doc2vec .
499
+ docker build --target runtime-chrome -t doc2vec:chrome .
500
+ ```
501
+
502
+ Both images are based on Wolfi (Chainguard) and run as the non-root user `doc2vec` (UID 14000).
503
+
504
+ ### Using a remote browser (`BROWSER_WS_ENDPOINT`)
505
+
506
+ Set `BROWSER_WS_ENDPOINT` to a Chrome DevTools Protocol WebSocket URL and doc2vec connects to that browser instead of launching one. Any CDP-speaking browser works; [browserless](https://github.com/browserless/browserless) is a convenient packaged one:
507
+
508
+ ```bash
509
+ docker run -d --name browser -e TOKEN=secret -e TIMEOUT=0 --shm-size=1g ghcr.io/browserless/chromium
510
+ docker run --rm --link browser \
511
+ -e BROWSER_WS_ENDPOINT='ws://browser:3000?token=secret' \
512
+ -e OPENAI_API_KEY \
513
+ -v "$PWD/config.yaml:/config/config.yaml:ro" \
514
+ doc2vec node dist/doc2vec.js /config/config.yaml
515
+ ```
516
+
517
+ Notes:
518
+
519
+ - A crawl holds a browser for as long as it runs, so disable or raise the browser service's session timeout (browserless: `TIMEOUT=0`).
520
+ - Each connection gets its own browser session; doc2vec closes it when the crawl finishes and every 50 pages.
521
+ - Launch flags such as `--no-sandbox` belong to the process that starts the browser and are not sent to a remote one.
522
+ - 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`.
523
+
488
524
  ## Controller Mode
489
525
 
490
526
  Besides the one-shot sync, doc2vec can run as a **long-lived controller** that schedules sync jobs, records run history and statistics in Postgres, and serves a web UI for monitoring and managing configs:
@@ -591,12 +627,17 @@ spec:
591
627
  - name: OPENAI_API_KEY
592
628
  valueFrom:
593
629
  secretKeyRef: { name: doc2vec-secrets, key: openai-api-key }
630
+ # The browser runs in its own Deployment (e.g. ghcr.io/browserless/chromium
631
+ # with TIMEOUT=0); see "Docker Images and the Browser". Drop this variable
632
+ # and use the `-chrome` image to bundle the browser instead.
633
+ - name: BROWSER_WS_ENDPOINT
634
+ value: ws://browserless:3000?token=$(BROWSERLESS_TOKEN)
594
635
  volumeMounts:
595
636
  - name: configs
596
637
  mountPath: /etc/doc2vec
597
638
  resources:
598
639
  requests: { memory: 1Gi }
599
- limits: { memory: 4Gi } # website sources launch headless Chromium
640
+ limits: { memory: 2Gi }
600
641
  volumes:
601
642
  - name: configs
602
643
  configMap: { name: doc2vec-configs }
@@ -36,7 +36,9 @@ 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.BrowserLaunchError = void 0;
39
+ exports.ContentProcessor = exports.BROWSER_WS_ENDPOINT_ENV = exports.BrowserLaunchError = void 0;
40
+ exports.remoteBrowserEndpoint = remoteBrowserEndpoint;
41
+ exports.openBrowser = openBrowser;
40
42
  exports.resolveBrowserExecutablePath = resolveBrowserExecutablePath;
41
43
  const readability_1 = require("@mozilla/readability");
42
44
  const axios_1 = __importDefault(require("axios"));
@@ -62,6 +64,56 @@ class BrowserLaunchError extends Error {
62
64
  }
63
65
  }
64
66
  exports.BrowserLaunchError = BrowserLaunchError;
67
+ /**
68
+ * Where the browser comes from.
69
+ *
70
+ * Unset (the default): this process launches its own Chromium, which the image
71
+ * has to carry. Set: this process CONNECTS to a browser running elsewhere over
72
+ * the Chrome DevTools Protocol, and the image needs no browser at all. The
73
+ * value is a full CDP WebSocket URL, e.g.
74
+ * ws://browserless.qdrant.svc.cluster.local:3000?token=<token>
75
+ *
76
+ * A remote browser is the one that keeps the app image small and lets the
77
+ * browser be updated independently of the app (a browser image tracks Chrome
78
+ * stable; a distro chromium is frozen at whatever the distro rebuilt last).
79
+ */
80
+ exports.BROWSER_WS_ENDPOINT_ENV = 'BROWSER_WS_ENDPOINT';
81
+ function remoteBrowserEndpoint() {
82
+ const raw = process.env[exports.BROWSER_WS_ENDPOINT_ENV]?.trim();
83
+ return raw ? raw : undefined;
84
+ }
85
+ /**
86
+ * Chromium flags for a browser THIS process launches. They are not sent to a
87
+ * remote browser: launch flags belong to the process that starts the browser.
88
+ */
89
+ const LOCAL_LAUNCH_ARGS = [
90
+ '--no-sandbox',
91
+ '--disable-setuid-sandbox',
92
+ '--disable-dev-shm-usage', // Use /tmp instead of /dev/shm (critical in Docker, default 64MB)
93
+ '--disable-gpu',
94
+ '--disable-extensions',
95
+ ];
96
+ const BROWSER_PROTOCOL_TIMEOUT_MS = 60000;
97
+ /**
98
+ * Open a browser: connect to the remote one named by BROWSER_WS_ENDPOINT, or
99
+ * launch a local one. Callers close it with browser.close() in both cases —
100
+ * a browserless-style service gives each connection its own browser, so
101
+ * closing ends only this caller's session.
102
+ */
103
+ async function openBrowser() {
104
+ const endpoint = remoteBrowserEndpoint();
105
+ if (endpoint) {
106
+ return puppeteer_1.default.connect({
107
+ browserWSEndpoint: endpoint,
108
+ protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
109
+ });
110
+ }
111
+ return puppeteer_1.default.launch({
112
+ executablePath: await resolveBrowserExecutablePath(),
113
+ args: LOCAL_LAUNCH_ARGS,
114
+ protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
115
+ });
116
+ }
65
117
  /**
66
118
  * Resolve which browser binary to launch, in order of preference:
67
119
  * 1. PUPPETEER_EXECUTABLE_PATH (explicit override)
@@ -421,21 +473,9 @@ class ContentProcessor {
421
473
  let browser = null;
422
474
  let page = null;
423
475
  const launchBrowser = async () => {
424
- const executablePath = await resolveBrowserExecutablePath();
425
- const launchOptions = {
426
- executablePath,
427
- args: [
428
- '--no-sandbox',
429
- '--disable-setuid-sandbox',
430
- '--disable-dev-shm-usage', // Use /tmp instead of /dev/shm (critical in Docker, default 64MB)
431
- '--disable-gpu',
432
- '--disable-extensions',
433
- ],
434
- protocolTimeout: 60000,
435
- };
436
476
  let b;
437
477
  try {
438
- b = await puppeteer_1.default.launch(launchOptions);
478
+ b = await openBrowser();
439
479
  }
440
480
  catch (firstError) {
441
481
  // One retry after a pause — the failure may be transient (e.g.
@@ -445,7 +485,7 @@ class ContentProcessor {
445
485
  logger.warn(`Browser launch failed (${memoryDiagnostics()}), retrying once in 5s...`, firstError);
446
486
  await new Promise(resolve => setTimeout(resolve, 5000));
447
487
  try {
448
- b = await puppeteer_1.default.launch(launchOptions);
488
+ b = await openBrowser();
449
489
  }
450
490
  catch (secondError) {
451
491
  throw new BrowserLaunchError(`browser failed to launch twice (${memoryDiagnostics()}): ${secondError instanceof Error ? secondError.message : String(secondError)}`);
@@ -962,18 +1002,8 @@ class ContentProcessor {
962
1002
  page = existingPage;
963
1003
  }
964
1004
  else {
965
- // Standalone mode: launch a browser for this single page
966
- browser = await puppeteer_1.default.launch({
967
- executablePath: await resolveBrowserExecutablePath(),
968
- args: [
969
- '--no-sandbox',
970
- '--disable-setuid-sandbox',
971
- '--disable-dev-shm-usage',
972
- '--disable-gpu',
973
- '--disable-extensions',
974
- ],
975
- protocolTimeout: 60000,
976
- });
1005
+ // Standalone mode: open a browser for this single page
1006
+ browser = await openBrowser();
977
1007
  page = await browser.newPage();
978
1008
  }
979
1009
  logger.debug(`Navigating to ${url}`);
@@ -1062,7 +1092,7 @@ class ContentProcessor {
1062
1092
  classesToPreserve: ['article-content', 'original-h1'],
1063
1093
  });
1064
1094
  const article = reader.parse();
1065
- if (!article) {
1095
+ if (!article || article.content == null) {
1066
1096
  logger.warn(`Failed to parse article content with Readability for ${url}`);
1067
1097
  return { content: null, links, finalUrl, etag };
1068
1098
  }