@link-assistant/web-capture 1.10.5 → 1.10.6

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 CHANGED
@@ -5,3 +5,4 @@ dist
5
5
  package-lock.json
6
6
  .eslintcache
7
7
  CLAUDE.md
8
+ tests/fixtures/deepwiki/*.md
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.10.6
4
+
5
+ ### Patch Changes
6
+
7
+ - Render JavaScript-heavy pages before Markdown conversion so DeepWiki search pages capture their full content.
8
+
3
9
  ## 1.10.5
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.10.5",
3
+ "version": "1.10.6",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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, url, markdown);
68
+ return await sendMarkdownResponse(res, pageUrl, markdown);
60
69
  }
61
70
 
62
- const html = await fetchHtml(pageUrl);
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, url, result.content);
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, url, markdown);
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);