@link-assistant/web-capture 1.10.2 → 1.10.4
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 +15 -0
- package/bin/web-capture.js +42 -19
- package/package.json +1 -1
- package/src/github.js +285 -0
- package/src/html.js +11 -1
- package/src/image.js +6 -1
- package/src/lib.js +80 -1
- package/src/markdown.js +20 -0
- package/src/txt.js +38 -8
- package/tests/integration/api-endpoints.test.js +104 -0
- package/tests/integration/github-readme.test.js +137 -0
- package/tests/integration/stackoverflow-download.test.js +173 -0
- package/tests/unit/github.test.js +97 -0
- package/tests/unit/stackoverflow.test.js +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.10.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Add live StackOverflow download coverage across JavaScript output formats and browser engines.
|
|
8
|
+
|
|
9
|
+
## 1.10.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Add compact GitHub repository capture for txt and markdown output, including repository metadata, the root file tree, and README content.
|
|
14
|
+
|
|
3
15
|
## 1.10.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
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/html.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
fetchHtml,
|
|
3
|
+
convertToUtf8,
|
|
4
|
+
convertRelativeUrls,
|
|
5
|
+
isStackOverflowQuestionUrl,
|
|
6
|
+
} from './lib.js';
|
|
2
7
|
import { createBrowser, getBrowserEngine } from './browser.js';
|
|
3
8
|
|
|
4
9
|
export async function htmlHandler(req, res) {
|
|
@@ -12,6 +17,11 @@ export async function htmlHandler(req, res) {
|
|
|
12
17
|
|
|
13
18
|
// First try to fetch HTML directly
|
|
14
19
|
const html = await fetchHtml(absoluteUrl);
|
|
20
|
+
if (isStackOverflowQuestionUrl(absoluteUrl)) {
|
|
21
|
+
const utf8Html = convertToUtf8(html);
|
|
22
|
+
const absoluteHtml = convertRelativeUrls(utf8Html, absoluteUrl);
|
|
23
|
+
return res.type('text/html; charset=utf-8').send(absoluteHtml);
|
|
24
|
+
}
|
|
15
25
|
|
|
16
26
|
// Check if it's valid HTML and contains JavaScript
|
|
17
27
|
const hasJavaScript =
|
package/src/image.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createBrowser, getBrowserEngine } from './browser.js';
|
|
2
|
+
import { isStackOverflowQuestionUrl } from './lib.js';
|
|
2
3
|
import { dismissPopups, scrollToLoadContent } from './popups.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -39,6 +40,10 @@ export async function imageHandler(req, res) {
|
|
|
39
40
|
try {
|
|
40
41
|
const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
41
42
|
const engine = getBrowserEngine(req);
|
|
43
|
+
const waitUntil =
|
|
44
|
+
engine === 'playwright' && isStackOverflowQuestionUrl(absoluteUrl)
|
|
45
|
+
? 'load'
|
|
46
|
+
: 'networkidle0';
|
|
42
47
|
const browserOpts = theme ? { colorScheme: theme } : {};
|
|
43
48
|
const browser = await createBrowser(engine, browserOpts);
|
|
44
49
|
try {
|
|
@@ -52,7 +57,7 @@ export async function imageHandler(req, res) {
|
|
|
52
57
|
);
|
|
53
58
|
await page.setViewport({ width, height });
|
|
54
59
|
await page.goto(absoluteUrl, {
|
|
55
|
-
waitUntil
|
|
60
|
+
waitUntil,
|
|
56
61
|
timeout: 30000,
|
|
57
62
|
});
|
|
58
63
|
|
package/src/lib.js
CHANGED
|
@@ -4,27 +4,87 @@ import fetch from 'node-fetch';
|
|
|
4
4
|
import * as cheerio from 'cheerio';
|
|
5
5
|
import TurndownService from 'turndown';
|
|
6
6
|
import iconv from 'iconv-lite';
|
|
7
|
-
import { URL } from 'url';
|
|
7
|
+
import { URL, URLSearchParams } from 'url';
|
|
8
8
|
import turndownPluginGfm from 'turndown-plugin-gfm';
|
|
9
9
|
import he from 'he';
|
|
10
10
|
import { isFormulaImage, isMathElement, extractFormula } from './latex.js';
|
|
11
11
|
import { extractMetadata } from './metadata.js';
|
|
12
12
|
import { postProcessMarkdown } from './postprocess.js';
|
|
13
|
+
import { retry } from './retry.js';
|
|
14
|
+
|
|
15
|
+
const STACKPRINTER_RETRIES = 3;
|
|
16
|
+
const STACKPRINTER_RETRY_BASE_DELAY_MS = 1000;
|
|
13
17
|
|
|
14
18
|
export async function fetchHtml(url) {
|
|
15
19
|
if (!url) {
|
|
16
20
|
throw new Error('Missing URL parameter');
|
|
17
21
|
}
|
|
22
|
+
const stackPrinterUrl = stackPrinterUrlForQuestion(url);
|
|
23
|
+
if (stackPrinterUrl) {
|
|
24
|
+
return await fetchStackPrinterHtml(stackPrinterUrl);
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
const response = await fetch(url);
|
|
19
28
|
return response.text();
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
async function fetchStackPrinterHtml(url) {
|
|
32
|
+
return await retry(
|
|
33
|
+
async () => {
|
|
34
|
+
const response = await fetch(url);
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`StackPrinter returned HTTP ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const html = await response.text();
|
|
40
|
+
if (isStackPrinterTransientError(html)) {
|
|
41
|
+
throw new Error('StackPrinter returned a transient error page');
|
|
42
|
+
}
|
|
43
|
+
return html;
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
retries: STACKPRINTER_RETRIES,
|
|
47
|
+
baseDelay: STACKPRINTER_RETRY_BASE_DELAY_MS,
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isStackPrinterTransientError(html) {
|
|
53
|
+
return /Ooooops|Please try again later/i.test(html);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isStackOverflowQuestionUrl(url) {
|
|
57
|
+
return Boolean(getStackOverflowQuestionId(url));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function stackPrinterUrlForQuestion(url) {
|
|
61
|
+
const questionId = getStackOverflowQuestionId(url);
|
|
62
|
+
if (!questionId) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const params = new URLSearchParams({
|
|
67
|
+
question: questionId,
|
|
68
|
+
service: 'stackoverflow',
|
|
69
|
+
language: 'en',
|
|
70
|
+
hideAnswers: 'false',
|
|
71
|
+
showAll: 'true',
|
|
72
|
+
width: '640',
|
|
73
|
+
});
|
|
74
|
+
return `https://stackprinter.appspot.com/export?${params.toString()}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
22
77
|
export function isTextPasteUrl(url) {
|
|
23
78
|
const parsed = parseTextPasteUrl(url);
|
|
24
79
|
return Boolean(parsed);
|
|
25
80
|
}
|
|
26
81
|
|
|
27
82
|
export function normalizeUrlForTextContent(url) {
|
|
83
|
+
const stackPrinterUrl = stackPrinterUrlForQuestion(url);
|
|
84
|
+
if (stackPrinterUrl) {
|
|
85
|
+
return stackPrinterUrl;
|
|
86
|
+
}
|
|
87
|
+
|
|
28
88
|
const parsed = parseTextPasteUrl(url);
|
|
29
89
|
if (!parsed) {
|
|
30
90
|
return url;
|
|
@@ -49,6 +109,25 @@ export function getTextPasteFilename(url) {
|
|
|
49
109
|
return pasteId ? `xpaste-pro-${pasteId}.txt` : null;
|
|
50
110
|
}
|
|
51
111
|
|
|
112
|
+
function getStackOverflowQuestionId(url) {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = new URL(url);
|
|
115
|
+
const hostname = parsed.hostname.replace(/^www\./, '');
|
|
116
|
+
if (hostname !== 'stackoverflow.com') {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const [, prefix, questionId] = parsed.pathname.split('/');
|
|
121
|
+
if (prefix !== 'questions' || !/^\d+$/.test(questionId || '')) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return questionId;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
52
131
|
export function appendTextPasteMarkdownAttachment(markdown, url, rawText) {
|
|
53
132
|
const textFilename = getTextPasteFilename(url) || 'download.txt';
|
|
54
133
|
const normalizedRawText = normalizeMarkdownAttachmentText(rawText);
|
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,17 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
fetchHtml,
|
|
5
|
+
getTextPasteFilename,
|
|
6
|
+
isStackOverflowQuestionUrl,
|
|
7
|
+
normalizeUrlForTextContent,
|
|
8
|
+
} from './lib.js';
|
|
9
|
+
import {
|
|
10
|
+
fetchGithubRepositorySnapshot,
|
|
11
|
+
formatGithubRepositoryText,
|
|
12
|
+
getGithubRepositoryTextFilename,
|
|
13
|
+
isGithubRepositoryUrl,
|
|
14
|
+
} from './github.js';
|
|
4
15
|
|
|
5
16
|
export async function txtHandler(req, res) {
|
|
6
17
|
const url = req.query.url;
|
|
@@ -9,6 +20,17 @@ export async function txtHandler(req, res) {
|
|
|
9
20
|
}
|
|
10
21
|
|
|
11
22
|
try {
|
|
23
|
+
if (isGithubRepositoryUrl(url)) {
|
|
24
|
+
const snapshot = await fetchGithubRepositorySnapshot(url);
|
|
25
|
+
const text = formatGithubRepositoryText(snapshot);
|
|
26
|
+
return sendTextResponse(res, url, text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (isStackOverflowQuestionUrl(url)) {
|
|
30
|
+
const text = await fetchHtml(url);
|
|
31
|
+
return sendTextResponse(res, url, text);
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
// Normalize URL to get text content (e.g., xpaste.pro -> xpaste.pro/raw)
|
|
13
35
|
const textUrl = normalizeUrlForTextContent(url);
|
|
14
36
|
|
|
@@ -27,20 +49,28 @@ export async function txtHandler(req, res) {
|
|
|
27
49
|
|
|
28
50
|
const text = await response.text();
|
|
29
51
|
|
|
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);
|
|
52
|
+
sendTextResponse(res, url, text);
|
|
37
53
|
} catch (err) {
|
|
38
54
|
console.error('Text fetch error:', err);
|
|
39
55
|
res.status(500).send('Error fetching text content');
|
|
40
56
|
}
|
|
41
57
|
}
|
|
42
58
|
|
|
59
|
+
function sendTextResponse(res, url, text) {
|
|
60
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
61
|
+
res.setHeader(
|
|
62
|
+
'Content-Disposition',
|
|
63
|
+
`attachment; filename="${getFilenameFromUrl(url)}"`
|
|
64
|
+
);
|
|
65
|
+
res.send(text);
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
function getFilenameFromUrl(url) {
|
|
69
|
+
const githubRepositoryFilename = getGithubRepositoryTextFilename(url);
|
|
70
|
+
if (githubRepositoryFilename) {
|
|
71
|
+
return githubRepositoryFilename;
|
|
72
|
+
}
|
|
73
|
+
|
|
44
74
|
const textPasteFilename = getTextPasteFilename(url);
|
|
45
75
|
if (textPasteFilename) {
|
|
46
76
|
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,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for downloading the StackOverflow page from issue #11.
|
|
3
|
+
*
|
|
4
|
+
* These tests hit the live StackOverflow page:
|
|
5
|
+
* https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git
|
|
6
|
+
*
|
|
7
|
+
* Set STACKOVERFLOW_INTEGRATION=true to run them. The suite is skipped by
|
|
8
|
+
* default so offline/local test runs stay deterministic.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { jest } from '@jest/globals';
|
|
12
|
+
import request from 'supertest';
|
|
13
|
+
import unzipper from 'unzipper';
|
|
14
|
+
import { app } from '../../src/index.js';
|
|
15
|
+
import { retry } from '../../src/retry.js';
|
|
16
|
+
|
|
17
|
+
const STACKOVERFLOW_URL =
|
|
18
|
+
'https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git';
|
|
19
|
+
const STACKOVERFLOW_TITLE =
|
|
20
|
+
'How do I undo the most recent local commits in Git';
|
|
21
|
+
|
|
22
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
23
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
24
|
+
]);
|
|
25
|
+
const ZIP_SIGNATURE = Buffer.from([0x50, 0x4b]);
|
|
26
|
+
const PDF_SIGNATURE = Buffer.from([0x25, 0x50, 0x44, 0x46]);
|
|
27
|
+
|
|
28
|
+
const SKIP_LIVE =
|
|
29
|
+
!process.env.STACKOVERFLOW_INTEGRATION ||
|
|
30
|
+
process.env.STACKOVERFLOW_INTEGRATION === 'false';
|
|
31
|
+
const describeIfLive = SKIP_LIVE ? describe.skip : describe;
|
|
32
|
+
|
|
33
|
+
jest.setTimeout(180000);
|
|
34
|
+
|
|
35
|
+
function parseBinary(res, callback) {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
38
|
+
res.on('end', () => callback(null, Buffer.concat(chunks)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getText(path, query = {}) {
|
|
42
|
+
return await retry(
|
|
43
|
+
() =>
|
|
44
|
+
request(app)
|
|
45
|
+
.get(path)
|
|
46
|
+
.query({ url: STACKOVERFLOW_URL, ...query })
|
|
47
|
+
.expect(200),
|
|
48
|
+
{
|
|
49
|
+
retries: 2,
|
|
50
|
+
baseDelay: 2000,
|
|
51
|
+
onRetry: (err, attempt, delay) => {
|
|
52
|
+
console.log(
|
|
53
|
+
`Retry ${attempt} for ${path} after ${delay}ms: ${err.message}`
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getBinary(path, query = {}) {
|
|
61
|
+
return await retry(
|
|
62
|
+
() =>
|
|
63
|
+
request(app)
|
|
64
|
+
.get(path)
|
|
65
|
+
.query({ url: STACKOVERFLOW_URL, ...query })
|
|
66
|
+
.buffer(true)
|
|
67
|
+
.parse(parseBinary)
|
|
68
|
+
.expect(200),
|
|
69
|
+
{
|
|
70
|
+
retries: 2,
|
|
71
|
+
baseDelay: 2000,
|
|
72
|
+
onRetry: (err, attempt, delay) => {
|
|
73
|
+
console.log(
|
|
74
|
+
`Retry ${attempt} for ${path} after ${delay}ms: ${err.message}`
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function expectStackOverflowContent(text) {
|
|
82
|
+
expect(text).toContain(STACKOVERFLOW_TITLE);
|
|
83
|
+
expect(text).toMatch(/Stack Overflow/i);
|
|
84
|
+
expect(text).toMatch(/\bgit\b/i);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function expectBufferWithSignature(buffer, signature) {
|
|
88
|
+
expect(Buffer.isBuffer(buffer)).toBe(true);
|
|
89
|
+
expect(buffer.length).toBeGreaterThan(1000);
|
|
90
|
+
expect(buffer.subarray(0, signature.length)).toEqual(signature);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describeIfLive('StackOverflow page downloads (JavaScript)', () => {
|
|
94
|
+
it('downloads the page as Markdown and plain text', async () => {
|
|
95
|
+
const markdown = await getText('/markdown');
|
|
96
|
+
expect(markdown.headers['content-type']).toMatch(/text\/markdown/);
|
|
97
|
+
expectStackOverflowContent(markdown.text);
|
|
98
|
+
expect(markdown.text).not.toMatch(/<html/i);
|
|
99
|
+
|
|
100
|
+
const text = await getText('/txt');
|
|
101
|
+
expect(text.headers['content-type']).toMatch(/text\/plain/);
|
|
102
|
+
expect(text.headers['content-disposition']).toContain('stackoverflow-com');
|
|
103
|
+
expectStackOverflowContent(text.text);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('downloads Markdown and HTML archives with the expected files', async () => {
|
|
107
|
+
const markdownArchive = await getBinary('/archive', {
|
|
108
|
+
documentFormat: 'markdown',
|
|
109
|
+
localImages: 'false',
|
|
110
|
+
});
|
|
111
|
+
expect(markdownArchive.headers['content-type']).toMatch(/application\/zip/);
|
|
112
|
+
expectBufferWithSignature(markdownArchive.body, ZIP_SIGNATURE);
|
|
113
|
+
|
|
114
|
+
const markdownZip = await unzipper.Open.buffer(markdownArchive.body);
|
|
115
|
+
const markdownEntries = new Map(
|
|
116
|
+
markdownZip.files.map((file) => [file.path, file])
|
|
117
|
+
);
|
|
118
|
+
expect(markdownEntries.has('document.md')).toBe(true);
|
|
119
|
+
expect(markdownEntries.has('document.html')).toBe(true);
|
|
120
|
+
expectStackOverflowContent(
|
|
121
|
+
(await markdownEntries.get('document.md').buffer()).toString('utf8')
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const htmlArchive = await getBinary('/archive', {
|
|
125
|
+
documentFormat: 'html',
|
|
126
|
+
localImages: 'false',
|
|
127
|
+
});
|
|
128
|
+
expect(htmlArchive.headers['content-type']).toMatch(/application\/zip/);
|
|
129
|
+
expectBufferWithSignature(htmlArchive.body, ZIP_SIGNATURE);
|
|
130
|
+
|
|
131
|
+
const htmlZip = await unzipper.Open.buffer(htmlArchive.body);
|
|
132
|
+
const htmlEntries = new Map(htmlZip.files.map((file) => [file.path, file]));
|
|
133
|
+
expect(htmlEntries.has('document.html')).toBe(true);
|
|
134
|
+
expectStackOverflowContent(
|
|
135
|
+
(await htmlEntries.get('document.html').buffer()).toString('utf8')
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('downloads the page as DOCX and PDF', async () => {
|
|
140
|
+
const docx = await getBinary('/docx');
|
|
141
|
+
expect(docx.headers['content-type']).toMatch(
|
|
142
|
+
/application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document/
|
|
143
|
+
);
|
|
144
|
+
expectBufferWithSignature(docx.body, ZIP_SIGNATURE);
|
|
145
|
+
|
|
146
|
+
const pdf = await getBinary('/pdf');
|
|
147
|
+
expect(pdf.headers['content-type']).toMatch(/application\/pdf/);
|
|
148
|
+
expectBufferWithSignature(pdf.body, PDF_SIGNATURE);
|
|
149
|
+
}, 180000);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe.each(['puppeteer', 'playwright'])(
|
|
153
|
+
'StackOverflow browser downloads (%s engine)',
|
|
154
|
+
(engine) => {
|
|
155
|
+
describeIfLive(`${engine}`, () => {
|
|
156
|
+
it('downloads rendered HTML for the page', async () => {
|
|
157
|
+
const html = await getText('/html', { engine });
|
|
158
|
+
expect(html.headers['content-type']).toMatch(/text\/html/);
|
|
159
|
+
expectStackOverflowContent(html.text);
|
|
160
|
+
expect(html.text).toMatch(/<html/i);
|
|
161
|
+
}, 120000);
|
|
162
|
+
|
|
163
|
+
it('downloads a PNG screenshot for the page', async () => {
|
|
164
|
+
const image = await getBinary('/image', {
|
|
165
|
+
engine,
|
|
166
|
+
dismissPopups: 'true',
|
|
167
|
+
});
|
|
168
|
+
expect(image.headers['content-type']).toMatch(/image\/png/);
|
|
169
|
+
expectBufferWithSignature(image.body, PNG_SIGNATURE);
|
|
170
|
+
}, 120000);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
);
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isStackOverflowQuestionUrl,
|
|
3
|
+
stackPrinterUrlForQuestion,
|
|
4
|
+
} from '../../src/lib.js';
|
|
5
|
+
|
|
6
|
+
describe('Stack Overflow URL helpers', () => {
|
|
7
|
+
it('recognizes Stack Overflow question URLs', () => {
|
|
8
|
+
expect(
|
|
9
|
+
isStackOverflowQuestionUrl(
|
|
10
|
+
'https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git'
|
|
11
|
+
)
|
|
12
|
+
).toBe(true);
|
|
13
|
+
expect(
|
|
14
|
+
isStackOverflowQuestionUrl(
|
|
15
|
+
'https://stackoverflow.com/questions/tagged/git'
|
|
16
|
+
)
|
|
17
|
+
).toBe(false);
|
|
18
|
+
expect(
|
|
19
|
+
isStackOverflowQuestionUrl('https://serverfault.com/questions/927358')
|
|
20
|
+
).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('builds a StackPrinter URL for direct captures', () => {
|
|
24
|
+
const stackPrinterUrl = stackPrinterUrlForQuestion(
|
|
25
|
+
'https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(stackPrinterUrl).toBe(
|
|
29
|
+
'https://stackprinter.appspot.com/export?question=927358&service=stackoverflow&language=en&hideAnswers=false&showAll=true&width=640'
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|