@link-assistant/web-capture 1.1.2 → 1.4.2

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +17 -14
  2. package/README.md +256 -146
  3. package/bin/web-capture.js +403 -6
  4. package/package.json +11 -4
  5. package/src/animation.js +283 -0
  6. package/src/archive.js +212 -0
  7. package/src/batch.js +180 -0
  8. package/src/browser.js +150 -111
  9. package/src/docx.js +182 -0
  10. package/src/figures.js +208 -0
  11. package/src/gdocs.js +333 -0
  12. package/src/image.js +65 -8
  13. package/src/index.js +15 -0
  14. package/src/latex.js +127 -0
  15. package/src/lib.js +226 -1
  16. package/src/localize-images.js +202 -0
  17. package/src/metadata.js +333 -0
  18. package/src/pdf.js +76 -0
  19. package/src/popups.js +171 -0
  20. package/src/postprocess.js +265 -0
  21. package/src/retry.js +48 -0
  22. package/src/themed-image.js +167 -0
  23. package/src/verify.js +312 -0
  24. package/tests/e2e/docker.test.js +51 -12
  25. package/tests/e2e/process.test.js +34 -10
  26. package/tests/fixtures/habr-articles.js +45 -0
  27. package/tests/integration/api-endpoints.test.js +231 -0
  28. package/tests/integration/habr-article.test.js +337 -0
  29. package/tests/mock/index.test.js +5 -3
  30. package/tests/unit/animation.test.js +33 -0
  31. package/tests/unit/batch.test.js +114 -0
  32. package/tests/unit/browser.test.js +38 -2
  33. package/tests/unit/figures.test.js +74 -0
  34. package/tests/unit/gdocs.test.js +203 -0
  35. package/tests/unit/latex.test.js +123 -0
  36. package/tests/unit/localize-images.test.js +78 -0
  37. package/tests/unit/metadata.test.js +140 -0
  38. package/tests/unit/postprocess.test.js +108 -0
  39. package/tests/unit/retry.test.js +111 -0
  40. package/tests/unit/verify.test.js +182 -0
  41. package/.github/workflows/release.yml +0 -294
  42. package/ARCHITECTURE.md +0 -982
  43. package/LICENSE +0 -24
  44. package/apply.sh +0 -166
  45. package/docs/market-analysis-2025-05-13.md +0 -64
  46. package/scripts/changeset-version.mjs +0 -38
  47. package/scripts/create-github-release.mjs +0 -93
  48. package/scripts/create-manual-changeset.mjs +0 -88
  49. package/scripts/format-github-release.mjs +0 -83
  50. package/scripts/format-release-notes.mjs +0 -218
  51. package/scripts/instant-version-bump.mjs +0 -121
  52. package/scripts/publish-to-npm.mjs +0 -128
  53. package/scripts/setup-npm.mjs +0 -37
  54. package/scripts/validate-changeset.mjs +0 -106
  55. package/scripts/version-and-commit.mjs +0 -237
package/src/browser.js CHANGED
@@ -1,17 +1,37 @@
1
- // Browser abstraction layer to support both Puppeteer and Playwright
2
- import puppeteer from 'puppeteer';
3
- import playwright from 'playwright';
1
+ // Browser abstraction layer using browser-commander for all browser operations
2
+ // See: https://github.com/link-foundation/browser-commander
3
+
4
+ import {
5
+ launchBrowser,
6
+ makeBrowserCommander,
7
+ emulateMedia,
8
+ } from 'browser-commander';
9
+ import os from 'os';
10
+ import path from 'path';
4
11
 
5
12
  /**
6
- * Unified browser interface that works with both Puppeteer and Playwright
7
- * @typedef {Object} BrowserAdapter
8
- * @property {Function} newPage - Create a new page
9
- * @property {Function} close - Close the browser
10
- * @property {string} type - Browser type ('puppeteer' or 'playwright')
13
+ * Additional Chrome args needed for headless server environments
14
+ * These are appended to browser-commander's default CHROME_ARGS
11
15
  */
16
+ const SERVER_CHROME_ARGS = [
17
+ '--no-sandbox',
18
+ '--disable-setuid-sandbox',
19
+ '--disable-dev-shm-usage',
20
+ ];
12
21
 
13
22
  /**
14
- * Unified page interface
23
+ * Unified page interface wrapping browser-commander's makeBrowserCommander.
24
+ *
25
+ * Exposes:
26
+ * - setExtraHTTPHeaders / setUserAgent / setViewport / goto / content / screenshot / close
27
+ * (via the underlying raw page for cross-engine compatibility)
28
+ * - commander.pdf() – PDF generation (browser-commander v0.8.0)
29
+ * - commander.emulateMedia() – Color scheme emulation (browser-commander v0.7.0)
30
+ * - commander.keyboard – Keyboard interaction (browser-commander v0.7.0)
31
+ * - commander.onDialog() – Dialog event handling (browser-commander v0.7.0)
32
+ * - commander.evaluate() – Page evaluation (browser-commander)
33
+ * All other browser-commander APIs are available on the returned commander object.
34
+ *
15
35
  * @typedef {Object} PageAdapter
16
36
  * @property {Function} setExtraHTTPHeaders - Set HTTP headers
17
37
  * @property {Function} setUserAgent - Set user agent
@@ -20,158 +40,177 @@ import playwright from 'playwright';
20
40
  * @property {Function} content - Get page HTML content
21
41
  * @property {Function} screenshot - Take screenshot
22
42
  * @property {Function} close - Close the page
23
- * @property {Object} _page - Original page object
24
- * @property {string} _type - Browser type
43
+ * @property {Function} pdf - Generate PDF (browser-commander v0.8.0)
44
+ * @property {Function} emulateMedia - Color scheme emulation (browser-commander v0.7.0)
45
+ * @property {Object} keyboard - Keyboard interaction (browser-commander v0.7.0)
46
+ * @property {Function} onDialog - Dialog event handling (browser-commander v0.7.0)
47
+ * @property {Function} evaluate - Page evaluation
48
+ * @property {string} type - Browser type ('puppeteer' or 'playwright')
25
49
  */
26
50
 
27
51
  /**
28
- * Create a browser instance using the specified engine
52
+ * Create a browser instance using the specified engine.
53
+ * Uses browser-commander's launchBrowser + makeBrowserCommander facade.
54
+ *
29
55
  * @param {string} engine - 'puppeteer' or 'playwright' (defaults to puppeteer)
30
56
  * @param {Object} options - Browser launch options
31
- * @returns {Promise<BrowserAdapter>}
57
+ * @param {string} [options.colorScheme] - Color scheme: 'light', 'dark', or 'no-preference'
58
+ * @returns {Promise<Object>} - Browser handle with newPage() and close()
32
59
  */
33
60
  export async function createBrowser(engine = 'puppeteer', options = {}) {
34
61
  const normalizedEngine = engine.toLowerCase();
62
+ const engineType =
63
+ normalizedEngine === 'playwright' || normalizedEngine === 'pw'
64
+ ? 'playwright'
65
+ : 'puppeteer';
35
66
 
36
- if (normalizedEngine === 'playwright') {
37
- return createPlaywrightBrowser(options);
38
- } else {
39
- return createPuppeteerBrowser(options);
40
- }
41
- }
67
+ const { colorScheme, ...launchOpts } = options;
42
68
 
43
- /**
44
- * Create a Puppeteer browser instance
45
- * @param {Object} options - Puppeteer launch options
46
- * @returns {Promise<BrowserAdapter>}
47
- */
48
- async function createPuppeteerBrowser(options = {}) {
49
- const defaultOptions = {
50
- args: [
51
- '--no-sandbox',
52
- '--disable-setuid-sandbox',
53
- '--disable-dev-shm-usage',
54
- ],
55
- };
69
+ // Generate unique userDataDir for this session to avoid conflicts
70
+ const userDataDir = path.join(
71
+ os.tmpdir(),
72
+ `web-capture-${engineType}-${Date.now()}`
73
+ );
74
+
75
+ // Use browser-commander's launchBrowser with server-specific args
76
+ const { browser, page: initialPage } = await launchBrowser({
77
+ engine: engineType,
78
+ args: SERVER_CHROME_ARGS,
79
+ headless: true,
80
+ userDataDir,
81
+ slowMo: 0,
82
+ ...launchOpts,
83
+ });
56
84
 
57
- const browser = await puppeteer.launch({ ...defaultOptions, ...options });
85
+ // Close the initial page since we'll create new ones via newPage()
86
+ await initialPage.close();
58
87
 
59
88
  return {
60
89
  async newPage() {
61
- const page = await browser.newPage();
62
- return createPuppeteerPageAdapter(page);
63
- },
64
- async close() {
65
- await browser.close();
66
- },
67
- type: 'puppeteer',
68
- _browser: browser,
69
- };
70
- }
90
+ const rawPage = await browser.newPage();
71
91
 
72
- /**
73
- * Create a Playwright browser instance
74
- * @param {Object} options - Playwright launch options
75
- * @returns {Promise<BrowserAdapter>}
76
- */
77
- async function createPlaywrightBrowser(options = {}) {
78
- const defaultOptions = {
79
- args: [
80
- '--no-sandbox',
81
- '--disable-setuid-sandbox',
82
- '--disable-dev-shm-usage',
83
- ],
84
- };
92
+ // Apply color scheme emulation using browser-commander's unified API (v0.7.0+)
93
+ if (colorScheme) {
94
+ try {
95
+ await emulateMedia({
96
+ page: rawPage,
97
+ engine: engineType,
98
+ colorScheme,
99
+ });
100
+ } catch {
101
+ /* emulateMedia not available in all environments */
102
+ }
103
+ }
85
104
 
86
- // Playwright uses chromium by default
87
- const browser = await playwright.chromium.launch({
88
- ...defaultOptions,
89
- ...options,
90
- });
105
+ // Wrap with the full browser-commander facade for unified API access
106
+ const commander = makeBrowserCommander({ page: rawPage });
91
107
 
92
- return {
93
- async newPage() {
94
- const page = await browser.newPage();
95
- return createPlaywrightPageAdapter(page);
108
+ // Build a unified page adapter that exposes both:
109
+ // 1. The familiar cross-engine adapter interface (setExtraHTTPHeaders, goto, etc.)
110
+ // 2. All browser-commander v0.7.0+ APIs (pdf, emulateMedia, keyboard, onDialog, evaluate)
111
+ return createPageAdapter(rawPage, commander, engineType);
96
112
  },
97
113
  async close() {
98
114
  await browser.close();
99
115
  },
100
- type: 'playwright',
101
- _browser: browser,
116
+ type: engineType,
117
+ rawBrowser: browser,
102
118
  };
103
119
  }
104
120
 
105
121
  /**
106
- * Create a page adapter for Puppeteer
107
- * @param {Object} page - Puppeteer page object
122
+ * Create a unified page adapter from a raw page and its browser-commander facade.
123
+ * Provides a stable cross-engine interface plus all new browser-commander APIs.
124
+ *
125
+ * @param {Object} rawPage - Raw Playwright or Puppeteer page
126
+ * @param {Object} commander - browser-commander facade from makeBrowserCommander
127
+ * @param {string} engineType - 'puppeteer' or 'playwright'
108
128
  * @returns {PageAdapter}
109
129
  */
110
- function createPuppeteerPageAdapter(page) {
130
+ function createPageAdapter(rawPage, commander, engineType) {
111
131
  return {
132
+ // --- Cross-engine adapter methods (stable interface) ---
133
+
112
134
  async setExtraHTTPHeaders(headers) {
113
- await page.setExtraHTTPHeaders(headers);
135
+ await rawPage.setExtraHTTPHeaders(headers);
114
136
  },
115
137
  async setUserAgent(userAgent) {
116
- await page.setUserAgent(userAgent);
138
+ if (engineType === 'playwright') {
139
+ // Playwright doesn't have page.setUserAgent, use extraHTTPHeaders instead
140
+ await rawPage.setExtraHTTPHeaders({ 'User-Agent': userAgent });
141
+ } else {
142
+ await rawPage.setUserAgent(userAgent);
143
+ }
117
144
  },
118
145
  async setViewport(viewport) {
119
- await page.setViewport(viewport);
146
+ if (engineType === 'playwright') {
147
+ // Playwright uses setViewportSize instead of setViewport
148
+ await rawPage.setViewportSize(viewport);
149
+ } else {
150
+ await rawPage.setViewport(viewport);
151
+ }
120
152
  },
121
153
  async goto(url, options = {}) {
122
- await page.goto(url, options);
154
+ if (engineType === 'playwright') {
155
+ // Convert Puppeteer waitUntil options to Playwright equivalents
156
+ const playwrightOptions = { ...options };
157
+ if (playwrightOptions.waitUntil === 'networkidle0') {
158
+ playwrightOptions.waitUntil = 'networkidle';
159
+ }
160
+ await rawPage.goto(url, playwrightOptions);
161
+ } else {
162
+ await rawPage.goto(url, options);
163
+ }
123
164
  },
124
165
  async content() {
125
- return await page.content();
166
+ return await rawPage.content();
126
167
  },
127
168
  async screenshot(options = {}) {
128
- return await page.screenshot(options);
169
+ return await rawPage.screenshot(options);
129
170
  },
130
171
  async close() {
131
- await page.close();
172
+ await commander.destroy();
173
+ await rawPage.close();
132
174
  },
133
- _page: page,
134
- _type: 'puppeteer',
135
- };
136
- }
137
175
 
138
- /**
139
- * Create a page adapter for Playwright
140
- * @param {Object} page - Playwright page object
141
- * @returns {PageAdapter}
142
- */
143
- function createPlaywrightPageAdapter(page) {
144
- return {
145
- async setExtraHTTPHeaders(headers) {
146
- await page.setExtraHTTPHeaders(headers);
176
+ // --- browser-commander v0.8.0+: PDF generation ---
177
+ // Usage: await page.pdf({ pdfOptions: { format: 'A4', printBackground: true } })
178
+ async pdf(opts = {}) {
179
+ return await commander.pdf(opts);
147
180
  },
148
- async setUserAgent(userAgent) {
149
- // Playwright doesn't have page.setUserAgent, use extraHTTPHeaders instead
150
- await page.setExtraHTTPHeaders({ 'User-Agent': userAgent });
181
+
182
+ // --- browser-commander v0.7.0+: Color scheme emulation ---
183
+ // Usage: await page.emulateMedia({ colorScheme: 'dark' })
184
+ async emulateMedia(opts = {}) {
185
+ return await commander.emulateMedia(opts);
151
186
  },
152
- async setViewport(viewport) {
153
- // Playwright uses setViewportSize instead of setViewport
154
- await page.setViewportSize(viewport);
187
+
188
+ // --- browser-commander v0.7.0+: Keyboard interaction ---
189
+ // Usage: await page.keyboard.press('Escape')
190
+ get keyboard() {
191
+ return commander.keyboard;
155
192
  },
156
- async goto(url, options = {}) {
157
- // Convert Puppeteer waitUntil options to Playwright equivalents
158
- const playwrightOptions = { ...options };
159
- if (playwrightOptions.waitUntil === 'networkidle0') {
160
- playwrightOptions.waitUntil = 'networkidle';
161
- }
162
- await page.goto(url, playwrightOptions);
193
+
194
+ // --- browser-commander v0.7.0+: Dialog event handling ---
195
+ // Usage: page.onDialog(async (dialog) => { await dialog.dismiss(); })
196
+ onDialog(handler) {
197
+ return commander.onDialog(handler);
163
198
  },
164
- async content() {
165
- return await page.content();
199
+ offDialog(handler) {
200
+ return commander.offDialog(handler);
166
201
  },
167
- async screenshot(options = {}) {
168
- return await page.screenshot(options);
202
+ clearDialogHandlers() {
203
+ return commander.clearDialogHandlers();
169
204
  },
170
- async close() {
171
- await page.close();
205
+
206
+ // --- browser-commander: Page evaluation ---
207
+ // Usage: await page.evaluate(() => window.scrollTo(0, 0))
208
+ async evaluate(fn, ...args) {
209
+ return await commander.evaluate({ fn, args });
172
210
  },
173
- _page: page,
174
- _type: 'playwright',
211
+
212
+ // Engine type identifier
213
+ type: engineType,
175
214
  };
176
215
  }
177
216
 
package/src/docx.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * DOCX export handler.
3
+ *
4
+ * Converts a web page to a DOCX document with embedded images.
5
+ * Uses the page's markdown conversion and the `docx` library to build the document.
6
+ *
7
+ * Query parameters:
8
+ * url (required) - URL to convert
9
+ * engine - 'puppeteer' or 'playwright' (only used for browser rendering fallback)
10
+ */
11
+
12
+ import fetch from 'node-fetch';
13
+ import * as cheerio from 'cheerio';
14
+ import { URL } from 'url';
15
+ import {
16
+ Document,
17
+ Packer,
18
+ Paragraph,
19
+ TextRun,
20
+ HeadingLevel,
21
+ ImageRun,
22
+ ExternalHyperlink,
23
+ } from 'docx';
24
+ import { fetchHtml, convertRelativeUrls } from './lib.js';
25
+
26
+ export async function docxHandler(req, res) {
27
+ const url = req.query.url;
28
+ if (!url) {
29
+ return res.status(400).send('Missing `url` parameter');
30
+ }
31
+
32
+ try {
33
+ const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
34
+ const html = await fetchHtml(absoluteUrl);
35
+ const absHtml = convertRelativeUrls(html, absoluteUrl);
36
+ const $ = cheerio.load(absHtml);
37
+
38
+ // Remove unwanted elements
39
+ $('style, script, noscript, nav, footer, header').remove();
40
+
41
+ // Build DOCX sections from HTML structure
42
+ const children = [];
43
+
44
+ // Extract title
45
+ const title = $('h1').first().text().trim() || $('title').text().trim();
46
+ if (title) {
47
+ children.push(
48
+ new Paragraph({ text: title, heading: HeadingLevel.TITLE })
49
+ );
50
+ }
51
+
52
+ // Process body content
53
+ const body = $('article').length ? $('article') : $('body');
54
+ for (const el of body.children().toArray()) {
55
+ const tagName = (el.tagName || '').toLowerCase();
56
+ const text = $(el).text().trim();
57
+
58
+ if (!text && tagName !== 'img' && tagName !== 'figure') {
59
+ continue;
60
+ }
61
+
62
+ if (tagName === 'h1') {
63
+ children.push(new Paragraph({ text, heading: HeadingLevel.HEADING_1 }));
64
+ } else if (tagName === 'h2') {
65
+ children.push(new Paragraph({ text, heading: HeadingLevel.HEADING_2 }));
66
+ } else if (tagName === 'h3') {
67
+ children.push(new Paragraph({ text, heading: HeadingLevel.HEADING_3 }));
68
+ } else if (tagName === 'h4' || tagName === 'h5' || tagName === 'h6') {
69
+ children.push(new Paragraph({ text, heading: HeadingLevel.HEADING_4 }));
70
+ } else if (tagName === 'figure' || tagName === 'img') {
71
+ const img = tagName === 'img' ? $(el) : $(el).find('img').first();
72
+ const src = img.attr('src');
73
+ if (src && !src.startsWith('data:')) {
74
+ try {
75
+ const imgUrl = new URL(src, absoluteUrl).href;
76
+ const imgResp = await fetch(imgUrl);
77
+ if (imgResp.ok) {
78
+ const buffer = await imgResp.buffer();
79
+ children.push(
80
+ new Paragraph({
81
+ children: [
82
+ new ImageRun({
83
+ data: buffer,
84
+ transformation: { width: 600, height: 400 },
85
+ type: guessImageType(imgUrl),
86
+ }),
87
+ ],
88
+ })
89
+ );
90
+ }
91
+ } catch {
92
+ /* skip failed image embeds */
93
+ }
94
+ }
95
+ // Add caption if present
96
+ const caption = $(el).find('figcaption').text().trim();
97
+ if (caption) {
98
+ children.push(
99
+ new Paragraph({
100
+ children: [new TextRun({ text: caption, italics: true })],
101
+ })
102
+ );
103
+ }
104
+ } else if (tagName === 'ul' || tagName === 'ol') {
105
+ $(el)
106
+ .find('li')
107
+ .each(function () {
108
+ const liText = $(this).text().trim();
109
+ if (liText) {
110
+ children.push(
111
+ new Paragraph({
112
+ text: liText,
113
+ bullet: { level: 0 },
114
+ })
115
+ );
116
+ }
117
+ });
118
+ } else if (tagName === 'pre' || tagName === 'code') {
119
+ children.push(
120
+ new Paragraph({
121
+ children: [new TextRun({ text, font: 'Courier New', size: 20 })],
122
+ })
123
+ );
124
+ } else if (tagName === 'a') {
125
+ const href = $(el).attr('href');
126
+ if (href && text) {
127
+ children.push(
128
+ new Paragraph({
129
+ children: [
130
+ new ExternalHyperlink({
131
+ children: [
132
+ new TextRun({
133
+ text,
134
+ style: 'Hyperlink',
135
+ }),
136
+ ],
137
+ link: href,
138
+ }),
139
+ ],
140
+ })
141
+ );
142
+ }
143
+ } else {
144
+ // Default: treat as paragraph
145
+ if (text) {
146
+ children.push(new Paragraph({ text }));
147
+ }
148
+ }
149
+ }
150
+
151
+ if (children.length === 0) {
152
+ children.push(new Paragraph({ text: 'No content extracted.' }));
153
+ }
154
+
155
+ const doc = new Document({
156
+ sections: [{ children }],
157
+ });
158
+
159
+ const buffer = await Packer.toBuffer(doc);
160
+
161
+ res.set(
162
+ 'Content-Type',
163
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
164
+ );
165
+ res.set('Content-Disposition', 'attachment; filename="page.docx"');
166
+ res.end(buffer);
167
+ } catch (err) {
168
+ console.error(err);
169
+ res.status(500).send('Error generating DOCX');
170
+ }
171
+ }
172
+
173
+ function guessImageType(url) {
174
+ const pathname = new URL(url).pathname.toLowerCase();
175
+ if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) {
176
+ return 'jpg';
177
+ }
178
+ if (pathname.endsWith('.gif')) {
179
+ return 'gif';
180
+ }
181
+ return 'png';
182
+ }