@link-assistant/web-capture 1.1.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 (56) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/workflows/release.yml +294 -0
  4. package/.jscpd.json +20 -0
  5. package/.lenv +8 -0
  6. package/.prettierignore +7 -0
  7. package/.prettierrc +10 -0
  8. package/ARCHITECTURE.md +982 -0
  9. package/CHANGELOG.md +22 -0
  10. package/Dockerfile +45 -0
  11. package/LICENSE +24 -0
  12. package/README.md +268 -0
  13. package/apply.sh +166 -0
  14. package/babel.config.cjs +13 -0
  15. package/bin/web-capture.js +285 -0
  16. package/docker-compose.yml +9 -0
  17. package/docs/market-analysis-2025-05-13.md +64 -0
  18. package/eslint.config.js +112 -0
  19. package/examples/js/engine_comparison.js +39 -0
  20. package/examples/js/html_download.js +23 -0
  21. package/examples/js/image_download.js +36 -0
  22. package/examples/js/markdown_download.js +23 -0
  23. package/examples/js/playwright_example.js +23 -0
  24. package/examples/python/html_download.py +15 -0
  25. package/examples/python/image_download.py +20 -0
  26. package/examples/python/markdown_download.py +15 -0
  27. package/examples/python/playwright_example.py +22 -0
  28. package/jest.config.mjs +18 -0
  29. package/package.json +83 -0
  30. package/scripts/changeset-version.mjs +38 -0
  31. package/scripts/create-github-release.mjs +93 -0
  32. package/scripts/create-manual-changeset.mjs +88 -0
  33. package/scripts/format-github-release.mjs +83 -0
  34. package/scripts/format-release-notes.mjs +218 -0
  35. package/scripts/instant-version-bump.mjs +121 -0
  36. package/scripts/publish-to-npm.mjs +128 -0
  37. package/scripts/setup-npm.mjs +37 -0
  38. package/scripts/validate-changeset.mjs +106 -0
  39. package/scripts/version-and-commit.mjs +237 -0
  40. package/src/browser.js +207 -0
  41. package/src/fetch.js +38 -0
  42. package/src/html.js +64 -0
  43. package/src/image.js +41 -0
  44. package/src/index.js +55 -0
  45. package/src/lib.js +330 -0
  46. package/src/markdown.js +17 -0
  47. package/src/stream.js +50 -0
  48. package/tests/e2e/docker.test.js +148 -0
  49. package/tests/e2e/process.test.js +117 -0
  50. package/tests/integration/browser-engines.test.js +128 -0
  51. package/tests/jest.setup.mjs +7 -0
  52. package/tests/mock/index.test.js +223 -0
  53. package/tests/unit/browser.test.js +136 -0
  54. package/tests/unit/cli.test.js +151 -0
  55. package/tests/unit/convertRelativeUrls.test.js +34 -0
  56. package/tests/unit/html2md.test.js +610 -0
package/src/browser.js ADDED
@@ -0,0 +1,207 @@
1
+ // Browser abstraction layer to support both Puppeteer and Playwright
2
+ import puppeteer from 'puppeteer';
3
+ import playwright from 'playwright';
4
+
5
+ /**
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')
11
+ */
12
+
13
+ /**
14
+ * Unified page interface
15
+ * @typedef {Object} PageAdapter
16
+ * @property {Function} setExtraHTTPHeaders - Set HTTP headers
17
+ * @property {Function} setUserAgent - Set user agent
18
+ * @property {Function} setViewport - Set viewport size
19
+ * @property {Function} goto - Navigate to URL
20
+ * @property {Function} content - Get page HTML content
21
+ * @property {Function} screenshot - Take screenshot
22
+ * @property {Function} close - Close the page
23
+ * @property {Object} _page - Original page object
24
+ * @property {string} _type - Browser type
25
+ */
26
+
27
+ /**
28
+ * Create a browser instance using the specified engine
29
+ * @param {string} engine - 'puppeteer' or 'playwright' (defaults to puppeteer)
30
+ * @param {Object} options - Browser launch options
31
+ * @returns {Promise<BrowserAdapter>}
32
+ */
33
+ export async function createBrowser(engine = 'puppeteer', options = {}) {
34
+ const normalizedEngine = engine.toLowerCase();
35
+
36
+ if (normalizedEngine === 'playwright') {
37
+ return createPlaywrightBrowser(options);
38
+ } else {
39
+ return createPuppeteerBrowser(options);
40
+ }
41
+ }
42
+
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
+ };
56
+
57
+ const browser = await puppeteer.launch({ ...defaultOptions, ...options });
58
+
59
+ return {
60
+ 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
+ }
71
+
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
+ };
85
+
86
+ // Playwright uses chromium by default
87
+ const browser = await playwright.chromium.launch({
88
+ ...defaultOptions,
89
+ ...options,
90
+ });
91
+
92
+ return {
93
+ async newPage() {
94
+ const page = await browser.newPage();
95
+ return createPlaywrightPageAdapter(page);
96
+ },
97
+ async close() {
98
+ await browser.close();
99
+ },
100
+ type: 'playwright',
101
+ _browser: browser,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Create a page adapter for Puppeteer
107
+ * @param {Object} page - Puppeteer page object
108
+ * @returns {PageAdapter}
109
+ */
110
+ function createPuppeteerPageAdapter(page) {
111
+ return {
112
+ async setExtraHTTPHeaders(headers) {
113
+ await page.setExtraHTTPHeaders(headers);
114
+ },
115
+ async setUserAgent(userAgent) {
116
+ await page.setUserAgent(userAgent);
117
+ },
118
+ async setViewport(viewport) {
119
+ await page.setViewport(viewport);
120
+ },
121
+ async goto(url, options = {}) {
122
+ await page.goto(url, options);
123
+ },
124
+ async content() {
125
+ return await page.content();
126
+ },
127
+ async screenshot(options = {}) {
128
+ return await page.screenshot(options);
129
+ },
130
+ async close() {
131
+ await page.close();
132
+ },
133
+ _page: page,
134
+ _type: 'puppeteer',
135
+ };
136
+ }
137
+
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);
147
+ },
148
+ async setUserAgent(userAgent) {
149
+ // Playwright doesn't have page.setUserAgent, use extraHTTPHeaders instead
150
+ await page.setExtraHTTPHeaders({ 'User-Agent': userAgent });
151
+ },
152
+ async setViewport(viewport) {
153
+ // Playwright uses setViewportSize instead of setViewport
154
+ await page.setViewportSize(viewport);
155
+ },
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);
163
+ },
164
+ async content() {
165
+ return await page.content();
166
+ },
167
+ async screenshot(options = {}) {
168
+ return await page.screenshot(options);
169
+ },
170
+ async close() {
171
+ await page.close();
172
+ },
173
+ _page: page,
174
+ _type: 'playwright',
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Get the browser engine from query parameters or environment variable
180
+ * @param {Object} req - Express request object
181
+ * @returns {string} - 'puppeteer' or 'playwright'
182
+ */
183
+ export function getBrowserEngine(req) {
184
+ // Check query parameter first
185
+ const engineParam = req.query.engine || req.query.browser;
186
+ if (engineParam) {
187
+ const normalized = engineParam.toLowerCase();
188
+ if (normalized === 'playwright' || normalized === 'pw') {
189
+ return 'playwright';
190
+ }
191
+ if (normalized === 'puppeteer' || normalized === 'pptr') {
192
+ return 'puppeteer';
193
+ }
194
+ }
195
+
196
+ // Check environment variable
197
+ const envEngine = process.env.BROWSER_ENGINE;
198
+ if (envEngine) {
199
+ const normalized = envEngine.toLowerCase();
200
+ if (normalized === 'playwright') {
201
+ return 'playwright';
202
+ }
203
+ }
204
+
205
+ // Default to puppeteer for backward compatibility
206
+ return 'puppeteer';
207
+ }
package/src/fetch.js ADDED
@@ -0,0 +1,38 @@
1
+ import fetch from 'node-fetch';
2
+
3
+ export async function fetchHandler(req, res) {
4
+ const url = req.query.url;
5
+ if (!url) {
6
+ return res.status(400).send('Missing `url` parameter');
7
+ }
8
+ try {
9
+ const response = await fetch(url);
10
+ // Copy status and headers
11
+ res.status(response.status);
12
+
13
+ // Set default content type if not present
14
+ const contentType = response.headers.get('content-type') || 'text/plain';
15
+ res.setHeader('Content-Type', contentType);
16
+
17
+ // Copy other headers
18
+ for (const [key, value] of response.headers.entries()) {
19
+ if (
20
+ key.toLowerCase() !== 'transfer-encoding' &&
21
+ key.toLowerCase() !== 'content-encoding' &&
22
+ key.toLowerCase() !== 'content-length'
23
+ ) {
24
+ res.setHeader(key, value);
25
+ }
26
+ }
27
+
28
+ // Get the response body as buffer and send it
29
+ const buffer = await response.buffer();
30
+ res.send(buffer);
31
+ } catch (err) {
32
+ console.error('Fetch error:', err);
33
+ if (!res.headersSent) {
34
+ res.status(500);
35
+ res.end('Error fetching content');
36
+ }
37
+ }
38
+ }
package/src/html.js ADDED
@@ -0,0 +1,64 @@
1
+ import { fetchHtml, convertToUtf8, convertRelativeUrls } from './lib.js';
2
+ import { createBrowser, getBrowserEngine } from './browser.js';
3
+
4
+ export async function htmlHandler(req, res) {
5
+ const url = req.query.url;
6
+ if (!url) {
7
+ return res.status(400).send('Missing `url` parameter');
8
+ }
9
+ try {
10
+ // Ensure URL is absolute
11
+ const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
12
+
13
+ // First try to fetch HTML directly
14
+ const html = await fetchHtml(absoluteUrl);
15
+
16
+ // Check if it's valid HTML and contains JavaScript
17
+ const hasJavaScript =
18
+ /<script[^>]*>[\s\S]*?<\/script>|<script[^>]*\/>|javascript:/i.test(html);
19
+ const isHtml = /<html[^>]*>[\s\S]*?<\/html>/i.test(html);
20
+
21
+ if (!isHtml || hasJavaScript) {
22
+ // If not HTML or contains JavaScript, use browser to get rendered HTML
23
+ const engine = getBrowserEngine(req);
24
+ const browser = await createBrowser(engine);
25
+ try {
26
+ const page = await browser.newPage();
27
+
28
+ // Set proper encoding headers
29
+ await page.setExtraHTTPHeaders({
30
+ 'Accept-Language': 'en-US,en;q=0.9',
31
+ 'Accept-Charset': 'utf-8',
32
+ });
33
+
34
+ await page.setUserAgent(
35
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
36
+ );
37
+ await page.setViewport({ width: 1280, height: 800 });
38
+ // Navigate to the page
39
+ await page.goto(absoluteUrl, {
40
+ waitUntil: 'networkidle0',
41
+ timeout: 30000,
42
+ });
43
+ // Wait for 5 seconds after page load
44
+ await new Promise((resolve) => setTimeout(resolve, 5000));
45
+ // Get the rendered HTML, convert to UTF-8, and make URLs absolute (and inject runtime JS hook)
46
+ const renderedHtml = await page.content();
47
+ const utf8Html = convertToUtf8(renderedHtml);
48
+ const absoluteHtml = convertRelativeUrls(utf8Html, absoluteUrl);
49
+
50
+ res.type('text/html; charset=utf-8').send(absoluteHtml);
51
+ } finally {
52
+ await browser.close();
53
+ }
54
+ } else {
55
+ // If it's plain HTML without JavaScript, convert to UTF-8 and make URLs absolute
56
+ const utf8Html = convertToUtf8(html);
57
+ const absoluteHtml = convertRelativeUrls(utf8Html, absoluteUrl);
58
+ res.type('text/html; charset=utf-8').send(absoluteHtml);
59
+ }
60
+ } catch (err) {
61
+ console.error('HTML fetch error:', err);
62
+ res.status(500).send('Error fetching HTML');
63
+ }
64
+ }
package/src/image.js ADDED
@@ -0,0 +1,41 @@
1
+ import { createBrowser, getBrowserEngine } from './browser.js';
2
+
3
+ export async function imageHandler(req, res) {
4
+ const url = req.query.url;
5
+ if (!url) {
6
+ return res.status(400).send('Missing `url` parameter');
7
+ }
8
+ try {
9
+ // Ensure URL is absolute
10
+ const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
11
+ const engine = getBrowserEngine(req);
12
+ const browser = await createBrowser(engine);
13
+ try {
14
+ const page = await browser.newPage();
15
+ await page.setExtraHTTPHeaders({
16
+ 'Accept-Language': 'en-US,en;q=0.9',
17
+ 'Accept-Charset': 'utf-8',
18
+ });
19
+ await page.setUserAgent(
20
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
21
+ );
22
+ await page.setViewport({ width: 1280, height: 800 });
23
+ await page.goto(absoluteUrl, {
24
+ waitUntil: 'networkidle0',
25
+ timeout: 30000,
26
+ });
27
+ // Wait for 5 seconds after page load
28
+ await new Promise((resolve) => setTimeout(resolve, 5000));
29
+ // Take a screenshot of just the viewport (not the full page)
30
+ const buffer = await page.screenshot({ type: 'png' });
31
+ res.set('Content-Type', 'image/png');
32
+ res.set('Content-Disposition', 'inline; filename="screenshot.png"');
33
+ res.end(buffer);
34
+ } finally {
35
+ await browser.close();
36
+ }
37
+ } catch (err) {
38
+ console.error(err);
39
+ res.status(500).send('Error capturing screenshot');
40
+ }
41
+ }
package/src/index.js ADDED
@@ -0,0 +1,55 @@
1
+ import express from 'express';
2
+ import { fileURLToPath } from 'url';
3
+ import { htmlHandler } from './html.js';
4
+ import { markdownHandler } from './markdown.js';
5
+ import { imageHandler } from './image.js';
6
+ import { streamHandler } from './stream.js';
7
+ import { fetchHandler } from './fetch.js';
8
+
9
+ const app = express();
10
+ const port = process.env.PORT || 3000;
11
+
12
+ app.get('/html', htmlHandler);
13
+ app.get('/markdown', markdownHandler);
14
+ app.get('/image', imageHandler);
15
+ app.get('/stream', streamHandler);
16
+ app.get('/fetch', fetchHandler);
17
+
18
+ // Start the server if this is the main module
19
+ const isMainModule =
20
+ process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
21
+ let server;
22
+ if (isMainModule) {
23
+ console.log('Process PID:', process.pid);
24
+ server = app.listen(port, () => {
25
+ console.log(`Renderer service listening on http://localhost:${port}`);
26
+ });
27
+
28
+ // Graceful shutdown for Docker
29
+ function shutdown(signal) {
30
+ console.log(`Received shutdown signal (${signal}), closing server...`);
31
+ server.close(() => {
32
+ console.log('Server closed. Exiting process.');
33
+ process.exit(0);
34
+ });
35
+ // Force exit if not closed in 2 seconds
36
+ setTimeout(() => {
37
+ console.error('Force exiting after 2s');
38
+ process.exit(1);
39
+ }, 2000);
40
+ }
41
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
42
+ process.on('SIGINT', () => shutdown('SIGINT'));
43
+
44
+ process.on('exit', (code) => {
45
+ console.log('Process exit event with code:', code);
46
+ });
47
+ process.on('uncaughtException', (err) => {
48
+ console.error('Uncaught Exception:', err);
49
+ });
50
+ process.on('unhandledRejection', (reason, promise) => {
51
+ console.error('Unhandled Rejection at:', promise, 'reason:', reason);
52
+ });
53
+ }
54
+
55
+ export { app };