@link-assistant/web-capture 1.10.0 → 1.10.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.10.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Add xpaste.pro raw text capture and large-paste markdown archive handling.
8
+
9
+ ## 1.10.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 31b93a8: Add a live integration test that downloads the Wikipedia page
14
+ (https://en.wikipedia.org/wiki/Wikipedia) as both Markdown and an image (PNG
15
+ screenshot) using every supported browser engine — Puppeteer and Playwright
16
+ (issue #8). The suite is gated behind `WIKIPEDIA_INTEGRATION=true` so default
17
+ offline runs stay deterministic, and is wired into CI as a dedicated live step.
18
+
3
19
  ## 1.10.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -10,6 +10,7 @@ A CLI and microservice to fetch URLs and render them as:
10
10
 
11
11
  - **Markdown**: Converted from HTML with image extraction (default)
12
12
  - **HTML**: Rendered page content
13
+ - **Plain text**: Raw text downloads for paste-like URLs such as xpaste.pro
13
14
  - **PNG/JPEG screenshot**: Viewport or full-page capture with theme support
14
15
  - **ZIP archive**: Markdown + locally downloaded images
15
16
  - **PDF**: Print-quality document with embedded images
@@ -48,6 +49,9 @@ web-capture https://example.com -o -
48
49
  # Capture as HTML
49
50
  web-capture https://example.com --format html -o page.html
50
51
 
52
+ # Capture raw paste text
53
+ web-capture https://xpaste.pro/p/t4q0Lsp0 --format txt -o paste.txt
54
+
51
55
  # Take a PNG screenshot
52
56
  web-capture https://example.com --format png -o screenshot.png
53
57
 
@@ -141,6 +145,14 @@ GET /markdown?url=<URL>
141
145
 
142
146
  Converts the HTML content of the specified URL to Markdown format. By default, original remote image URLs are preserved and base64 data URIs are stripped (clean single-file output). Use `keepOriginalLinks=false` to strip all images, or `embedImages=true` to keep base64 images inline.
143
147
 
148
+ For xpaste.pro paste URLs, `/markdown` fetches the visual paste page, keeps
149
+ visible header, language, metadata, paste body, and footer text in visual order,
150
+ and appends the raw paste text as a fenced block named `xpaste-pro-<id>.txt`
151
+ when the final Markdown stays under 1500 lines. Larger paste pages return a ZIP
152
+ containing `index.md`, `xpaste-pro-<id>.md`, and `xpaste-pro-<id>.txt`.
153
+ Canonical `/p/<id>`, `/p/<id>/raw`, `/ru/p/<id>`, and `/en/p/<id>` URLs are
154
+ normalized before capture.
155
+
144
156
  | Parameter | Required | Description | Default |
145
157
  | ------------------- | -------- | ------------------------------------------------------------------------- | -------- |
146
158
  | `url` | Yes | URL to fetch | - |
@@ -149,6 +161,20 @@ Converts the HTML content of the specified URL to Markdown format. By default, o
149
161
  | `embedImages` | No | Keep base64 images inline (`true`/`false`) | `false` |
150
162
  | `keepOriginalLinks` | No | Keep original remote URLs, strip base64 | `true` |
151
163
 
164
+ ### Text Endpoint
165
+
166
+ ```
167
+ GET /txt?url=<URL>
168
+ ```
169
+
170
+ Returns raw text content as a `.txt` attachment. xpaste.pro paste URLs are
171
+ normalized to their `/raw` endpoint, including localized `/ru/p/<id>` and
172
+ `/en/p/<id>` URLs.
173
+
174
+ | Parameter | Required | Description | Default |
175
+ | --------- | -------- | ------------ | ------- |
176
+ | `url` | Yes | URL to fetch | - |
177
+
152
178
  ### Image Endpoint
153
179
 
154
180
  ```
@@ -334,6 +360,7 @@ web-capture <url> [options]
334
360
  | ----------------- | ---------------------------------------- |
335
361
  | `markdown` / `md` | Markdown conversion (default) |
336
362
  | `html` | Rendered HTML |
363
+ | `txt` / `text` | Raw text download |
337
364
  | `image` / `png` | PNG screenshot (lossless) |
338
365
  | `jpeg` | JPEG screenshot (configurable quality) |
339
366
  | `pdf` | PDF with embedded images |
@@ -456,6 +483,18 @@ npm run test:e2e # Run end-to-end tests
456
483
  npm run test:all # Run all tests including build
457
484
  ```
458
485
 
486
+ Some integration suites hit live servers and are skipped by default. Enable them
487
+ with environment variables:
488
+
489
+ ```bash
490
+ # Download the Wikipedia page (markdown + image) in every supported engine
491
+ WIKIPEDIA_INTEGRATION=true npm test -- --testPathPattern="wikipedia-download"
492
+
493
+ # Habr articles and public Google Docs live suites
494
+ HABR_INTEGRATION=true npm test -- --testPathPattern="habr-article"
495
+ GDOCS_INTEGRATION=true npm test -- --testPathPattern="gdocs-public-doc"
496
+ ```
497
+
459
498
  ## Built With
460
499
 
461
500
  - Express.js for the web server
@@ -78,7 +78,7 @@ const config = makeConfig({
78
78
  alias: 'f',
79
79
  type: 'string',
80
80
  description:
81
- 'Output format: markdown, md, html, image, png, jpeg, pdf, docx, archive',
81
+ 'Output format: markdown, md, html, txt, text, image, png, jpeg, pdf, docx, archive',
82
82
  default: 'markdown',
83
83
  })
84
84
  .option('theme', {
@@ -289,7 +289,7 @@ const config = makeConfig({
289
289
  'Capture private Google Doc with API token'
290
290
  )
291
291
  .epilogue(
292
- 'API Endpoints (in server mode):\n GET /html?url=<URL>&engine=<ENGINE> Get rendered HTML\n GET /markdown?url=<URL> Get Markdown conversion\n GET /image?url=<URL>&format=png|jpeg&theme=light|dark Screenshot\n GET /archive?url=<URL>&localImages=true&documentFormat=markdown|html ZIP archive\n GET /pdf?url=<URL>&theme=light|dark PDF with embedded images\n GET /docx?url=<URL> DOCX with embedded images\n GET /fetch?url=<URL> Proxy fetch\n GET /stream?url=<URL> Streaming proxy'
292
+ 'API Endpoints (in server mode):\n GET /html?url=<URL>&engine=<ENGINE> Get rendered HTML\n GET /markdown?url=<URL> Get Markdown conversion\n GET /txt?url=<URL> Get text content\n GET /image?url=<URL>&format=png|jpeg&theme=light|dark Screenshot\n GET /archive?url=<URL>&localImages=true&documentFormat=markdown|html ZIP archive\n GET /pdf?url=<URL>&theme=light|dark PDF with embedded images\n GET /docx?url=<URL> DOCX with embedded images\n GET /fetch?url=<URL> Proxy fetch\n GET /stream?url=<URL> Streaming proxy'
293
293
  )
294
294
  .strict(),
295
295
  lenv: {
@@ -322,6 +322,7 @@ async function startServer(port) {
322
322
  console.log('Available endpoints:');
323
323
  console.log(` GET /html?url=<URL> - Render page as HTML`);
324
324
  console.log(` GET /markdown?url=<URL> - Convert page to Markdown`);
325
+ console.log(` GET /txt?url=<URL> - Fetch text content`);
325
326
  console.log(` GET /image?url=<URL> - Screenshot (PNG/JPEG)`);
326
327
  console.log(
327
328
  ` GET /archive?url=<URL> - ZIP archive with markdown + images`
@@ -598,8 +599,12 @@ async function captureUrl(url, options) {
598
599
  }
599
600
 
600
601
  // Import required modules
601
- const { fetchHtml, convertToUtf8, convertRelativeUrls } =
602
- await import('../src/lib.js');
602
+ const {
603
+ fetchHtml,
604
+ convertToUtf8,
605
+ convertRelativeUrls,
606
+ normalizeUrlForTextContent,
607
+ } = await import('../src/lib.js');
603
608
  const { createBrowser } = await import('../src/browser.js');
604
609
  const {
605
610
  isGoogleDocsUrl,
@@ -915,7 +920,28 @@ async function captureUrl(url, options) {
915
920
  }
916
921
 
917
922
  try {
918
- if (normalizedFormat === 'jpeg') {
923
+ if (normalizedFormat === 'txt' || normalizedFormat === 'text') {
924
+ const response = await fetch(normalizeUrlForTextContent(absoluteUrl));
925
+ if (!response.ok) {
926
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
927
+ }
928
+ const contentType = response.headers.get('content-type') || 'text/plain';
929
+ if (!contentType.includes('text/')) {
930
+ throw new Error(`Expected text content, got ${contentType}`);
931
+ }
932
+ const text = await response.text();
933
+ const output =
934
+ explicitOutput === '-'
935
+ ? null
936
+ : explicitOutput || deriveOutputPath(absoluteUrl, 'txt', dataDir);
937
+ if (output) {
938
+ fs.mkdirSync(path.dirname(output), { recursive: true });
939
+ fs.writeFileSync(output, text, 'utf-8');
940
+ console.error(`Text saved to: ${output}`);
941
+ } else {
942
+ process.stdout.write(text);
943
+ }
944
+ } else if (normalizedFormat === 'jpeg') {
919
945
  // JPEG screenshot
920
946
  const { createBrowser } = await import('../src/browser.js');
921
947
  const { dismissPopups, scrollToLoadContent } =
@@ -1160,6 +1186,8 @@ async function captureUrl(url, options) {
1160
1186
  normalizedFormat === 'screenshot'
1161
1187
  ) {
1162
1188
  // Image/screenshot format
1189
+ const { dismissPopups, scrollToLoadContent } =
1190
+ await import('../src/popups.js');
1163
1191
  const browser = await createBrowser(engine);
1164
1192
  try {
1165
1193
  const page = await browser.newPage();
@@ -1177,8 +1205,12 @@ async function captureUrl(url, options) {
1177
1205
  });
1178
1206
  // Wait for 5 seconds after page load
1179
1207
  await new Promise((resolve) => setTimeout(resolve, 5000));
1208
+ if (fullPage) {
1209
+ await scrollToLoadContent(page);
1210
+ }
1211
+ await dismissPopups(page);
1180
1212
 
1181
- const buffer = await page.screenshot({ type: 'png' });
1213
+ const buffer = await page.screenshot({ type: 'png', fullPage });
1182
1214
 
1183
1215
  if (explicitOutput) {
1184
1216
  fs.writeFileSync(explicitOutput, buffer);
package/jest.config.mjs CHANGED
@@ -13,6 +13,7 @@ export default {
13
13
  '**/tests/mock/**/*.test.js',
14
14
  '**/tests/e2e/**/*.test.js',
15
15
  '**/tests/integration/**/*.test.js',
16
+ '**/tests/xpaste/**/*.test.js',
16
17
  ],
17
18
  setupFiles: ['./tests/jest.setup.mjs'],
18
19
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
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/index.js CHANGED
@@ -5,6 +5,7 @@ import { markdownHandler } from './markdown.js';
5
5
  import { imageHandler } from './image.js';
6
6
  import { streamHandler } from './stream.js';
7
7
  import { fetchHandler } from './fetch.js';
8
+ import { txtHandler } from './txt.js';
8
9
  import { archiveHandler } from './archive.js';
9
10
  import { pdfHandler } from './pdf.js';
10
11
  import { docxHandler } from './docx.js';
@@ -22,6 +23,7 @@ app.get('/markdown', markdownHandler);
22
23
  app.get('/image', imageHandler);
23
24
  app.get('/stream', streamHandler);
24
25
  app.get('/fetch', fetchHandler);
26
+ app.get('/txt', txtHandler);
25
27
  app.get('/archive', archiveHandler);
26
28
  app.get('/pdf', pdfHandler);
27
29
  app.get('/docx', docxHandler);
package/src/lib.js CHANGED
@@ -19,6 +19,95 @@ export async function fetchHtml(url) {
19
19
  return response.text();
20
20
  }
21
21
 
22
+ export function isTextPasteUrl(url) {
23
+ const parsed = parseTextPasteUrl(url);
24
+ return Boolean(parsed);
25
+ }
26
+
27
+ export function normalizeUrlForTextContent(url) {
28
+ const parsed = parseTextPasteUrl(url);
29
+ if (!parsed) {
30
+ return url;
31
+ }
32
+ return `https://${parsed.host}${parsed.pathPrefix}/p/${parsed.pasteId}/raw`;
33
+ }
34
+
35
+ export function normalizeUrlForTextPage(url) {
36
+ const parsed = parseTextPasteUrl(url);
37
+ if (!parsed) {
38
+ return url;
39
+ }
40
+ return `https://${parsed.host}${parsed.pathPrefix}/p/${parsed.pasteId}`;
41
+ }
42
+
43
+ export function getTextPasteId(url) {
44
+ return parseTextPasteUrl(url)?.pasteId || null;
45
+ }
46
+
47
+ export function getTextPasteFilename(url) {
48
+ const pasteId = getTextPasteId(url);
49
+ return pasteId ? `xpaste-pro-${pasteId}.txt` : null;
50
+ }
51
+
52
+ export function appendTextPasteMarkdownAttachment(markdown, url, rawText) {
53
+ const textFilename = getTextPasteFilename(url) || 'download.txt';
54
+ const normalizedRawText = normalizeMarkdownAttachmentText(rawText);
55
+ const fence = markdownFenceFor(normalizedRawText);
56
+ const attachmentText = normalizedRawText.endsWith('\n')
57
+ ? normalizedRawText
58
+ : `${normalizedRawText}\n`;
59
+
60
+ return [
61
+ markdown.trimEnd(),
62
+ '',
63
+ `## ${textFilename}`,
64
+ '',
65
+ `${fence}text`,
66
+ `${attachmentText}${fence}`,
67
+ '',
68
+ ].join('\n');
69
+ }
70
+
71
+ function normalizeMarkdownAttachmentText(text) {
72
+ return String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
73
+ }
74
+
75
+ function markdownFenceFor(text) {
76
+ const runs = text.match(/`{3,}/g) || [];
77
+ const length = runs.reduce((max, run) => Math.max(max, run.length + 1), 3);
78
+ return '`'.repeat(length);
79
+ }
80
+
81
+ function parseTextPasteUrl(url) {
82
+ try {
83
+ const parsed = new URL(url);
84
+ const host = parsed.hostname.toLowerCase();
85
+ if (host !== 'xpaste.pro' && host !== 'www.xpaste.pro') {
86
+ return null;
87
+ }
88
+
89
+ const parts = parsed.pathname.split('/').filter(Boolean);
90
+ const index = parts.indexOf('p');
91
+ let pathPrefix = '';
92
+ if (index === -1 || !parts[index + 1]) {
93
+ return null;
94
+ }
95
+ if (index === 1 && ['en', 'ru'].includes(parts[0])) {
96
+ pathPrefix = `/${parts[0]}`;
97
+ } else if (index !== 0) {
98
+ return null;
99
+ }
100
+ const tail = parts.slice(index + 2);
101
+ if (tail.length > 1 || (tail[0] && tail[0] !== 'raw')) {
102
+ return null;
103
+ }
104
+
105
+ return { host: 'xpaste.pro', pathPrefix, pasteId: parts[index + 1] };
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
22
111
  export function convertHtmlToMarkdown(html, baseUrl) {
23
112
  // Ensure all URLs are absolute before Markdown conversion
24
113
  if (baseUrl) {
@@ -27,6 +116,8 @@ export function convertHtmlToMarkdown(html, baseUrl) {
27
116
  // Load HTML into Cheerio
28
117
  const $ = cheerio.load(html);
29
118
 
119
+ reorderVisualLayoutElements($);
120
+
30
121
  // Remove <style>, <script>, and <noscript> tags
31
122
  $('style, script, noscript').remove();
32
123
 
@@ -199,6 +290,20 @@ function coalesceBrRunsToParagraphBreak(markdown) {
199
290
  return markdown.replace(/(?: {2,}\n){2,}/g, '\n\n');
200
291
  }
201
292
 
293
+ function reorderVisualLayoutElements($) {
294
+ const $header = $('header').first();
295
+ const $main = $('main').first();
296
+ const $footer = $('footer').first();
297
+
298
+ if ($header.length && $main.length && $header.index() > $main.index()) {
299
+ $main.before($header);
300
+ }
301
+
302
+ if ($footer.length && $main.length && $footer.index() < $main.index()) {
303
+ $main.after($footer);
304
+ }
305
+ }
306
+
202
307
  // Lift <br> nodes out of inline parents when they sit at the leading or
203
308
  // trailing edge.
204
309
  //
@@ -598,6 +703,8 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
598
703
  html = scopeHtmlForMarkdown(html, { contentSelector, bodySelector });
599
704
  $ = cheerio.load(html);
600
705
 
706
+ reorderVisualLayoutElements($);
707
+
601
708
  // Remove unwanted elements
602
709
  $('style, script, noscript').remove();
603
710
  $('*').each(function () {
package/src/markdown.js CHANGED
@@ -1,10 +1,19 @@
1
1
  import {
2
+ appendTextPasteMarkdownAttachment,
2
3
  fetchHtml,
3
4
  convertHtmlToMarkdownEnhanced,
5
+ getTextPasteId,
6
+ isTextPasteUrl,
7
+ normalizeUrlForTextContent,
8
+ normalizeUrlForTextPage,
4
9
  scopeHtmlForMarkdown,
5
10
  } from './lib.js';
6
11
  import { convertWithKreuzberg, isKreuzbergAvailable } from './kreuzberg.js';
7
12
  import { applyImageMode } from './extract-images.js';
13
+ import archiver from 'archiver';
14
+ import fetch from 'node-fetch';
15
+
16
+ const INLINE_MARKDOWN_LINE_LIMIT = 1500;
8
17
 
9
18
  export async function markdownHandler(req, res) {
10
19
  const url = req.query.url;
@@ -29,7 +38,8 @@ export async function markdownHandler(req, res) {
29
38
  }
30
39
 
31
40
  try {
32
- const html = await fetchHtml(url);
41
+ const pageUrl = normalizeUrlForTextPage(url);
42
+ const html = await fetchHtml(pageUrl);
33
43
 
34
44
  if (converter === 'kreuzberg') {
35
45
  const available = await isKreuzbergAvailable();
@@ -45,7 +55,9 @@ export async function markdownHandler(req, res) {
45
55
  contentSelector: req.query.contentSelector,
46
56
  bodySelector: req.query.bodySelector,
47
57
  });
48
- const result = await convertWithKreuzberg(scopedHtml, { baseUrl: url });
58
+ const result = await convertWithKreuzberg(scopedHtml, {
59
+ baseUrl: pageUrl,
60
+ });
49
61
  const imageResult = await applyImageMode(result.content, {
50
62
  mode: embedImages ? 'embed' : 'default',
51
63
  });
@@ -54,10 +66,10 @@ export async function markdownHandler(req, res) {
54
66
  if (format === 'json') {
55
67
  return res.json(result);
56
68
  }
57
- return res.type('text/markdown').send(result.content);
69
+ return await sendMarkdownResponse(res, url, result.content);
58
70
  }
59
71
 
60
- const { markdown } = convertHtmlToMarkdownEnhanced(html, url, {
72
+ let { markdown } = convertHtmlToMarkdownEnhanced(html, pageUrl, {
61
73
  contentSelector: req.query.contentSelector,
62
74
  bodySelector: req.query.bodySelector,
63
75
  });
@@ -67,9 +79,64 @@ export async function markdownHandler(req, res) {
67
79
  const result = await applyImageMode(markdown, {
68
80
  mode: embedImages ? 'embed' : 'default',
69
81
  });
70
- res.type('text/markdown').send(result.markdown);
82
+ markdown = result.markdown;
83
+ return await sendMarkdownResponse(res, url, markdown);
71
84
  } catch (err) {
72
85
  console.error(err);
73
86
  res.status(500).send('Error converting to Markdown');
74
87
  }
75
88
  }
89
+
90
+ async function sendMarkdownResponse(res, url, markdown) {
91
+ if (!isTextPasteUrl(url)) {
92
+ return res.type('text/markdown').send(markdown);
93
+ }
94
+
95
+ const rawText = await fetchRawText(url);
96
+ const markdownWithText = appendTextPasteMarkdownAttachment(
97
+ markdown,
98
+ url,
99
+ rawText
100
+ );
101
+ if (markdownWithText.split('\n').length >= INLINE_MARKDOWN_LINE_LIMIT) {
102
+ return await sendTextPasteMarkdownArchive(res, url, markdown, rawText);
103
+ }
104
+
105
+ return res.type('text/markdown').send(markdownWithText);
106
+ }
107
+
108
+ async function sendTextPasteMarkdownArchive(res, url, markdown, rawText) {
109
+ const pasteId = getTextPasteId(url) || 'paste';
110
+ const markdownFilename = `xpaste-pro-${pasteId}.md`;
111
+ const textFilename = `xpaste-pro-${pasteId}.txt`;
112
+ const lineCount = markdown.split('\n').length;
113
+
114
+ res.setHeader('Content-Type', 'application/zip');
115
+ res.setHeader('Content-Disposition', `attachment; filename="${pasteId}.zip"`);
116
+
117
+ const archive = archiver('zip', { zlib: { level: 9 } });
118
+ archive.pipe(res);
119
+ archive.append(
120
+ [
121
+ `# ${url}`,
122
+ '',
123
+ `Content from: ${url}`,
124
+ '',
125
+ `The page markdown is available in [${markdownFilename}](${markdownFilename}) (${lineCount} lines).`,
126
+ `The raw text content is available in [${textFilename}](${textFilename}).`,
127
+ '',
128
+ ].join('\n'),
129
+ { name: 'index.md' }
130
+ );
131
+ archive.append(markdown, { name: markdownFilename });
132
+ archive.append(rawText, { name: textFilename });
133
+ await archive.finalize();
134
+ }
135
+
136
+ async function fetchRawText(url) {
137
+ const response = await fetch(normalizeUrlForTextContent(url));
138
+ if (!response.ok) {
139
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
140
+ }
141
+ return response.text();
142
+ }
package/src/txt.js ADDED
@@ -0,0 +1,60 @@
1
+ import fetch from 'node-fetch';
2
+ import { URL } from 'node:url';
3
+ import { getTextPasteFilename, normalizeUrlForTextContent } from './lib.js';
4
+
5
+ export async function txtHandler(req, res) {
6
+ const url = req.query.url;
7
+ if (!url) {
8
+ return res.status(400).send('Missing `url` parameter');
9
+ }
10
+
11
+ try {
12
+ // Normalize URL to get text content (e.g., xpaste.pro -> xpaste.pro/raw)
13
+ const textUrl = normalizeUrlForTextContent(url);
14
+
15
+ const response = await fetch(textUrl);
16
+
17
+ if (!response.ok) {
18
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
19
+ }
20
+
21
+ const contentType = response.headers.get('content-type') || 'text/plain';
22
+
23
+ // Only accept text content types
24
+ if (!contentType.includes('text/')) {
25
+ throw new Error(`Expected text content, got ${contentType}`);
26
+ }
27
+
28
+ const text = await response.text();
29
+
30
+ // Set appropriate headers for text file download
31
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
32
+ res.setHeader(
33
+ 'Content-Disposition',
34
+ `attachment; filename="${getFilenameFromUrl(url)}"`
35
+ );
36
+ res.send(text);
37
+ } catch (err) {
38
+ console.error('Text fetch error:', err);
39
+ res.status(500).send('Error fetching text content');
40
+ }
41
+ }
42
+
43
+ function getFilenameFromUrl(url) {
44
+ const textPasteFilename = getTextPasteFilename(url);
45
+ if (textPasteFilename) {
46
+ return textPasteFilename;
47
+ }
48
+
49
+ try {
50
+ const urlObj = new URL(url);
51
+ const hostname = urlObj.hostname.replace(/\./g, '-');
52
+ const path = urlObj.pathname
53
+ .replace(/\//g, '-')
54
+ .replace(/^-/, '')
55
+ .replace(/-raw$/, '');
56
+ return `${hostname}${path}.txt`;
57
+ } catch {
58
+ return 'download.txt';
59
+ }
60
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Integration tests for downloading the Wikipedia page (issue #8).
3
+ *
4
+ * Verifies that web-capture can actually download the Wikipedia article about
5
+ * Wikipedia itself (https://en.wikipedia.org/wiki/Wikipedia) as both Markdown
6
+ * and an image (PNG screenshot) using every supported browser engine
7
+ * (Puppeteer and Playwright).
8
+ *
9
+ * These tests hit the live Wikipedia servers. Set WIKIPEDIA_INTEGRATION=true to
10
+ * run them. In CI they are enabled via the workflow environment variable. When
11
+ * the variable is unset the suite is skipped so default/offline runs stay
12
+ * deterministic.
13
+ *
14
+ * Navigation uses retry with exponential backoff to absorb transient network
15
+ * failures, mirroring the Habr integration suite.
16
+ */
17
+
18
+ import { jest } from '@jest/globals';
19
+ import { createBrowser } from '../../src/browser.js';
20
+ import { convertHtmlToMarkdown } from '../../src/lib.js';
21
+ import { retry } from '../../src/retry.js';
22
+
23
+ const WIKIPEDIA_URL = 'https://en.wikipedia.org/wiki/Wikipedia';
24
+
25
+ // PNG magic number (89 50 4E 47 0D 0A 1A 0A).
26
+ const PNG_SIGNATURE = Buffer.from([
27
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
28
+ ]);
29
+
30
+ const SKIP_LIVE =
31
+ !process.env.WIKIPEDIA_INTEGRATION ||
32
+ process.env.WIKIPEDIA_INTEGRATION === 'false';
33
+ const describeIfLive = SKIP_LIVE ? describe.skip : describe;
34
+
35
+ // These tests hit live servers and may take a while.
36
+ jest.setTimeout(120000);
37
+
38
+ // Navigate with retry + exponential backoff, then give the page a moment to
39
+ // settle so late-loading content and images are present before we capture.
40
+ async function navigateWithRetry(page, url) {
41
+ await retry(
42
+ async () => {
43
+ await page.goto(url, {
44
+ waitUntil: 'networkidle0',
45
+ timeout: 60000,
46
+ });
47
+ },
48
+ {
49
+ retries: 3,
50
+ baseDelay: 2000,
51
+ onRetry: (err, attempt, delay) => {
52
+ console.log(
53
+ `Navigation retry ${attempt} for ${url} after ${delay}ms: ${err.message}`
54
+ );
55
+ },
56
+ }
57
+ );
58
+ await new Promise((resolve) => setTimeout(resolve, 5000));
59
+ }
60
+
61
+ // Run the full matrix (Markdown + image) once per supported engine.
62
+ describe.each(['puppeteer', 'playwright'])(
63
+ 'Wikipedia Page Download (%s engine)',
64
+ (engine) => {
65
+ describeIfLive(`${engine}`, () => {
66
+ let browser;
67
+ let page;
68
+
69
+ beforeAll(async () => {
70
+ browser = await createBrowser(engine);
71
+ page = await browser.newPage();
72
+ await page.setViewport({ width: 1280, height: 800 });
73
+ await navigateWithRetry(page, WIKIPEDIA_URL);
74
+ });
75
+
76
+ afterAll(async () => {
77
+ if (browser) {
78
+ await browser.close();
79
+ }
80
+ });
81
+
82
+ it('downloads the Wikipedia page as Markdown', async () => {
83
+ const html = await page.content();
84
+ expect(html).toMatch(/<html/i);
85
+ expect(html.length).toBeGreaterThan(1000);
86
+
87
+ const markdown = convertHtmlToMarkdown(html, WIKIPEDIA_URL);
88
+
89
+ // Markdown should carry the page's real content...
90
+ expect(markdown).toContain('Wikipedia');
91
+ expect(markdown.length).toBeGreaterThan(500);
92
+ // ...and should contain markdown structure (headings and/or links)...
93
+ expect(markdown).toMatch(/(^#{1,6}\s)|(\[.*?\]\(.*?\))/m);
94
+ // ...but no longer be raw HTML document scaffolding.
95
+ expect(markdown).not.toMatch(/<html/i);
96
+ expect(markdown).not.toMatch(/<head[\s>]/i);
97
+ });
98
+
99
+ it('downloads the Wikipedia page as an image (PNG screenshot)', async () => {
100
+ const screenshot = await page.screenshot({ type: 'png' });
101
+
102
+ expect(screenshot).toBeInstanceOf(Buffer);
103
+ expect(screenshot.length).toBeGreaterThan(1000);
104
+ // Verify the PNG signature so we know it is a real image.
105
+ expect(screenshot.slice(0, 8)).toEqual(PNG_SIGNATURE);
106
+ });
107
+ });
108
+ }
109
+ );
110
+
111
+ describeIfLive('Wikipedia Page Download (engine parity)', () => {
112
+ it('both engines download Markdown and an image for the Wikipedia page', async () => {
113
+ const engines = ['puppeteer', 'playwright'];
114
+ const results = [];
115
+
116
+ for (const engine of engines) {
117
+ const browser = await createBrowser(engine);
118
+ try {
119
+ const page = await browser.newPage();
120
+ await page.setViewport({ width: 1280, height: 800 });
121
+ await navigateWithRetry(page, WIKIPEDIA_URL);
122
+
123
+ const html = await page.content();
124
+ const markdown = convertHtmlToMarkdown(html, WIKIPEDIA_URL);
125
+ const screenshot = await page.screenshot({ type: 'png' });
126
+
127
+ results.push({ engine, markdown, screenshot });
128
+ } finally {
129
+ await browser.close();
130
+ }
131
+ }
132
+
133
+ for (const { markdown, screenshot } of results) {
134
+ // Markdown captured from both engines.
135
+ expect(markdown).toContain('Wikipedia');
136
+ expect(markdown.length).toBeGreaterThan(500);
137
+ // Image captured from both engines.
138
+ expect(screenshot.slice(0, 8)).toEqual(PNG_SIGNATURE);
139
+ expect(screenshot.length).toBeGreaterThan(1000);
140
+ }
141
+ }, 180000);
142
+ });