@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.
- package/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +294 -0
- package/.jscpd.json +20 -0
- package/.lenv +8 -0
- package/.prettierignore +7 -0
- package/.prettierrc +10 -0
- package/ARCHITECTURE.md +982 -0
- package/CHANGELOG.md +22 -0
- package/Dockerfile +45 -0
- package/LICENSE +24 -0
- package/README.md +268 -0
- package/apply.sh +166 -0
- package/babel.config.cjs +13 -0
- package/bin/web-capture.js +285 -0
- package/docker-compose.yml +9 -0
- package/docs/market-analysis-2025-05-13.md +64 -0
- package/eslint.config.js +112 -0
- package/examples/js/engine_comparison.js +39 -0
- package/examples/js/html_download.js +23 -0
- package/examples/js/image_download.js +36 -0
- package/examples/js/markdown_download.js +23 -0
- package/examples/js/playwright_example.js +23 -0
- package/examples/python/html_download.py +15 -0
- package/examples/python/image_download.py +20 -0
- package/examples/python/markdown_download.py +15 -0
- package/examples/python/playwright_example.py +22 -0
- package/jest.config.mjs +18 -0
- package/package.json +83 -0
- package/scripts/changeset-version.mjs +38 -0
- package/scripts/create-github-release.mjs +93 -0
- package/scripts/create-manual-changeset.mjs +88 -0
- package/scripts/format-github-release.mjs +83 -0
- package/scripts/format-release-notes.mjs +218 -0
- package/scripts/instant-version-bump.mjs +121 -0
- package/scripts/publish-to-npm.mjs +128 -0
- package/scripts/setup-npm.mjs +37 -0
- package/scripts/validate-changeset.mjs +106 -0
- package/scripts/version-and-commit.mjs +237 -0
- package/src/browser.js +207 -0
- package/src/fetch.js +38 -0
- package/src/html.js +64 -0
- package/src/image.js +41 -0
- package/src/index.js +55 -0
- package/src/lib.js +330 -0
- package/src/markdown.js +17 -0
- package/src/stream.js +50 -0
- package/tests/e2e/docker.test.js +148 -0
- package/tests/e2e/process.test.js +117 -0
- package/tests/integration/browser-engines.test.js +128 -0
- package/tests/jest.setup.mjs +7 -0
- package/tests/mock/index.test.js +223 -0
- package/tests/unit/browser.test.js +136 -0
- package/tests/unit/cli.test.js +151 -0
- package/tests/unit/convertRelativeUrls.test.js +34 -0
- 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 };
|