@link-assistant/web-capture 1.10.2 → 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 +6 -0
- package/README.md +15 -0
- package/bin/web-capture.js +42 -19
- package/package.json +1 -1
- package/src/github.js +285 -0
- package/src/markdown.js +20 -0
- package/src/txt.js +27 -7
- 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/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -52,6 +52,10 @@ web-capture https://example.com --format html -o page.html
|
|
|
52
52
|
# Capture raw paste text
|
|
53
53
|
web-capture https://xpaste.pro/p/t4q0Lsp0 --format txt -o paste.txt
|
|
54
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
|
+
|
|
55
59
|
# Take a PNG screenshot
|
|
56
60
|
web-capture https://example.com --format png -o screenshot.png
|
|
57
61
|
|
|
@@ -153,6 +157,11 @@ containing `index.md`, `xpaste-pro-<id>.md`, and `xpaste-pro-<id>.txt`.
|
|
|
153
157
|
Canonical `/p/<id>`, `/p/<id>/raw`, `/ru/p/<id>`, and `/en/p/<id>` URLs are
|
|
154
158
|
normalized before capture.
|
|
155
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
|
+
|
|
156
165
|
| Parameter | Required | Description | Default |
|
|
157
166
|
| ------------------- | -------- | ------------------------------------------------------------------------- | -------- |
|
|
158
167
|
| `url` | Yes | URL to fetch | - |
|
|
@@ -171,6 +180,9 @@ Returns raw text content as a `.txt` attachment. xpaste.pro paste URLs are
|
|
|
171
180
|
normalized to their `/raw` endpoint, including localized `/ru/p/<id>` and
|
|
172
181
|
`/en/p/<id>` URLs.
|
|
173
182
|
|
|
183
|
+
Plain GitHub repository URLs return a compact `.txt` snapshot with repository
|
|
184
|
+
metadata, the root file tree, and README content.
|
|
185
|
+
|
|
174
186
|
| Parameter | Required | Description | Default |
|
|
175
187
|
| --------- | -------- | ------------ | ------- |
|
|
176
188
|
| `url` | Yes | URL to fetch | - |
|
|
@@ -490,6 +502,9 @@ with environment variables:
|
|
|
490
502
|
# Download the Wikipedia page (markdown + image) in every supported engine
|
|
491
503
|
WIKIPEDIA_INTEGRATION=true npm test -- --testPathPattern="wikipedia-download"
|
|
492
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
|
+
|
|
493
508
|
# Habr articles and public Google Docs live suites
|
|
494
509
|
HABR_INTEGRATION=true npm test -- --testPathPattern="habr-article"
|
|
495
510
|
GDOCS_INTEGRATION=true npm test -- --testPathPattern="gdocs-public-doc"
|
package/bin/web-capture.js
CHANGED
|
@@ -614,6 +614,12 @@ async function captureUrl(url, options) {
|
|
|
614
614
|
captureGoogleDocWithBrowserOrFallback,
|
|
615
615
|
selectGoogleDocsCaptureMethod,
|
|
616
616
|
} = await import('../src/gdocs.js');
|
|
617
|
+
const {
|
|
618
|
+
fetchGithubRepositorySnapshot,
|
|
619
|
+
formatGithubRepositoryMarkdown,
|
|
620
|
+
formatGithubRepositoryText,
|
|
621
|
+
isGithubRepositoryUrl,
|
|
622
|
+
} = await import('../src/github.js');
|
|
617
623
|
|
|
618
624
|
const normalizedFormat = format.toLowerCase();
|
|
619
625
|
log.debug(() => ({
|
|
@@ -921,15 +927,22 @@ async function captureUrl(url, options) {
|
|
|
921
927
|
|
|
922
928
|
try {
|
|
923
929
|
if (normalizedFormat === 'txt' || normalizedFormat === 'text') {
|
|
924
|
-
|
|
925
|
-
if (
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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();
|
|
931
945
|
}
|
|
932
|
-
const text = await response.text();
|
|
933
946
|
const output =
|
|
934
947
|
explicitOutput === '-'
|
|
935
948
|
? null
|
|
@@ -1151,17 +1164,27 @@ async function captureUrl(url, options) {
|
|
|
1151
1164
|
console.error(`Archive saved to: ${outPath}`);
|
|
1152
1165
|
} else if (normalizedFormat === 'markdown' || normalizedFormat === 'md') {
|
|
1153
1166
|
// Markdown format — enhanced conversion is now the default
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
+
}
|
|
1165
1188
|
|
|
1166
1189
|
const output =
|
|
1167
1190
|
explicitOutput === '-'
|
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/markdown.js
CHANGED
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
normalizeUrlForTextPage,
|
|
9
9
|
scopeHtmlForMarkdown,
|
|
10
10
|
} from './lib.js';
|
|
11
|
+
import {
|
|
12
|
+
fetchGithubRepositorySnapshot,
|
|
13
|
+
formatGithubRepositoryMarkdown,
|
|
14
|
+
isGithubRepositoryUrl,
|
|
15
|
+
} from './github.js';
|
|
11
16
|
import { convertWithKreuzberg, isKreuzbergAvailable } from './kreuzberg.js';
|
|
12
17
|
import { applyImageMode } from './extract-images.js';
|
|
13
18
|
import archiver from 'archiver';
|
|
@@ -39,6 +44,21 @@ export async function markdownHandler(req, res) {
|
|
|
39
44
|
|
|
40
45
|
try {
|
|
41
46
|
const pageUrl = normalizeUrlForTextPage(url);
|
|
47
|
+
if (
|
|
48
|
+
format === 'text' &&
|
|
49
|
+
!req.query.contentSelector &&
|
|
50
|
+
!req.query.bodySelector &&
|
|
51
|
+
isGithubRepositoryUrl(pageUrl)
|
|
52
|
+
) {
|
|
53
|
+
const snapshot = await fetchGithubRepositorySnapshot(pageUrl);
|
|
54
|
+
let markdown = formatGithubRepositoryMarkdown(snapshot);
|
|
55
|
+
const result = await applyImageMode(markdown, {
|
|
56
|
+
mode: embedImages ? 'embed' : 'default',
|
|
57
|
+
});
|
|
58
|
+
markdown = result.markdown;
|
|
59
|
+
return await sendMarkdownResponse(res, url, markdown);
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
const html = await fetchHtml(pageUrl);
|
|
43
63
|
|
|
44
64
|
if (converter === 'kreuzberg') {
|
package/src/txt.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
3
|
import { getTextPasteFilename, normalizeUrlForTextContent } from './lib.js';
|
|
4
|
+
import {
|
|
5
|
+
fetchGithubRepositorySnapshot,
|
|
6
|
+
formatGithubRepositoryText,
|
|
7
|
+
getGithubRepositoryTextFilename,
|
|
8
|
+
isGithubRepositoryUrl,
|
|
9
|
+
} from './github.js';
|
|
4
10
|
|
|
5
11
|
export async function txtHandler(req, res) {
|
|
6
12
|
const url = req.query.url;
|
|
@@ -9,6 +15,12 @@ export async function txtHandler(req, res) {
|
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
try {
|
|
18
|
+
if (isGithubRepositoryUrl(url)) {
|
|
19
|
+
const snapshot = await fetchGithubRepositorySnapshot(url);
|
|
20
|
+
const text = formatGithubRepositoryText(snapshot);
|
|
21
|
+
return sendTextResponse(res, url, text);
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
// Normalize URL to get text content (e.g., xpaste.pro -> xpaste.pro/raw)
|
|
13
25
|
const textUrl = normalizeUrlForTextContent(url);
|
|
14
26
|
|
|
@@ -27,20 +39,28 @@ export async function txtHandler(req, res) {
|
|
|
27
39
|
|
|
28
40
|
const text = await response.text();
|
|
29
41
|
|
|
30
|
-
|
|
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);
|
|
42
|
+
sendTextResponse(res, url, text);
|
|
37
43
|
} catch (err) {
|
|
38
44
|
console.error('Text fetch error:', err);
|
|
39
45
|
res.status(500).send('Error fetching text content');
|
|
40
46
|
}
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
function sendTextResponse(res, url, text) {
|
|
50
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
51
|
+
res.setHeader(
|
|
52
|
+
'Content-Disposition',
|
|
53
|
+
`attachment; filename="${getFilenameFromUrl(url)}"`
|
|
54
|
+
);
|
|
55
|
+
res.send(text);
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
function getFilenameFromUrl(url) {
|
|
59
|
+
const githubRepositoryFilename = getGithubRepositoryTextFilename(url);
|
|
60
|
+
if (githubRepositoryFilename) {
|
|
61
|
+
return githubRepositoryFilename;
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
const textPasteFilename = getTextPasteFilename(url);
|
|
45
65
|
if (textPasteFilename) {
|
|
46
66
|
return textPasteFilename;
|
|
@@ -25,6 +25,50 @@ const MOCK_HTML = `<!DOCTYPE html>
|
|
|
25
25
|
<img src="https://example.com/image.png" alt="test">
|
|
26
26
|
</body></html>`;
|
|
27
27
|
|
|
28
|
+
function mockGithubRepository(owner = 'octocat', repo = 'Hello-World') {
|
|
29
|
+
const readme = '# Hello World\n\nThis README came from GitHub.';
|
|
30
|
+
nock('https://api.github.com')
|
|
31
|
+
.get(`/repos/${owner}/${repo}`)
|
|
32
|
+
.reply(200, {
|
|
33
|
+
full_name: `${owner}/${repo}`,
|
|
34
|
+
description: 'A friendly test repository',
|
|
35
|
+
html_url: `https://github.com/${owner}/${repo}`,
|
|
36
|
+
default_branch: 'master',
|
|
37
|
+
language: 'JavaScript',
|
|
38
|
+
stargazers_count: 42,
|
|
39
|
+
forks_count: 7,
|
|
40
|
+
open_issues_count: 3,
|
|
41
|
+
license: { spdx_id: 'MIT' },
|
|
42
|
+
topics: ['demo', 'capture'],
|
|
43
|
+
})
|
|
44
|
+
.get(`/repos/${owner}/${repo}/readme`)
|
|
45
|
+
.query({ ref: 'master' })
|
|
46
|
+
.reply(200, {
|
|
47
|
+
name: 'README.md',
|
|
48
|
+
path: 'README.md',
|
|
49
|
+
encoding: 'base64',
|
|
50
|
+
content: Buffer.from(readme, 'utf8').toString('base64'),
|
|
51
|
+
html_url: `https://github.com/${owner}/${repo}/blob/master/README.md`,
|
|
52
|
+
})
|
|
53
|
+
.get(`/repos/${owner}/${repo}/contents`)
|
|
54
|
+
.query({ ref: 'master' })
|
|
55
|
+
.reply(200, [
|
|
56
|
+
{
|
|
57
|
+
name: 'src',
|
|
58
|
+
path: 'src',
|
|
59
|
+
type: 'dir',
|
|
60
|
+
html_url: `https://github.com/${owner}/${repo}/tree/master/src`,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'README.md',
|
|
64
|
+
path: 'README.md',
|
|
65
|
+
type: 'file',
|
|
66
|
+
size: readme.length,
|
|
67
|
+
html_url: `https://github.com/${owner}/${repo}/blob/master/README.md`,
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
28
72
|
afterEach(() => {
|
|
29
73
|
nock.cleanAll();
|
|
30
74
|
});
|
|
@@ -70,6 +114,44 @@ describe('API Endpoint Tests', () => {
|
|
|
70
114
|
expect(res.text).toContain('Hello world');
|
|
71
115
|
});
|
|
72
116
|
|
|
117
|
+
it('returns a compact GitHub repository markdown snapshot', async () => {
|
|
118
|
+
mockGithubRepository();
|
|
119
|
+
|
|
120
|
+
const res = await request(app)
|
|
121
|
+
.get('/markdown')
|
|
122
|
+
.query({ url: 'https://github.com/octocat/Hello-World' })
|
|
123
|
+
.expect(200);
|
|
124
|
+
|
|
125
|
+
expect(res.headers['content-type']).toMatch(/text\/markdown/);
|
|
126
|
+
expect(res.text).toContain('# octocat/Hello-World');
|
|
127
|
+
expect(res.text).toContain('## Repository');
|
|
128
|
+
expect(res.text).toContain('## Files');
|
|
129
|
+
expect(res.text).toContain('- [src/](');
|
|
130
|
+
expect(res.text).toContain('## README.md');
|
|
131
|
+
expect(res.text).toContain('This README came from GitHub.');
|
|
132
|
+
expect(res.text).not.toContain('Skip to content');
|
|
133
|
+
expect(nock.isDone()).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns a compact GitHub repository markdown snapshot for kreuzberg text format', async () => {
|
|
137
|
+
mockGithubRepository();
|
|
138
|
+
|
|
139
|
+
const res = await request(app)
|
|
140
|
+
.get('/markdown')
|
|
141
|
+
.query({
|
|
142
|
+
url: 'https://github.com/octocat/Hello-World',
|
|
143
|
+
converter: 'kreuzberg',
|
|
144
|
+
format: 'text',
|
|
145
|
+
})
|
|
146
|
+
.expect(200);
|
|
147
|
+
|
|
148
|
+
expect(res.headers['content-type']).toMatch(/text\/markdown/);
|
|
149
|
+
expect(res.text).toContain('# octocat/Hello-World');
|
|
150
|
+
expect(res.text).toContain('## Files');
|
|
151
|
+
expect(res.text).toContain('This README came from GitHub.');
|
|
152
|
+
expect(nock.isDone()).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
73
155
|
it('rejects unsupported converter names', async () => {
|
|
74
156
|
await request(app)
|
|
75
157
|
.get('/markdown')
|
|
@@ -133,6 +215,28 @@ describe('API Endpoint Tests', () => {
|
|
|
133
215
|
});
|
|
134
216
|
});
|
|
135
217
|
|
|
218
|
+
describe('GET /txt', () => {
|
|
219
|
+
it('returns a compact GitHub repository plain-text snapshot', async () => {
|
|
220
|
+
mockGithubRepository();
|
|
221
|
+
|
|
222
|
+
const res = await request(app)
|
|
223
|
+
.get('/txt')
|
|
224
|
+
.query({ url: 'https://github.com/octocat/Hello-World' })
|
|
225
|
+
.expect(200);
|
|
226
|
+
|
|
227
|
+
expect(res.headers['content-type']).toMatch(/text\/plain/);
|
|
228
|
+
expect(res.headers['content-disposition']).toContain(
|
|
229
|
+
'filename="octocat-Hello-World.txt"'
|
|
230
|
+
);
|
|
231
|
+
expect(res.text).toContain('Repository: octocat/Hello-World');
|
|
232
|
+
expect(res.text).toContain('Files:');
|
|
233
|
+
expect(res.text).toContain('- src/');
|
|
234
|
+
expect(res.text).toContain('README.md:');
|
|
235
|
+
expect(res.text).toContain('This README came from GitHub.');
|
|
236
|
+
expect(nock.isDone()).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
136
240
|
describe('GET /archive', () => {
|
|
137
241
|
it('returns a ZIP archive with remote images (markdown format)', async () => {
|
|
138
242
|
nock('https://example.com').get('/archive-test').reply(200, MOCK_HTML, {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for capturing GitHub repository pages (issue #5).
|
|
3
|
+
*
|
|
4
|
+
* Live tests are gated behind GITHUB_REPOSITORY_INTEGRATION so default/offline
|
|
5
|
+
* runs stay deterministic. The mocked API endpoint tests cover the default
|
|
6
|
+
* behavior without network access; this suite proves the real GitHub repository
|
|
7
|
+
* page can still be captured as compact text/markdown, original HTML, and PNG
|
|
8
|
+
* screenshots in every supported JavaScript browser engine.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { jest } from '@jest/globals';
|
|
12
|
+
import { createBrowser } from '../../src/browser.js';
|
|
13
|
+
import { fetchHtml } from '../../src/lib.js';
|
|
14
|
+
import { retry } from '../../src/retry.js';
|
|
15
|
+
import {
|
|
16
|
+
fetchGithubRepositorySnapshot,
|
|
17
|
+
formatGithubRepositoryMarkdown,
|
|
18
|
+
formatGithubRepositoryText,
|
|
19
|
+
parseGithubRepositoryUrl,
|
|
20
|
+
} from '../../src/github.js';
|
|
21
|
+
|
|
22
|
+
const GITHUB_REPOSITORY_URL =
|
|
23
|
+
process.env.GITHUB_REPOSITORY_URL ||
|
|
24
|
+
'https://github.com/link-assistant/web-capture';
|
|
25
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
26
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const SKIP_LIVE =
|
|
30
|
+
!process.env.GITHUB_REPOSITORY_INTEGRATION ||
|
|
31
|
+
process.env.GITHUB_REPOSITORY_INTEGRATION === 'false';
|
|
32
|
+
const describeIfLive = SKIP_LIVE ? describe.skip : describe;
|
|
33
|
+
|
|
34
|
+
jest.setTimeout(120000);
|
|
35
|
+
|
|
36
|
+
async function navigateWithRetry(page, url) {
|
|
37
|
+
await retry(
|
|
38
|
+
async () => {
|
|
39
|
+
await page.goto(url, {
|
|
40
|
+
waitUntil: 'domcontentloaded',
|
|
41
|
+
timeout: 30000,
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
retries: 3,
|
|
46
|
+
baseDelay: 2000,
|
|
47
|
+
onRetry: (err, attempt, delay) => {
|
|
48
|
+
console.log(
|
|
49
|
+
`Navigation retry ${attempt} for ${url} after ${delay}ms: ${err.message}`
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
await page.waitForTimeout(5000);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describeIfLive('GitHub repository capture', () => {
|
|
58
|
+
it('downloads compact repository text and markdown with README plus file tree', async () => {
|
|
59
|
+
const parsed = parseGithubRepositoryUrl(GITHUB_REPOSITORY_URL);
|
|
60
|
+
expect(parsed).not.toBeNull();
|
|
61
|
+
|
|
62
|
+
const snapshot = await retry(
|
|
63
|
+
() => fetchGithubRepositorySnapshot(GITHUB_REPOSITORY_URL),
|
|
64
|
+
{
|
|
65
|
+
retries: 3,
|
|
66
|
+
baseDelay: 2000,
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const text = formatGithubRepositoryText(snapshot);
|
|
71
|
+
const markdown = formatGithubRepositoryMarkdown(snapshot);
|
|
72
|
+
|
|
73
|
+
expect(text).toContain(`Repository: ${parsed.fullName}`);
|
|
74
|
+
expect(text).toContain('Files:');
|
|
75
|
+
expect(text).toMatch(/README/i);
|
|
76
|
+
expect(text.length).toBeGreaterThan(500);
|
|
77
|
+
|
|
78
|
+
expect(markdown).toContain(`# ${parsed.fullName}`);
|
|
79
|
+
expect(markdown).toContain('## Files');
|
|
80
|
+
expect(markdown).toMatch(/## .*README/i);
|
|
81
|
+
expect(markdown.length).toBeGreaterThan(500);
|
|
82
|
+
expect(markdown).not.toMatch(/<html/i);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('downloads original GitHub repository HTML', async () => {
|
|
86
|
+
const parsed = parseGithubRepositoryUrl(GITHUB_REPOSITORY_URL);
|
|
87
|
+
expect(parsed).not.toBeNull();
|
|
88
|
+
|
|
89
|
+
const html = await retry(() => fetchHtml(GITHUB_REPOSITORY_URL), {
|
|
90
|
+
retries: 3,
|
|
91
|
+
baseDelay: 2000,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(html).toMatch(/<html/i);
|
|
95
|
+
expect(html).toContain(parsed.owner);
|
|
96
|
+
expect(html).toContain(parsed.repo);
|
|
97
|
+
expect(html.length).toBeGreaterThan(1000);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe.each(['puppeteer', 'playwright'])(
|
|
102
|
+
'GitHub repository screenshot (%s engine)',
|
|
103
|
+
(engine) => {
|
|
104
|
+
describeIfLive(`${engine}`, () => {
|
|
105
|
+
let browser;
|
|
106
|
+
|
|
107
|
+
beforeAll(async () => {
|
|
108
|
+
browser = await createBrowser(engine);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
if (browser) {
|
|
113
|
+
await browser.close();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('captures the GitHub repository page as a PNG screenshot', async () => {
|
|
118
|
+
const page = await browser.newPage();
|
|
119
|
+
await page.setExtraHTTPHeaders({
|
|
120
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
121
|
+
'Accept-Charset': 'utf-8',
|
|
122
|
+
});
|
|
123
|
+
await page.setUserAgent(
|
|
124
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
125
|
+
);
|
|
126
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
127
|
+
await navigateWithRetry(page, GITHUB_REPOSITORY_URL);
|
|
128
|
+
|
|
129
|
+
const screenshot = await page.screenshot({ type: 'png' });
|
|
130
|
+
|
|
131
|
+
expect(screenshot).toBeInstanceOf(Buffer);
|
|
132
|
+
expect(screenshot.length).toBeGreaterThan(1000);
|
|
133
|
+
expect(screenshot.slice(0, 8)).toEqual(PNG_SIGNATURE);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatGithubRepositoryMarkdown,
|
|
3
|
+
formatGithubRepositoryText,
|
|
4
|
+
getGithubRepositoryTextFilename,
|
|
5
|
+
isGithubRepositoryUrl,
|
|
6
|
+
parseGithubRepositoryUrl,
|
|
7
|
+
} from '../../src/github.js';
|
|
8
|
+
|
|
9
|
+
const SNAPSHOT = {
|
|
10
|
+
sourceUrl: 'https://github.com/octocat/Hello-World',
|
|
11
|
+
defaultBranch: 'master',
|
|
12
|
+
repository: {
|
|
13
|
+
full_name: 'octocat/Hello-World',
|
|
14
|
+
description: 'A friendly test repository',
|
|
15
|
+
html_url: 'https://github.com/octocat/Hello-World',
|
|
16
|
+
default_branch: 'master',
|
|
17
|
+
language: 'JavaScript',
|
|
18
|
+
stargazers_count: 42,
|
|
19
|
+
forks_count: 7,
|
|
20
|
+
open_issues_count: 3,
|
|
21
|
+
license: { spdx_id: 'MIT' },
|
|
22
|
+
topics: ['demo', 'capture'],
|
|
23
|
+
},
|
|
24
|
+
tree: [
|
|
25
|
+
{
|
|
26
|
+
name: 'src',
|
|
27
|
+
path: 'src',
|
|
28
|
+
type: 'dir',
|
|
29
|
+
html_url: 'https://github.com/octocat/Hello-World/tree/master/src',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'README.md',
|
|
33
|
+
path: 'README.md',
|
|
34
|
+
type: 'file',
|
|
35
|
+
size: 37,
|
|
36
|
+
html_url: 'https://github.com/octocat/Hello-World/blob/master/README.md',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
readme: {
|
|
40
|
+
path: 'README.md',
|
|
41
|
+
content: '# Hello World\n\nThis is the README.',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe('GitHub repository URLs', () => {
|
|
46
|
+
it('detects plain GitHub repository pages', () => {
|
|
47
|
+
expect(
|
|
48
|
+
parseGithubRepositoryUrl('https://github.com/octocat/Hello-World')
|
|
49
|
+
).toEqual({
|
|
50
|
+
owner: 'octocat',
|
|
51
|
+
repo: 'Hello-World',
|
|
52
|
+
fullName: 'octocat/Hello-World',
|
|
53
|
+
htmlUrl: 'https://github.com/octocat/Hello-World',
|
|
54
|
+
});
|
|
55
|
+
expect(
|
|
56
|
+
isGithubRepositoryUrl('https://github.com/octocat/Hello-World')
|
|
57
|
+
).toBe(true);
|
|
58
|
+
expect(
|
|
59
|
+
isGithubRepositoryUrl('https://github.com/octocat/Hello-World/issues')
|
|
60
|
+
).toBe(false);
|
|
61
|
+
expect(
|
|
62
|
+
isGithubRepositoryUrl('https://example.com/octocat/Hello-World')
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('derives repository text filenames', () => {
|
|
67
|
+
expect(
|
|
68
|
+
getGithubRepositoryTextFilename('https://github.com/octocat/Hello-World')
|
|
69
|
+
).toBe('octocat-Hello-World.txt');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('GitHub repository snapshot formatting', () => {
|
|
74
|
+
it('formats a compact markdown snapshot with metadata, file tree, and README', () => {
|
|
75
|
+
const markdown = formatGithubRepositoryMarkdown(SNAPSHOT);
|
|
76
|
+
|
|
77
|
+
expect(markdown).toContain('# octocat/Hello-World');
|
|
78
|
+
expect(markdown).toContain('> A friendly test repository');
|
|
79
|
+
expect(markdown).toContain('- Default branch: `master`');
|
|
80
|
+
expect(markdown).toContain('- [src/](');
|
|
81
|
+
expect(markdown).toContain('- [README.md](');
|
|
82
|
+
expect(markdown).toContain('## README.md');
|
|
83
|
+
expect(markdown).toContain('# Hello World');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('formats a compact plain-text snapshot with metadata, file tree, and README', () => {
|
|
87
|
+
const text = formatGithubRepositoryText(SNAPSHOT);
|
|
88
|
+
|
|
89
|
+
expect(text).toContain('Repository: octocat/Hello-World');
|
|
90
|
+
expect(text).toContain('Description: A friendly test repository');
|
|
91
|
+
expect(text).toContain('Files:');
|
|
92
|
+
expect(text).toContain('- src/');
|
|
93
|
+
expect(text).toContain('- README.md');
|
|
94
|
+
expect(text).toContain('README.md:');
|
|
95
|
+
expect(text).toContain('This is the README.');
|
|
96
|
+
});
|
|
97
|
+
});
|