@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 +12 -0
- package/README.md +42 -0
- package/bin/web-capture.js +72 -17
- package/jest.config.mjs +1 -0
- package/package.json +1 -1
- package/src/github.js +285 -0
- package/src/index.js +2 -0
- package/src/lib.js +107 -0
- package/src/markdown.js +92 -5
- package/src/txt.js +80 -0
- package/tests/integration/api-endpoints.test.js +104 -0
- package/tests/integration/github-readme.test.js +137 -0
- package/tests/unit/github.test.js +97 -0
- package/tests/xpaste/xpaste-screenshot-markdown.mock.test.js +375 -0
- package/tests/xpaste/xpaste.e2e.test.js +36 -0
- package/tests/xpaste/xpaste.integration.test.js +41 -0
- package/tests/xpaste/xpaste.mock.test.js +261 -0
- package/tests/xpaste/xpaste.unit.test.js +119 -0
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"
|
package/bin/web-capture.js
CHANGED
|
@@ -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 {
|
|
602
|
-
|
|
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 === '
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
package/package.json
CHANGED
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);
|