@link-assistant/web-capture 1.10.4 → 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,17 @@
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
+
9
+ ## 1.10.5
10
+
11
+ ### Patch Changes
12
+
13
+ - 05132b4: Support public Google Drive image file URLs across capture formats.
14
+
3
15
  ## 1.10.4
4
16
 
5
17
  ### Patch Changes
@@ -497,6 +497,17 @@ function archiveExtension(archiveFormat) {
497
497
  return 'zip';
498
498
  }
499
499
 
500
+ function sanitizeImageExtension(extension) {
501
+ if (!extension) {
502
+ return null;
503
+ }
504
+
505
+ const normalized = extension.toLowerCase().replace(/^\./, '');
506
+ return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(normalized)
507
+ ? normalized
508
+ : null;
509
+ }
510
+
500
511
  function writeGoogleDocsTextOutput({
501
512
  content,
502
513
  absoluteUrl,
@@ -601,6 +612,10 @@ async function captureUrl(url, options) {
601
612
  // Import required modules
602
613
  const {
603
614
  fetchHtml,
615
+ fetchGoogleDriveImage,
616
+ fetchGoogleDriveImageHtml,
617
+ googleDriveImageHtmlForUrl,
618
+ googleDriveImageTextForUrl,
604
619
  convertToUtf8,
605
620
  convertRelativeUrls,
606
621
  normalizeUrlForTextContent,
@@ -926,22 +941,42 @@ async function captureUrl(url, options) {
926
941
  }
927
942
 
928
943
  try {
944
+ if (['image', 'png', 'jpeg', 'screenshot'].includes(normalizedFormat)) {
945
+ const googleDriveImage = await fetchGoogleDriveImage(absoluteUrl);
946
+ if (googleDriveImage) {
947
+ if (explicitOutput === '-') {
948
+ process.stdout.write(googleDriveImage.buffer);
949
+ } else {
950
+ const outPath = explicitOutput || googleDriveImage.filename;
951
+ fs.writeFileSync(outPath, googleDriveImage.buffer);
952
+ console.error(`Google Drive image saved to: ${outPath}`);
953
+ }
954
+ return;
955
+ }
956
+ }
957
+
929
958
  if (normalizedFormat === 'txt' || normalizedFormat === 'text') {
930
959
  let text;
931
960
  if (isGithubRepositoryUrl(absoluteUrl)) {
932
961
  const snapshot = await fetchGithubRepositorySnapshot(absoluteUrl);
933
962
  text = formatGithubRepositoryText(snapshot);
934
963
  } else {
935
- const response = await fetch(normalizeUrlForTextContent(absoluteUrl));
936
- if (!response.ok) {
937
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
938
- }
939
- const contentType =
940
- response.headers.get('content-type') || 'text/plain';
941
- if (!contentType.includes('text/')) {
942
- throw new Error(`Expected text content, got ${contentType}`);
964
+ const googleDriveImageHtml =
965
+ await fetchGoogleDriveImageHtml(absoluteUrl);
966
+ if (googleDriveImageHtml) {
967
+ text = googleDriveImageTextForUrl(absoluteUrl);
968
+ } else {
969
+ const response = await fetch(normalizeUrlForTextContent(absoluteUrl));
970
+ if (!response.ok) {
971
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
972
+ }
973
+ const contentType =
974
+ response.headers.get('content-type') || 'text/plain';
975
+ if (!contentType.includes('text/')) {
976
+ throw new Error(`Expected text content, got ${contentType}`);
977
+ }
978
+ text = await response.text();
943
979
  }
944
- text = await response.text();
945
980
  }
946
981
  const output =
947
982
  explicitOutput === '-'
@@ -992,17 +1027,30 @@ async function captureUrl(url, options) {
992
1027
  const { dismissPopups, scrollToLoadContent } =
993
1028
  await import('../src/popups.js');
994
1029
  const browserOpts = theme ? { colorScheme: theme } : {};
1030
+ const googleDriveImage = await fetchGoogleDriveImage(absoluteUrl);
1031
+ const googleDriveHtml = googleDriveImage
1032
+ ? googleDriveImageHtmlForUrl(absoluteUrl, {
1033
+ imageSrc: `data:${googleDriveImage.contentType};base64,${googleDriveImage.buffer.toString('base64')}`,
1034
+ })
1035
+ : null;
995
1036
  const browser = await createBrowser(engine, browserOpts);
996
1037
  try {
997
1038
  const page = await browser.newPage();
998
1039
  await page.setViewport({ width, height });
999
- await page.goto(absoluteUrl, {
1000
- waitUntil: 'networkidle0',
1001
- timeout: 30000,
1002
- });
1003
- await new Promise((resolve) => setTimeout(resolve, 5000));
1004
- await scrollToLoadContent(page);
1005
- await dismissPopups(page);
1040
+ if (googleDriveHtml) {
1041
+ await page.setContent(googleDriveHtml, {
1042
+ waitUntil: 'networkidle0',
1043
+ timeout: 30000,
1044
+ });
1045
+ } else {
1046
+ await page.goto(absoluteUrl, {
1047
+ waitUntil: 'networkidle0',
1048
+ timeout: 30000,
1049
+ });
1050
+ await new Promise((resolve) => setTimeout(resolve, 5000));
1051
+ await scrollToLoadContent(page);
1052
+ await dismissPopups(page);
1053
+ }
1006
1054
  const rawPage = page.rawPage || page;
1007
1055
  const pdfBuffer = await rawPage.pdf({
1008
1056
  format: 'A4',
@@ -1079,11 +1127,19 @@ async function captureUrl(url, options) {
1079
1127
  // Collect images
1080
1128
  const $ = cheerio.load(html);
1081
1129
  const images = [];
1130
+ const imageExtensionByUrl = new Map();
1082
1131
  $('img').each(function () {
1083
1132
  const src = $(this).attr('src');
1084
1133
  if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
1085
1134
  try {
1086
- images.push(new URL(src, absoluteUrl).href);
1135
+ const imgUrl = new URL(src, absoluteUrl).href;
1136
+ images.push(imgUrl);
1137
+ const hintedExtension = sanitizeImageExtension(
1138
+ $(this).attr('data-web-capture-extension')
1139
+ );
1140
+ if (hintedExtension && !imageExtensionByUrl.has(imgUrl)) {
1141
+ imageExtensionByUrl.set(imgUrl, hintedExtension);
1142
+ }
1087
1143
  } catch {
1088
1144
  /* skip */
1089
1145
  }
@@ -1100,7 +1156,10 @@ async function captureUrl(url, options) {
1100
1156
  ) {
1101
1157
  let idx = 1;
1102
1158
  for (const imgUrl of uniqueImages) {
1103
- const ext = imgUrl.match(/\.(jpe?g|gif|webp|svg|png)/i)?.[1] || 'png';
1159
+ const ext =
1160
+ imageExtensionByUrl.get(imgUrl) ||
1161
+ imgUrl.match(/\.(jpe?g|gif|webp|svg|png)/i)?.[1] ||
1162
+ 'png';
1104
1163
  imageMap.set(imgUrl, `images/image-${idx}.${ext}`);
1105
1164
  idx++;
1106
1165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.10.4",
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/archive.js CHANGED
@@ -58,12 +58,19 @@ export async function archiveHandler(req, res) {
58
58
  // Collect images from the HTML
59
59
  const $ = cheerio.load(html);
60
60
  const images = [];
61
+ const imageExtensionByUrl = new Map();
61
62
  $('img').each(function () {
62
63
  const src = $(this).attr('src');
63
64
  if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
64
65
  try {
65
66
  const imgUrl = new URL(src, absoluteUrl).href;
66
67
  images.push(imgUrl);
68
+ const hintedExtension = sanitizeImageExtension(
69
+ $(this).attr('data-web-capture-extension')
70
+ );
71
+ if (hintedExtension && !imageExtensionByUrl.has(imgUrl)) {
72
+ imageExtensionByUrl.set(imgUrl, hintedExtension);
73
+ }
67
74
  } catch {
68
75
  /* skip invalid URLs */
69
76
  }
@@ -89,7 +96,8 @@ export async function archiveHandler(req, res) {
89
96
  if (localImages && uniqueImages.length > 0) {
90
97
  let idx = 1;
91
98
  for (const imgUrl of uniqueImages) {
92
- const ext = guessImageExtension(imgUrl);
99
+ const ext =
100
+ imageExtensionByUrl.get(imgUrl) || guessImageExtension(imgUrl);
93
101
  const filename = `image-${idx}.${ext}`;
94
102
  imageMap.set(imgUrl, `images/${filename}`);
95
103
  idx++;
@@ -277,3 +285,14 @@ function guessImageExtension(url) {
277
285
  }
278
286
  return 'png';
279
287
  }
288
+
289
+ function sanitizeImageExtension(extension) {
290
+ if (!extension) {
291
+ return null;
292
+ }
293
+
294
+ const normalized = extension.toLowerCase().replace(/^\./, '');
295
+ return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(normalized)
296
+ ? normalized
297
+ : null;
298
+ }
package/src/browser.js CHANGED
@@ -23,7 +23,7 @@ const SERVER_CHROME_ARGS = [
23
23
  * Unified page interface wrapping browser-commander's makeBrowserCommander.
24
24
  *
25
25
  * Exposes:
26
- * - setExtraHTTPHeaders / setUserAgent / setViewport / goto / content / screenshot / close
26
+ * - setExtraHTTPHeaders / setUserAgent / setViewport / goto / setContent / content / screenshot / close
27
27
  * (via the underlying raw page for cross-engine compatibility)
28
28
  * - commander.pdf() – PDF generation (browser-commander v0.8.0)
29
29
  * - commander.emulateMedia() – Color scheme emulation (browser-commander v0.7.0)
@@ -37,6 +37,7 @@ const SERVER_CHROME_ARGS = [
37
37
  * @property {Function} setUserAgent - Set user agent
38
38
  * @property {Function} setViewport - Set viewport size
39
39
  * @property {Function} goto - Navigate to URL
40
+ * @property {Function} setContent - Set page HTML content
40
41
  * @property {Function} content - Get page HTML content
41
42
  * @property {Function} screenshot - Take screenshot
42
43
  * @property {Function} close - Close the page
@@ -64,7 +65,7 @@ export async function createBrowser(engine = 'puppeteer', options = {}) {
64
65
  ? 'playwright'
65
66
  : 'puppeteer';
66
67
 
67
- const { colorScheme, ...launchOpts } = options;
68
+ const { colorScheme, args = [], ...launchOpts } = options;
68
69
 
69
70
  // Generate unique userDataDir for this session to avoid conflicts
70
71
  const userDataDir = path.join(
@@ -75,7 +76,7 @@ export async function createBrowser(engine = 'puppeteer', options = {}) {
75
76
  // Use browser-commander's launchBrowser with server-specific args
76
77
  const { browser, page: initialPage } = await launchBrowser({
77
78
  engine: engineType,
78
- args: SERVER_CHROME_ARGS,
79
+ args: [...SERVER_CHROME_ARGS, ...args],
79
80
  headless: true,
80
81
  userDataDir,
81
82
  slowMo: 0,
@@ -162,6 +163,16 @@ function createPageAdapter(rawPage, commander, engineType) {
162
163
  await rawPage.goto(url, options);
163
164
  }
164
165
  },
166
+ async setContent(html, options = {}) {
167
+ const contentOptions = { ...options };
168
+ if (
169
+ engineType === 'playwright' &&
170
+ contentOptions.waitUntil === 'networkidle0'
171
+ ) {
172
+ contentOptions.waitUntil = 'networkidle';
173
+ }
174
+ await rawPage.setContent(html, contentOptions);
175
+ },
165
176
  async content() {
166
177
  return await rawPage.content();
167
178
  },
package/src/docx.js CHANGED
@@ -76,13 +76,14 @@ export async function docxHandler(req, res) {
76
76
  const imgResp = await fetch(imgUrl);
77
77
  if (imgResp.ok) {
78
78
  const buffer = await imgResp.buffer();
79
+ const contentType = imgResp.headers.get('content-type') || '';
79
80
  children.push(
80
81
  new Paragraph({
81
82
  children: [
82
83
  new ImageRun({
83
84
  data: buffer,
84
85
  transformation: { width: 600, height: 400 },
85
- type: guessImageType(imgUrl),
86
+ type: guessImageType(imgUrl, contentType),
86
87
  }),
87
88
  ],
88
89
  })
@@ -170,7 +171,24 @@ export async function docxHandler(req, res) {
170
171
  }
171
172
  }
172
173
 
173
- function guessImageType(url) {
174
+ function guessImageType(url, contentType = '') {
175
+ const normalizedContentType = contentType.toLowerCase().split(';')[0].trim();
176
+ if (normalizedContentType === 'image/jpeg') {
177
+ return 'jpg';
178
+ }
179
+ if (normalizedContentType === 'image/png') {
180
+ return 'png';
181
+ }
182
+ if (normalizedContentType === 'image/gif') {
183
+ return 'gif';
184
+ }
185
+ if (normalizedContentType === 'image/bmp') {
186
+ return 'bmp';
187
+ }
188
+ if (normalizedContentType === 'image/svg+xml') {
189
+ return 'svg';
190
+ }
191
+
174
192
  const pathname = new URL(url).pathname.toLowerCase();
175
193
  if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) {
176
194
  return 'jpg';
package/src/fetch.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fetch from 'node-fetch';
2
+ import { convertGoogleDriveUrl } from './lib.js';
2
3
 
3
4
  export async function fetchHandler(req, res) {
4
5
  const url = req.query.url;
@@ -6,7 +7,7 @@ export async function fetchHandler(req, res) {
6
7
  return res.status(400).send('Missing `url` parameter');
7
8
  }
8
9
  try {
9
- const response = await fetch(url);
10
+ const response = await fetch(convertGoogleDriveUrl(url));
10
11
  // Copy status and headers
11
12
  res.status(response.status);
12
13
 
package/src/image.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createBrowser, getBrowserEngine } from './browser.js';
2
- import { isStackOverflowQuestionUrl } from './lib.js';
2
+ import { fetchGoogleDriveImage, isStackOverflowQuestionUrl } from './lib.js';
3
3
  import { dismissPopups, scrollToLoadContent } from './popups.js';
4
4
 
5
5
  /**
@@ -39,6 +39,17 @@ export async function imageHandler(req, res) {
39
39
 
40
40
  try {
41
41
  const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
42
+ const googleDriveImage = await fetchGoogleDriveImage(absoluteUrl);
43
+ if (googleDriveImage) {
44
+ res.set('Content-Type', googleDriveImage.contentType);
45
+ res.set(
46
+ 'Content-Disposition',
47
+ `inline; filename="${googleDriveImage.filename}"`
48
+ );
49
+ res.end(googleDriveImage.buffer);
50
+ return;
51
+ }
52
+
42
53
  const engine = getBrowserEngine(req);
43
54
  const waitUntil =
44
55
  engine === 'playwright' && isStackOverflowQuestionUrl(absoluteUrl)
package/src/lib.js CHANGED
@@ -14,11 +14,17 @@ import { retry } from './retry.js';
14
14
 
15
15
  const STACKPRINTER_RETRIES = 3;
16
16
  const STACKPRINTER_RETRY_BASE_DELAY_MS = 1000;
17
+ const GOOGLE_DRIVE_FILE_ID_RE = /^[A-Za-z0-9_-]+$/;
17
18
 
18
19
  export async function fetchHtml(url) {
19
20
  if (!url) {
20
21
  throw new Error('Missing URL parameter');
21
22
  }
23
+ const googleDriveImageHtml = await fetchGoogleDriveImageHtml(url);
24
+ if (googleDriveImageHtml) {
25
+ return googleDriveImageHtml;
26
+ }
27
+
22
28
  const stackPrinterUrl = stackPrinterUrlForQuestion(url);
23
29
  if (stackPrinterUrl) {
24
30
  return await fetchStackPrinterHtml(stackPrinterUrl);
@@ -28,6 +34,163 @@ export async function fetchHtml(url) {
28
34
  return response.text();
29
35
  }
30
36
 
37
+ export function getGoogleDriveFileId(url) {
38
+ if (!url) {
39
+ return null;
40
+ }
41
+
42
+ try {
43
+ const parsed = new URL(url);
44
+ const hostname = parsed.hostname.toLowerCase();
45
+ const pathnameParts = parsed.pathname.split('/').filter(Boolean);
46
+ let fileId = null;
47
+
48
+ if (
49
+ hostname === 'drive.google.com' ||
50
+ hostname === 'www.drive.google.com'
51
+ ) {
52
+ if (pathnameParts[0] === 'file' && pathnameParts[1] === 'd') {
53
+ fileId = pathnameParts[2] || null;
54
+ } else if (pathnameParts[0] === 'open' || pathnameParts[0] === 'uc') {
55
+ fileId = parsed.searchParams.get('id');
56
+ }
57
+ } else if (
58
+ hostname === 'drive.usercontent.google.com' &&
59
+ pathnameParts[0] === 'download'
60
+ ) {
61
+ fileId = parsed.searchParams.get('id');
62
+ }
63
+
64
+ return fileId && GOOGLE_DRIVE_FILE_ID_RE.test(fileId) ? fileId : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export function isGoogleDriveFileUrl(url) {
71
+ return Boolean(getGoogleDriveFileId(url));
72
+ }
73
+
74
+ export function convertGoogleDriveUrl(url) {
75
+ const fileId = getGoogleDriveFileId(url);
76
+ if (!fileId) {
77
+ return url;
78
+ }
79
+
80
+ return `https://drive.google.com/uc?export=download&id=${fileId}`;
81
+ }
82
+
83
+ export function googleDriveImageHtmlForUrl(url, options = {}) {
84
+ const fileId = getGoogleDriveFileId(url);
85
+ if (!fileId) {
86
+ return null;
87
+ }
88
+
89
+ const imageUrl = options.imageSrc || convertGoogleDriveUrl(url);
90
+ const escapedImageUrl = he.escape(imageUrl);
91
+ const escapedSourceUrl = he.escape(url);
92
+ const extensionAttribute = options.imageExtension
93
+ ? ` data-web-capture-extension="${he.escape(options.imageExtension)}"`
94
+ : '';
95
+
96
+ return `<!DOCTYPE html>
97
+ <html>
98
+ <head>
99
+ <meta charset="utf-8">
100
+ <title>Google Drive image</title>
101
+ </head>
102
+ <body>
103
+ <h1>Google Drive image</h1>
104
+ <p><a href="${escapedSourceUrl}">Original Google Drive file</a></p>
105
+ <img src="${escapedImageUrl}" alt="Google Drive image"${extensionAttribute}>
106
+ </body>
107
+ </html>`;
108
+ }
109
+
110
+ export function googleDriveImageTextForUrl(url) {
111
+ if (!isGoogleDriveFileUrl(url)) {
112
+ return null;
113
+ }
114
+
115
+ return [
116
+ 'Google Drive image',
117
+ '',
118
+ `Original Google Drive file: ${url}`,
119
+ `Image: ${convertGoogleDriveUrl(url)}`,
120
+ '',
121
+ ].join('\n');
122
+ }
123
+
124
+ export async function fetchGoogleDriveImage(url) {
125
+ const fileId = getGoogleDriveFileId(url);
126
+ if (!fileId) {
127
+ return null;
128
+ }
129
+
130
+ const downloadUrl = convertGoogleDriveUrl(url);
131
+ const response = await fetch(downloadUrl);
132
+ if (!response.ok) {
133
+ throw new Error(`Google Drive returned HTTP ${response.status}`);
134
+ }
135
+
136
+ const contentType = response.headers.get('content-type') || '';
137
+ if (!isImageContentType(contentType)) {
138
+ throw new Error(`Google Drive file is not an image (${contentType})`);
139
+ }
140
+
141
+ return {
142
+ buffer: await response.buffer(),
143
+ contentType,
144
+ downloadUrl,
145
+ filename: `google-drive-${fileId}.${imageExtensionForContentType(contentType)}`,
146
+ };
147
+ }
148
+
149
+ export async function fetchGoogleDriveImageHtml(url) {
150
+ if (!isGoogleDriveFileUrl(url)) {
151
+ return null;
152
+ }
153
+
154
+ const downloadUrl = convertGoogleDriveUrl(url);
155
+ let imageExtension = null;
156
+ try {
157
+ const response = await fetch(downloadUrl, { method: 'HEAD' });
158
+ if (response.ok) {
159
+ const contentType = response.headers.get('content-type');
160
+ if (!isImageContentType(contentType)) {
161
+ return null;
162
+ }
163
+ imageExtension = imageExtensionForContentType(contentType);
164
+ }
165
+ } catch {
166
+ // Some public Drive responses reject HEAD even though GET works. In that
167
+ // case still render the direct image document and let consumers fetch it.
168
+ }
169
+
170
+ return googleDriveImageHtmlForUrl(url, { imageExtension });
171
+ }
172
+
173
+ function isImageContentType(contentType) {
174
+ return (contentType || '').toLowerCase().startsWith('image/');
175
+ }
176
+
177
+ function imageExtensionForContentType(contentType) {
178
+ const normalized = (contentType || '').toLowerCase().split(';')[0].trim();
179
+ if (normalized === 'image/jpeg' || normalized === 'image/jpg') {
180
+ return 'jpg';
181
+ }
182
+ if (normalized === 'image/gif') {
183
+ return 'gif';
184
+ }
185
+ if (normalized === 'image/webp') {
186
+ return 'webp';
187
+ }
188
+ if (normalized === 'image/svg+xml') {
189
+ return 'svg';
190
+ }
191
+ return 'png';
192
+ }
193
+
31
194
  async function fetchStackPrinterHtml(url) {
32
195
  return await retry(
33
196
  async () => {