@link-assistant/web-capture 1.10.1 → 1.10.3

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,17 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.10.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Add compact GitHub repository capture for txt and markdown output, including repository metadata, the root file tree, and README content.
8
+
9
+ ## 1.10.2
10
+
11
+ ### Patch Changes
12
+
13
+ - Add xpaste.pro raw text capture and large-paste markdown archive handling.
14
+
3
15
  ## 1.10.1
4
16
 
5
17
  ### Patch 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,13 @@ 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
+
55
+ # Capture a GitHub repository as compact text or Markdown
56
+ web-capture https://github.com/link-assistant/web-capture --format txt -o repository.txt
57
+ web-capture https://github.com/link-assistant/web-capture --format markdown -o repository.md
58
+
51
59
  # Take a PNG screenshot
52
60
  web-capture https://example.com --format png -o screenshot.png
53
61
 
@@ -141,6 +149,19 @@ GET /markdown?url=<URL>
141
149
 
142
150
  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
151
 
152
+ For xpaste.pro paste URLs, `/markdown` fetches the visual paste page, keeps
153
+ visible header, language, metadata, paste body, and footer text in visual order,
154
+ and appends the raw paste text as a fenced block named `xpaste-pro-<id>.txt`
155
+ when the final Markdown stays under 1500 lines. Larger paste pages return a ZIP
156
+ containing `index.md`, `xpaste-pro-<id>.md`, and `xpaste-pro-<id>.txt`.
157
+ Canonical `/p/<id>`, `/p/<id>/raw`, `/ru/p/<id>`, and `/en/p/<id>` URLs are
158
+ normalized before capture.
159
+
160
+ For plain GitHub repository URLs such as `https://github.com/owner/repo`,
161
+ `/markdown` returns a compact repository snapshot with repository metadata, the
162
+ root file tree, and README content. GitHub subpages continue through the regular
163
+ HTML-to-Markdown conversion path.
164
+
144
165
  | Parameter | Required | Description | Default |
145
166
  | ------------------- | -------- | ------------------------------------------------------------------------- | -------- |
146
167
  | `url` | Yes | URL to fetch | - |
@@ -149,6 +170,23 @@ Converts the HTML content of the specified URL to Markdown format. By default, o
149
170
  | `embedImages` | No | Keep base64 images inline (`true`/`false`) | `false` |
150
171
  | `keepOriginalLinks` | No | Keep original remote URLs, strip base64 | `true` |
151
172
 
173
+ ### Text Endpoint
174
+
175
+ ```
176
+ GET /txt?url=<URL>
177
+ ```
178
+
179
+ Returns raw text content as a `.txt` attachment. xpaste.pro paste URLs are
180
+ normalized to their `/raw` endpoint, including localized `/ru/p/<id>` and
181
+ `/en/p/<id>` URLs.
182
+
183
+ Plain GitHub repository URLs return a compact `.txt` snapshot with repository
184
+ metadata, the root file tree, and README content.
185
+
186
+ | Parameter | Required | Description | Default |
187
+ | --------- | -------- | ------------ | ------- |
188
+ | `url` | Yes | URL to fetch | - |
189
+
152
190
  ### Image Endpoint
153
191
 
154
192
  ```
@@ -334,6 +372,7 @@ web-capture <url> [options]
334
372
  | ----------------- | ---------------------------------------- |
335
373
  | `markdown` / `md` | Markdown conversion (default) |
336
374
  | `html` | Rendered HTML |
375
+ | `txt` / `text` | Raw text download |
337
376
  | `image` / `png` | PNG screenshot (lossless) |
338
377
  | `jpeg` | JPEG screenshot (configurable quality) |
339
378
  | `pdf` | PDF with embedded images |
@@ -463,6 +502,9 @@ with environment variables:
463
502
  # Download the Wikipedia page (markdown + image) in every supported engine
464
503
  WIKIPEDIA_INTEGRATION=true npm test -- --testPathPattern="wikipedia-download"
465
504
 
505
+ # Download a GitHub repository page as compact txt/markdown, original HTML, and screenshots
506
+ GITHUB_REPOSITORY_INTEGRATION=true npm test -- --testPathPattern="github-readme"
507
+
466
508
  # Habr articles and public Google Docs live suites
467
509
  HABR_INTEGRATION=true npm test -- --testPathPattern="habr-article"
468
510
  GDOCS_INTEGRATION=true npm test -- --testPathPattern="gdocs-public-doc"
@@ -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,
@@ -609,6 +614,12 @@ async function captureUrl(url, options) {
609
614
  captureGoogleDocWithBrowserOrFallback,
610
615
  selectGoogleDocsCaptureMethod,
611
616
  } = await import('../src/gdocs.js');
617
+ const {
618
+ fetchGithubRepositorySnapshot,
619
+ formatGithubRepositoryMarkdown,
620
+ formatGithubRepositoryText,
621
+ isGithubRepositoryUrl,
622
+ } = await import('../src/github.js');
612
623
 
613
624
  const normalizedFormat = format.toLowerCase();
614
625
  log.debug(() => ({
@@ -915,7 +926,35 @@ async function captureUrl(url, options) {
915
926
  }
916
927
 
917
928
  try {
918
- if (normalizedFormat === 'jpeg') {
929
+ if (normalizedFormat === 'txt' || normalizedFormat === 'text') {
930
+ let text;
931
+ if (isGithubRepositoryUrl(absoluteUrl)) {
932
+ const snapshot = await fetchGithubRepositorySnapshot(absoluteUrl);
933
+ text = formatGithubRepositoryText(snapshot);
934
+ } 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}`);
943
+ }
944
+ text = await response.text();
945
+ }
946
+ const output =
947
+ explicitOutput === '-'
948
+ ? null
949
+ : explicitOutput || deriveOutputPath(absoluteUrl, 'txt', dataDir);
950
+ if (output) {
951
+ fs.mkdirSync(path.dirname(output), { recursive: true });
952
+ fs.writeFileSync(output, text, 'utf-8');
953
+ console.error(`Text saved to: ${output}`);
954
+ } else {
955
+ process.stdout.write(text);
956
+ }
957
+ } else if (normalizedFormat === 'jpeg') {
919
958
  // JPEG screenshot
920
959
  const { createBrowser } = await import('../src/browser.js');
921
960
  const { dismissPopups, scrollToLoadContent } =
@@ -1125,17 +1164,27 @@ async function captureUrl(url, options) {
1125
1164
  console.error(`Archive saved to: ${outPath}`);
1126
1165
  } else if (normalizedFormat === 'markdown' || normalizedFormat === 'md') {
1127
1166
  // Markdown format — enhanced conversion is now the default
1128
- const html = await fetchHtml(absoluteUrl);
1129
- const { convertHtmlToMarkdownEnhanced } = await import('../src/lib.js');
1130
- const result = convertHtmlToMarkdownEnhanced(html, absoluteUrl, {
1131
- extractLatex: options.extractLatex,
1132
- extractMetadata: options.extractMetadata,
1133
- postProcess: options.postProcess,
1134
- detectCodeLanguage: options.detectCodeLanguage,
1135
- contentSelector: options.contentSelector,
1136
- bodySelector: options.bodySelector,
1137
- });
1138
- const markdown = result.markdown;
1167
+ let markdown;
1168
+ if (
1169
+ isGithubRepositoryUrl(absoluteUrl) &&
1170
+ !options.contentSelector &&
1171
+ !options.bodySelector
1172
+ ) {
1173
+ const snapshot = await fetchGithubRepositorySnapshot(absoluteUrl);
1174
+ markdown = formatGithubRepositoryMarkdown(snapshot);
1175
+ } else {
1176
+ const html = await fetchHtml(absoluteUrl);
1177
+ const { convertHtmlToMarkdownEnhanced } = await import('../src/lib.js');
1178
+ const result = convertHtmlToMarkdownEnhanced(html, absoluteUrl, {
1179
+ extractLatex: options.extractLatex,
1180
+ extractMetadata: options.extractMetadata,
1181
+ postProcess: options.postProcess,
1182
+ detectCodeLanguage: options.detectCodeLanguage,
1183
+ contentSelector: options.contentSelector,
1184
+ bodySelector: options.bodySelector,
1185
+ });
1186
+ markdown = result.markdown;
1187
+ }
1139
1188
 
1140
1189
  const output =
1141
1190
  explicitOutput === '-'
@@ -1160,6 +1209,8 @@ async function captureUrl(url, options) {
1160
1209
  normalizedFormat === 'screenshot'
1161
1210
  ) {
1162
1211
  // Image/screenshot format
1212
+ const { dismissPopups, scrollToLoadContent } =
1213
+ await import('../src/popups.js');
1163
1214
  const browser = await createBrowser(engine);
1164
1215
  try {
1165
1216
  const page = await browser.newPage();
@@ -1177,8 +1228,12 @@ async function captureUrl(url, options) {
1177
1228
  });
1178
1229
  // Wait for 5 seconds after page load
1179
1230
  await new Promise((resolve) => setTimeout(resolve, 5000));
1231
+ if (fullPage) {
1232
+ await scrollToLoadContent(page);
1233
+ }
1234
+ await dismissPopups(page);
1180
1235
 
1181
- const buffer = await page.screenshot({ type: 'png' });
1236
+ const buffer = await page.screenshot({ type: 'png', fullPage });
1182
1237
 
1183
1238
  if (explicitOutput) {
1184
1239
  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.1",
3
+ "version": "1.10.3",
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/github.js ADDED
@@ -0,0 +1,285 @@
1
+ import fetch from 'node-fetch';
2
+ import { URL } from 'node:url';
3
+
4
+ const GITHUB_API_BASE = 'https://api.github.com';
5
+ const GITHUB_USER_AGENT = 'web-capture';
6
+
7
+ export function parseGithubRepositoryUrl(url) {
8
+ try {
9
+ const parsed = new URL(url);
10
+ const host = parsed.hostname.toLowerCase();
11
+ if (host !== 'github.com' && host !== 'www.github.com') {
12
+ return null;
13
+ }
14
+
15
+ const parts = parsed.pathname.split('/').filter(Boolean);
16
+ if (parts.length !== 2) {
17
+ return null;
18
+ }
19
+
20
+ const [owner, repo] = parts;
21
+ if (!owner || !repo) {
22
+ return null;
23
+ }
24
+
25
+ return {
26
+ owner,
27
+ repo,
28
+ fullName: `${owner}/${repo}`,
29
+ htmlUrl: `https://github.com/${owner}/${repo}`,
30
+ };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export function isGithubRepositoryUrl(url) {
37
+ return Boolean(parseGithubRepositoryUrl(url));
38
+ }
39
+
40
+ export function getGithubRepositoryTextFilename(url) {
41
+ const parsed = parseGithubRepositoryUrl(url);
42
+ return parsed ? `${parsed.owner}-${parsed.repo}.txt` : null;
43
+ }
44
+
45
+ export async function fetchGithubRepositorySnapshot(url) {
46
+ const parsed = parseGithubRepositoryUrl(url);
47
+ if (!parsed) {
48
+ throw new Error(`Not a GitHub repository URL: ${url}`);
49
+ }
50
+
51
+ const repository = await fetchGithubJson(
52
+ `${GITHUB_API_BASE}/repos/${encodePath(parsed.owner)}/${encodePath(parsed.repo)}`
53
+ );
54
+ const defaultBranch = repository.default_branch || 'main';
55
+
56
+ const [readme, tree] = await Promise.all([
57
+ fetchGithubReadme(parsed, defaultBranch),
58
+ fetchGithubRootTree(parsed, defaultBranch),
59
+ ]);
60
+
61
+ return {
62
+ sourceUrl: parsed.htmlUrl,
63
+ repository,
64
+ defaultBranch,
65
+ readme,
66
+ tree,
67
+ };
68
+ }
69
+
70
+ export function formatGithubRepositoryMarkdown(snapshot) {
71
+ const { repository, defaultBranch, readme, tree, sourceUrl } = snapshot;
72
+ const lines = [
73
+ `# ${repository.full_name}`,
74
+ '',
75
+ repository.description ? `> ${repository.description}` : null,
76
+ '',
77
+ '## Repository',
78
+ '',
79
+ `- URL: ${repository.html_url || sourceUrl}`,
80
+ `- Default branch: \`${defaultBranch}\``,
81
+ repository.language ? `- Primary language: ${repository.language}` : null,
82
+ numberLine('Stars', repository.stargazers_count),
83
+ numberLine('Forks', repository.forks_count),
84
+ numberLine('Open issues', repository.open_issues_count),
85
+ repository.license?.spdx_id
86
+ ? `- License: ${repository.license.spdx_id}`
87
+ : null,
88
+ topicsLine(repository.topics),
89
+ '',
90
+ '## Files',
91
+ '',
92
+ ].filter((line) => line !== null);
93
+
94
+ if (tree.length > 0) {
95
+ for (const item of tree) {
96
+ const label = item.type === 'dir' ? `${item.name}/` : item.name;
97
+ const suffix =
98
+ item.type === 'file' && typeof item.size === 'number'
99
+ ? ` (${formatBytes(item.size)})`
100
+ : '';
101
+ lines.push(`- [${label}](${item.html_url})${suffix}`);
102
+ }
103
+ } else {
104
+ lines.push('- No root files returned by the GitHub API.');
105
+ }
106
+
107
+ lines.push('', `## ${readme?.path || 'README'}`, '');
108
+ if (readme?.content) {
109
+ lines.push(readme.content.trimEnd(), '');
110
+ } else {
111
+ lines.push('README content was not returned by the GitHub API.', '');
112
+ }
113
+
114
+ return lines.join('\n');
115
+ }
116
+
117
+ export function formatGithubRepositoryText(snapshot) {
118
+ const { repository, defaultBranch, readme, tree, sourceUrl } = snapshot;
119
+ const lines = [
120
+ `Repository: ${repository.full_name}`,
121
+ repository.description ? `Description: ${repository.description}` : null,
122
+ `URL: ${repository.html_url || sourceUrl}`,
123
+ `Default branch: ${defaultBranch}`,
124
+ repository.language ? `Primary language: ${repository.language}` : null,
125
+ plainNumberLine('Stars', repository.stargazers_count),
126
+ plainNumberLine('Forks', repository.forks_count),
127
+ plainNumberLine('Open issues', repository.open_issues_count),
128
+ repository.license?.spdx_id
129
+ ? `License: ${repository.license.spdx_id}`
130
+ : null,
131
+ repository.topics?.length
132
+ ? `Topics: ${repository.topics.join(', ')}`
133
+ : null,
134
+ '',
135
+ 'Files:',
136
+ ].filter((line) => line !== null);
137
+
138
+ if (tree.length > 0) {
139
+ for (const item of tree) {
140
+ const label = item.type === 'dir' ? `${item.name}/` : item.name;
141
+ const suffix =
142
+ item.type === 'file' && typeof item.size === 'number'
143
+ ? ` (${formatBytes(item.size)})`
144
+ : '';
145
+ lines.push(`- ${label}${suffix}`);
146
+ }
147
+ } else {
148
+ lines.push('- No root files returned by the GitHub API.');
149
+ }
150
+
151
+ lines.push('', `${readme?.path || 'README'}:`, '');
152
+ if (readme?.content) {
153
+ lines.push(readme.content.trimEnd(), '');
154
+ } else {
155
+ lines.push('README content was not returned by the GitHub API.', '');
156
+ }
157
+
158
+ return lines.join('\n');
159
+ }
160
+
161
+ async function fetchGithubReadme(parsed, defaultBranch) {
162
+ const readme = await fetchGithubJson(
163
+ `${GITHUB_API_BASE}/repos/${encodePath(parsed.owner)}/${encodePath(parsed.repo)}/readme?ref=${encodeURIComponent(defaultBranch)}`,
164
+ { optional: true }
165
+ );
166
+ if (!readme) {
167
+ return null;
168
+ }
169
+
170
+ let content = null;
171
+ if (readme.content && readme.encoding === 'base64') {
172
+ content = Buffer.from(
173
+ readme.content.replace(/\s+/g, ''),
174
+ 'base64'
175
+ ).toString('utf8');
176
+ } else if (readme.download_url) {
177
+ content = await fetchGithubText(readme.download_url, { optional: true });
178
+ }
179
+
180
+ return {
181
+ name: readme.name || 'README',
182
+ path: readme.path || readme.name || 'README',
183
+ htmlUrl: readme.html_url,
184
+ content,
185
+ };
186
+ }
187
+
188
+ async function fetchGithubRootTree(parsed, defaultBranch) {
189
+ const tree = await fetchGithubJson(
190
+ `${GITHUB_API_BASE}/repos/${encodePath(parsed.owner)}/${encodePath(parsed.repo)}/contents?ref=${encodeURIComponent(defaultBranch)}`,
191
+ { optional: true }
192
+ );
193
+ if (!Array.isArray(tree)) {
194
+ return [];
195
+ }
196
+
197
+ return tree
198
+ .map((item) => {
199
+ const pathKind = item.type === 'dir' ? 'tree' : 'blob';
200
+ return {
201
+ name: item.name,
202
+ path: item.path,
203
+ type: item.type,
204
+ size: item.size,
205
+ html_url:
206
+ item.html_url ||
207
+ `https://github.com/${parsed.owner}/${parsed.repo}/${pathKind}/${defaultBranch}/${item.path}`,
208
+ };
209
+ })
210
+ .sort((a, b) => {
211
+ if (a.type !== b.type) {
212
+ return a.type === 'dir' ? -1 : 1;
213
+ }
214
+ return a.name.localeCompare(b.name);
215
+ });
216
+ }
217
+
218
+ async function fetchGithubJson(url, options = {}) {
219
+ const response = await fetch(url, {
220
+ headers: githubHeaders('application/vnd.github+json'),
221
+ });
222
+ if (options.optional && response.status === 404) {
223
+ return null;
224
+ }
225
+ if (!response.ok) {
226
+ throw new Error(`GitHub API ${response.status}: ${response.statusText}`);
227
+ }
228
+ return response.json();
229
+ }
230
+
231
+ async function fetchGithubText(url, options = {}) {
232
+ const response = await fetch(url, {
233
+ headers: githubHeaders('text/plain'),
234
+ });
235
+ if (options.optional && response.status === 404) {
236
+ return null;
237
+ }
238
+ if (!response.ok) {
239
+ throw new Error(`GitHub raw ${response.status}: ${response.statusText}`);
240
+ }
241
+ return response.text();
242
+ }
243
+
244
+ function githubHeaders(accept) {
245
+ const headers = {
246
+ Accept: accept,
247
+ 'User-Agent': GITHUB_USER_AGENT,
248
+ 'X-GitHub-Api-Version': '2022-11-28',
249
+ };
250
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
251
+ if (token) {
252
+ headers.Authorization = `Bearer ${token}`;
253
+ }
254
+ return headers;
255
+ }
256
+
257
+ function encodePath(value) {
258
+ return encodeURIComponent(value);
259
+ }
260
+
261
+ function numberLine(label, value) {
262
+ return typeof value === 'number'
263
+ ? `- ${label}: ${value.toLocaleString()}`
264
+ : null;
265
+ }
266
+
267
+ function plainNumberLine(label, value) {
268
+ return typeof value === 'number'
269
+ ? `${label}: ${value.toLocaleString()}`
270
+ : null;
271
+ }
272
+
273
+ function topicsLine(topics) {
274
+ return topics?.length ? `- Topics: ${topics.join(', ')}` : null;
275
+ }
276
+
277
+ function formatBytes(size) {
278
+ if (size < 1024) {
279
+ return `${size} B`;
280
+ }
281
+ if (size < 1024 * 1024) {
282
+ return `${(size / 1024).toFixed(1)} KB`;
283
+ }
284
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
285
+ }
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);