@link-assistant/web-capture 1.10.5 → 1.10.7
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/.prettierignore +1 -0
- package/CHANGELOG.md +12 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/browser.js +2 -2
- package/src/markdown.js +171 -5
- package/tests/fixtures/deepwiki/deepwiki-full-page.png +0 -0
- package/tests/fixtures/deepwiki/index.md +1500 -0
- package/tests/fixtures/deepwiki/part-1.md +776 -0
- package/tests/helpers/search-fixture.cjs +17 -0
- package/tests/integration/deepwiki-download.test.js +144 -0
- package/tests/integration/deepwiki-rendered-page.mock.test.js +121 -0
- package/tests/integration/formalai-contract.test.js +214 -0
- package/tests/unit/cli.test.js +104 -7
- package/tests/unit/markdown.test.js +36 -0
package/.prettierignore
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.10.7
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5311a23: Document the FormalAI-compatible capture/search contract and add smoke tests for stable HTTP and CLI response shapes.
|
|
8
|
+
|
|
9
|
+
## 1.10.6
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Render JavaScript-heavy pages before Markdown conversion so DeepWiki search pages capture their full content.
|
|
14
|
+
|
|
3
15
|
## 1.10.5
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -127,6 +127,8 @@ parsing. For very large or slow documents, tune the wait with
|
|
|
127
127
|
## API Endpoints (Server Mode)
|
|
128
128
|
|
|
129
129
|
Start the server with `web-capture --serve` and use the endpoints below.
|
|
130
|
+
FormalAI integration should use the stable HTTP/CLI contract documented in
|
|
131
|
+
[`../docs/formalai-contract.md`](../docs/formalai-contract.md).
|
|
130
132
|
|
|
131
133
|
### HTML Endpoint
|
|
132
134
|
|
package/package.json
CHANGED
package/src/browser.js
CHANGED
|
@@ -65,7 +65,7 @@ export async function createBrowser(engine = 'puppeteer', options = {}) {
|
|
|
65
65
|
? 'playwright'
|
|
66
66
|
: 'puppeteer';
|
|
67
67
|
|
|
68
|
-
const { colorScheme, ...launchOpts } = options;
|
|
68
|
+
const { colorScheme, args = [], ...launchOpts } = options;
|
|
69
69
|
|
|
70
70
|
// Generate unique userDataDir for this session to avoid conflicts
|
|
71
71
|
const userDataDir = path.join(
|
|
@@ -76,7 +76,7 @@ export async function createBrowser(engine = 'puppeteer', options = {}) {
|
|
|
76
76
|
// Use browser-commander's launchBrowser with server-specific args
|
|
77
77
|
const { browser, page: initialPage } = await launchBrowser({
|
|
78
78
|
engine: engineType,
|
|
79
|
-
args: SERVER_CHROME_ARGS,
|
|
79
|
+
args: [...SERVER_CHROME_ARGS, ...args],
|
|
80
80
|
headless: true,
|
|
81
81
|
userDataDir,
|
|
82
82
|
slowMo: 0,
|
package/src/markdown.js
CHANGED
|
@@ -15,10 +15,19 @@ import {
|
|
|
15
15
|
} from './github.js';
|
|
16
16
|
import { convertWithKreuzberg, isKreuzbergAvailable } from './kreuzberg.js';
|
|
17
17
|
import { applyImageMode } from './extract-images.js';
|
|
18
|
+
import { createBrowser, getBrowserEngine } from './browser.js';
|
|
19
|
+
import { retry } from './retry.js';
|
|
18
20
|
import archiver from 'archiver';
|
|
19
21
|
import fetch from 'node-fetch';
|
|
22
|
+
import * as cheerio from 'cheerio';
|
|
20
23
|
|
|
21
24
|
const INLINE_MARKDOWN_LINE_LIMIT = 1500;
|
|
25
|
+
const RENDERED_PAGE_SETTLE_TIMEOUT_MS = 5000;
|
|
26
|
+
const RENDERED_PAGE_STABLE_FOR_MS = 1000;
|
|
27
|
+
const RENDERED_PAGE_POLL_MS = 250;
|
|
28
|
+
const RENDERED_PAGE_MIN_TEXT_LENGTH = 200;
|
|
29
|
+
const DESKTOP_USER_AGENT =
|
|
30
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
22
31
|
|
|
23
32
|
export async function markdownHandler(req, res) {
|
|
24
33
|
const url = req.query.url;
|
|
@@ -43,7 +52,7 @@ export async function markdownHandler(req, res) {
|
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
try {
|
|
46
|
-
const pageUrl = normalizeUrlForTextPage(url);
|
|
55
|
+
const pageUrl = ensureAbsoluteUrl(normalizeUrlForTextPage(url));
|
|
47
56
|
if (
|
|
48
57
|
format === 'text' &&
|
|
49
58
|
!req.query.contentSelector &&
|
|
@@ -56,10 +65,10 @@ export async function markdownHandler(req, res) {
|
|
|
56
65
|
mode: embedImages ? 'embed' : 'default',
|
|
57
66
|
});
|
|
58
67
|
markdown = result.markdown;
|
|
59
|
-
return await sendMarkdownResponse(res,
|
|
68
|
+
return await sendMarkdownResponse(res, pageUrl, markdown);
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
const html = await
|
|
71
|
+
const html = await fetchMarkdownHtml(req, pageUrl);
|
|
63
72
|
|
|
64
73
|
if (converter === 'kreuzberg') {
|
|
65
74
|
const available = await isKreuzbergAvailable();
|
|
@@ -86,7 +95,7 @@ export async function markdownHandler(req, res) {
|
|
|
86
95
|
if (format === 'json') {
|
|
87
96
|
return res.json(result);
|
|
88
97
|
}
|
|
89
|
-
return await sendMarkdownResponse(res,
|
|
98
|
+
return await sendMarkdownResponse(res, pageUrl, result.content);
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
let { markdown } = convertHtmlToMarkdownEnhanced(html, pageUrl, {
|
|
@@ -100,13 +109,170 @@ export async function markdownHandler(req, res) {
|
|
|
100
109
|
mode: embedImages ? 'embed' : 'default',
|
|
101
110
|
});
|
|
102
111
|
markdown = result.markdown;
|
|
103
|
-
return await sendMarkdownResponse(res,
|
|
112
|
+
return await sendMarkdownResponse(res, pageUrl, markdown);
|
|
104
113
|
} catch (err) {
|
|
105
114
|
console.error(err);
|
|
106
115
|
res.status(500).send('Error converting to Markdown');
|
|
107
116
|
}
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
function ensureAbsoluteUrl(url) {
|
|
120
|
+
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function fetchMarkdownHtml(req, pageUrl) {
|
|
124
|
+
const html = await fetchHtml(pageUrl);
|
|
125
|
+
if (!shouldRenderHtmlWithBrowser(html)) {
|
|
126
|
+
return html;
|
|
127
|
+
}
|
|
128
|
+
return await renderHtmlWithBrowser(req, pageUrl);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function shouldRenderHtmlWithBrowser(html) {
|
|
132
|
+
const isCompleteHtml = /<html[\s>][\s\S]*<\/html>/i.test(html);
|
|
133
|
+
if (!isCompleteHtml) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!hasClientRenderedAppMarkers(html)) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return getStaticBodyTextLength(html) < RENDERED_PAGE_MIN_TEXT_LENGTH;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hasClientRenderedAppMarkers(html) {
|
|
145
|
+
return /(?:__NEXT_DATA__|self\.__next_f|\/_next\/static|id=["']__(?:next|nuxt)["']|id=["']root["']|window\.__NUXT__|ng-version=|\/@vite\/client)/i.test(
|
|
146
|
+
html
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getStaticBodyTextLength(html) {
|
|
151
|
+
const $ = cheerio.load(html);
|
|
152
|
+
$('script, style, noscript, template').remove();
|
|
153
|
+
return $('body').text().replace(/\s+/g, ' ').trim().length;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function renderHtmlWithBrowser(req, pageUrl) {
|
|
157
|
+
const engine = getBrowserEngine(req);
|
|
158
|
+
const browser = await createBrowser(engine, {
|
|
159
|
+
args: [
|
|
160
|
+
`--user-agent=${DESKTOP_USER_AGENT}`,
|
|
161
|
+
'--window-size=1280,800',
|
|
162
|
+
'--disable-blink-features=AutomationControlled',
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
try {
|
|
166
|
+
const page = await browser.newPage();
|
|
167
|
+
await prepareRenderedPage(page);
|
|
168
|
+
await navigateRenderedPage(page, pageUrl);
|
|
169
|
+
await waitForRenderedPageToSettle(page);
|
|
170
|
+
return await page.content();
|
|
171
|
+
} finally {
|
|
172
|
+
await browser.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function prepareRenderedPage(page) {
|
|
177
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
178
|
+
if (page.type === 'playwright') {
|
|
179
|
+
await page.setExtraHTTPHeaders({
|
|
180
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
181
|
+
'Accept-Charset': 'utf-8',
|
|
182
|
+
'User-Agent': DESKTOP_USER_AGENT,
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await page.setExtraHTTPHeaders({
|
|
188
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
189
|
+
'Accept-Charset': 'utf-8',
|
|
190
|
+
});
|
|
191
|
+
await page.setUserAgent(DESKTOP_USER_AGENT);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function navigateRenderedPage(page, pageUrl) {
|
|
195
|
+
await retry(
|
|
196
|
+
async () => {
|
|
197
|
+
try {
|
|
198
|
+
await page.goto(pageUrl, {
|
|
199
|
+
waitUntil: 'networkidle0',
|
|
200
|
+
timeout: 60000,
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (!/timeout/i.test(err.message || '')) {
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
await page.goto(pageUrl, {
|
|
207
|
+
waitUntil: 'domcontentloaded',
|
|
208
|
+
timeout: 60000,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
retries: 2,
|
|
214
|
+
baseDelay: 2000,
|
|
215
|
+
onRetry: (err, attempt, delay) => {
|
|
216
|
+
console.log(
|
|
217
|
+
`Rendered markdown navigation retry ${attempt} for ${pageUrl} after ${delay}ms: ${err.message}`
|
|
218
|
+
);
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function waitForRenderedPageToSettle(page) {
|
|
225
|
+
const deadline = Date.now() + RENDERED_PAGE_SETTLE_TIMEOUT_MS;
|
|
226
|
+
let lastTextLength = -1;
|
|
227
|
+
let stableSince = 0;
|
|
228
|
+
|
|
229
|
+
while (Date.now() < deadline) {
|
|
230
|
+
let textLength;
|
|
231
|
+
try {
|
|
232
|
+
textLength = await getBodyTextLength(page);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if (!isTransientPageEvaluationError(err)) {
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
237
|
+
lastTextLength = -1;
|
|
238
|
+
stableSince = 0;
|
|
239
|
+
await page.waitForTimeout(RENDERED_PAGE_POLL_MS);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
if (
|
|
244
|
+
textLength === lastTextLength &&
|
|
245
|
+
textLength >= RENDERED_PAGE_MIN_TEXT_LENGTH
|
|
246
|
+
) {
|
|
247
|
+
if (!stableSince) {
|
|
248
|
+
stableSince = now;
|
|
249
|
+
}
|
|
250
|
+
if (now - stableSince >= RENDERED_PAGE_STABLE_FOR_MS) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
lastTextLength = textLength;
|
|
255
|
+
stableSince = now;
|
|
256
|
+
}
|
|
257
|
+
await page.waitForTimeout(RENDERED_PAGE_POLL_MS);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function getBodyTextLength(page) {
|
|
262
|
+
const rawPage = page.rawPage || page;
|
|
263
|
+
return await rawPage.evaluate(() => {
|
|
264
|
+
// eslint-disable-next-line no-undef
|
|
265
|
+
const text = document.body?.innerText || '';
|
|
266
|
+
return text.trim().length;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isTransientPageEvaluationError(err) {
|
|
271
|
+
return /execution context was destroyed|context was destroyed|cannot find context/i.test(
|
|
272
|
+
err.message || ''
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
110
276
|
async function sendMarkdownResponse(res, url, markdown) {
|
|
111
277
|
if (!isTextPasteUrl(url)) {
|
|
112
278
|
return res.type('text/markdown').send(markdown);
|
|
Binary file
|