@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/src/lib.js
CHANGED
|
@@ -19,6 +19,95 @@ export async function fetchHtml(url) {
|
|
|
19
19
|
return response.text();
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export function isTextPasteUrl(url) {
|
|
23
|
+
const parsed = parseTextPasteUrl(url);
|
|
24
|
+
return Boolean(parsed);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeUrlForTextContent(url) {
|
|
28
|
+
const parsed = parseTextPasteUrl(url);
|
|
29
|
+
if (!parsed) {
|
|
30
|
+
return url;
|
|
31
|
+
}
|
|
32
|
+
return `https://${parsed.host}${parsed.pathPrefix}/p/${parsed.pasteId}/raw`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeUrlForTextPage(url) {
|
|
36
|
+
const parsed = parseTextPasteUrl(url);
|
|
37
|
+
if (!parsed) {
|
|
38
|
+
return url;
|
|
39
|
+
}
|
|
40
|
+
return `https://${parsed.host}${parsed.pathPrefix}/p/${parsed.pasteId}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getTextPasteId(url) {
|
|
44
|
+
return parseTextPasteUrl(url)?.pasteId || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getTextPasteFilename(url) {
|
|
48
|
+
const pasteId = getTextPasteId(url);
|
|
49
|
+
return pasteId ? `xpaste-pro-${pasteId}.txt` : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function appendTextPasteMarkdownAttachment(markdown, url, rawText) {
|
|
53
|
+
const textFilename = getTextPasteFilename(url) || 'download.txt';
|
|
54
|
+
const normalizedRawText = normalizeMarkdownAttachmentText(rawText);
|
|
55
|
+
const fence = markdownFenceFor(normalizedRawText);
|
|
56
|
+
const attachmentText = normalizedRawText.endsWith('\n')
|
|
57
|
+
? normalizedRawText
|
|
58
|
+
: `${normalizedRawText}\n`;
|
|
59
|
+
|
|
60
|
+
return [
|
|
61
|
+
markdown.trimEnd(),
|
|
62
|
+
'',
|
|
63
|
+
`## ${textFilename}`,
|
|
64
|
+
'',
|
|
65
|
+
`${fence}text`,
|
|
66
|
+
`${attachmentText}${fence}`,
|
|
67
|
+
'',
|
|
68
|
+
].join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeMarkdownAttachmentText(text) {
|
|
72
|
+
return String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function markdownFenceFor(text) {
|
|
76
|
+
const runs = text.match(/`{3,}/g) || [];
|
|
77
|
+
const length = runs.reduce((max, run) => Math.max(max, run.length + 1), 3);
|
|
78
|
+
return '`'.repeat(length);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseTextPasteUrl(url) {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = new URL(url);
|
|
84
|
+
const host = parsed.hostname.toLowerCase();
|
|
85
|
+
if (host !== 'xpaste.pro' && host !== 'www.xpaste.pro') {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
90
|
+
const index = parts.indexOf('p');
|
|
91
|
+
let pathPrefix = '';
|
|
92
|
+
if (index === -1 || !parts[index + 1]) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (index === 1 && ['en', 'ru'].includes(parts[0])) {
|
|
96
|
+
pathPrefix = `/${parts[0]}`;
|
|
97
|
+
} else if (index !== 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const tail = parts.slice(index + 2);
|
|
101
|
+
if (tail.length > 1 || (tail[0] && tail[0] !== 'raw')) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { host: 'xpaste.pro', pathPrefix, pasteId: parts[index + 1] };
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
22
111
|
export function convertHtmlToMarkdown(html, baseUrl) {
|
|
23
112
|
// Ensure all URLs are absolute before Markdown conversion
|
|
24
113
|
if (baseUrl) {
|
|
@@ -27,6 +116,8 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
27
116
|
// Load HTML into Cheerio
|
|
28
117
|
const $ = cheerio.load(html);
|
|
29
118
|
|
|
119
|
+
reorderVisualLayoutElements($);
|
|
120
|
+
|
|
30
121
|
// Remove <style>, <script>, and <noscript> tags
|
|
31
122
|
$('style, script, noscript').remove();
|
|
32
123
|
|
|
@@ -199,6 +290,20 @@ function coalesceBrRunsToParagraphBreak(markdown) {
|
|
|
199
290
|
return markdown.replace(/(?: {2,}\n){2,}/g, '\n\n');
|
|
200
291
|
}
|
|
201
292
|
|
|
293
|
+
function reorderVisualLayoutElements($) {
|
|
294
|
+
const $header = $('header').first();
|
|
295
|
+
const $main = $('main').first();
|
|
296
|
+
const $footer = $('footer').first();
|
|
297
|
+
|
|
298
|
+
if ($header.length && $main.length && $header.index() > $main.index()) {
|
|
299
|
+
$main.before($header);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if ($footer.length && $main.length && $footer.index() < $main.index()) {
|
|
303
|
+
$main.after($footer);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
202
307
|
// Lift <br> nodes out of inline parents when they sit at the leading or
|
|
203
308
|
// trailing edge.
|
|
204
309
|
//
|
|
@@ -598,6 +703,8 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
598
703
|
html = scopeHtmlForMarkdown(html, { contentSelector, bodySelector });
|
|
599
704
|
$ = cheerio.load(html);
|
|
600
705
|
|
|
706
|
+
reorderVisualLayoutElements($);
|
|
707
|
+
|
|
601
708
|
// Remove unwanted elements
|
|
602
709
|
$('style, script, noscript').remove();
|
|
603
710
|
$('*').each(function () {
|
package/src/markdown.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
|
+
appendTextPasteMarkdownAttachment,
|
|
2
3
|
fetchHtml,
|
|
3
4
|
convertHtmlToMarkdownEnhanced,
|
|
5
|
+
getTextPasteId,
|
|
6
|
+
isTextPasteUrl,
|
|
7
|
+
normalizeUrlForTextContent,
|
|
8
|
+
normalizeUrlForTextPage,
|
|
4
9
|
scopeHtmlForMarkdown,
|
|
5
10
|
} from './lib.js';
|
|
11
|
+
import {
|
|
12
|
+
fetchGithubRepositorySnapshot,
|
|
13
|
+
formatGithubRepositoryMarkdown,
|
|
14
|
+
isGithubRepositoryUrl,
|
|
15
|
+
} from './github.js';
|
|
6
16
|
import { convertWithKreuzberg, isKreuzbergAvailable } from './kreuzberg.js';
|
|
7
17
|
import { applyImageMode } from './extract-images.js';
|
|
18
|
+
import archiver from 'archiver';
|
|
19
|
+
import fetch from 'node-fetch';
|
|
20
|
+
|
|
21
|
+
const INLINE_MARKDOWN_LINE_LIMIT = 1500;
|
|
8
22
|
|
|
9
23
|
export async function markdownHandler(req, res) {
|
|
10
24
|
const url = req.query.url;
|
|
@@ -29,7 +43,23 @@ export async function markdownHandler(req, res) {
|
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
try {
|
|
32
|
-
const
|
|
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
|
+
|
|
62
|
+
const html = await fetchHtml(pageUrl);
|
|
33
63
|
|
|
34
64
|
if (converter === 'kreuzberg') {
|
|
35
65
|
const available = await isKreuzbergAvailable();
|
|
@@ -45,7 +75,9 @@ export async function markdownHandler(req, res) {
|
|
|
45
75
|
contentSelector: req.query.contentSelector,
|
|
46
76
|
bodySelector: req.query.bodySelector,
|
|
47
77
|
});
|
|
48
|
-
const result = await convertWithKreuzberg(scopedHtml, {
|
|
78
|
+
const result = await convertWithKreuzberg(scopedHtml, {
|
|
79
|
+
baseUrl: pageUrl,
|
|
80
|
+
});
|
|
49
81
|
const imageResult = await applyImageMode(result.content, {
|
|
50
82
|
mode: embedImages ? 'embed' : 'default',
|
|
51
83
|
});
|
|
@@ -54,10 +86,10 @@ export async function markdownHandler(req, res) {
|
|
|
54
86
|
if (format === 'json') {
|
|
55
87
|
return res.json(result);
|
|
56
88
|
}
|
|
57
|
-
return res
|
|
89
|
+
return await sendMarkdownResponse(res, url, result.content);
|
|
58
90
|
}
|
|
59
91
|
|
|
60
|
-
|
|
92
|
+
let { markdown } = convertHtmlToMarkdownEnhanced(html, pageUrl, {
|
|
61
93
|
contentSelector: req.query.contentSelector,
|
|
62
94
|
bodySelector: req.query.bodySelector,
|
|
63
95
|
});
|
|
@@ -67,9 +99,64 @@ export async function markdownHandler(req, res) {
|
|
|
67
99
|
const result = await applyImageMode(markdown, {
|
|
68
100
|
mode: embedImages ? 'embed' : 'default',
|
|
69
101
|
});
|
|
70
|
-
|
|
102
|
+
markdown = result.markdown;
|
|
103
|
+
return await sendMarkdownResponse(res, url, markdown);
|
|
71
104
|
} catch (err) {
|
|
72
105
|
console.error(err);
|
|
73
106
|
res.status(500).send('Error converting to Markdown');
|
|
74
107
|
}
|
|
75
108
|
}
|
|
109
|
+
|
|
110
|
+
async function sendMarkdownResponse(res, url, markdown) {
|
|
111
|
+
if (!isTextPasteUrl(url)) {
|
|
112
|
+
return res.type('text/markdown').send(markdown);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const rawText = await fetchRawText(url);
|
|
116
|
+
const markdownWithText = appendTextPasteMarkdownAttachment(
|
|
117
|
+
markdown,
|
|
118
|
+
url,
|
|
119
|
+
rawText
|
|
120
|
+
);
|
|
121
|
+
if (markdownWithText.split('\n').length >= INLINE_MARKDOWN_LINE_LIMIT) {
|
|
122
|
+
return await sendTextPasteMarkdownArchive(res, url, markdown, rawText);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return res.type('text/markdown').send(markdownWithText);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function sendTextPasteMarkdownArchive(res, url, markdown, rawText) {
|
|
129
|
+
const pasteId = getTextPasteId(url) || 'paste';
|
|
130
|
+
const markdownFilename = `xpaste-pro-${pasteId}.md`;
|
|
131
|
+
const textFilename = `xpaste-pro-${pasteId}.txt`;
|
|
132
|
+
const lineCount = markdown.split('\n').length;
|
|
133
|
+
|
|
134
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
135
|
+
res.setHeader('Content-Disposition', `attachment; filename="${pasteId}.zip"`);
|
|
136
|
+
|
|
137
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
138
|
+
archive.pipe(res);
|
|
139
|
+
archive.append(
|
|
140
|
+
[
|
|
141
|
+
`# ${url}`,
|
|
142
|
+
'',
|
|
143
|
+
`Content from: ${url}`,
|
|
144
|
+
'',
|
|
145
|
+
`The page markdown is available in [${markdownFilename}](${markdownFilename}) (${lineCount} lines).`,
|
|
146
|
+
`The raw text content is available in [${textFilename}](${textFilename}).`,
|
|
147
|
+
'',
|
|
148
|
+
].join('\n'),
|
|
149
|
+
{ name: 'index.md' }
|
|
150
|
+
);
|
|
151
|
+
archive.append(markdown, { name: markdownFilename });
|
|
152
|
+
archive.append(rawText, { name: textFilename });
|
|
153
|
+
await archive.finalize();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function fetchRawText(url) {
|
|
157
|
+
const response = await fetch(normalizeUrlForTextContent(url));
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
160
|
+
}
|
|
161
|
+
return response.text();
|
|
162
|
+
}
|
package/src/txt.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { getTextPasteFilename, normalizeUrlForTextContent } from './lib.js';
|
|
4
|
+
import {
|
|
5
|
+
fetchGithubRepositorySnapshot,
|
|
6
|
+
formatGithubRepositoryText,
|
|
7
|
+
getGithubRepositoryTextFilename,
|
|
8
|
+
isGithubRepositoryUrl,
|
|
9
|
+
} from './github.js';
|
|
10
|
+
|
|
11
|
+
export async function txtHandler(req, res) {
|
|
12
|
+
const url = req.query.url;
|
|
13
|
+
if (!url) {
|
|
14
|
+
return res.status(400).send('Missing `url` parameter');
|
|
15
|
+
}
|
|
16
|
+
|
|
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
|
+
|
|
24
|
+
// Normalize URL to get text content (e.g., xpaste.pro -> xpaste.pro/raw)
|
|
25
|
+
const textUrl = normalizeUrlForTextContent(url);
|
|
26
|
+
|
|
27
|
+
const response = await fetch(textUrl);
|
|
28
|
+
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const contentType = response.headers.get('content-type') || 'text/plain';
|
|
34
|
+
|
|
35
|
+
// Only accept text content types
|
|
36
|
+
if (!contentType.includes('text/')) {
|
|
37
|
+
throw new Error(`Expected text content, got ${contentType}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const text = await response.text();
|
|
41
|
+
|
|
42
|
+
sendTextResponse(res, url, text);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('Text fetch error:', err);
|
|
45
|
+
res.status(500).send('Error fetching text content');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
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
|
+
|
|
58
|
+
function getFilenameFromUrl(url) {
|
|
59
|
+
const githubRepositoryFilename = getGithubRepositoryTextFilename(url);
|
|
60
|
+
if (githubRepositoryFilename) {
|
|
61
|
+
return githubRepositoryFilename;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const textPasteFilename = getTextPasteFilename(url);
|
|
65
|
+
if (textPasteFilename) {
|
|
66
|
+
return textPasteFilename;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const urlObj = new URL(url);
|
|
71
|
+
const hostname = urlObj.hostname.replace(/\./g, '-');
|
|
72
|
+
const path = urlObj.pathname
|
|
73
|
+
.replace(/\//g, '-')
|
|
74
|
+
.replace(/^-/, '')
|
|
75
|
+
.replace(/-raw$/, '');
|
|
76
|
+
return `${hostname}${path}.txt`;
|
|
77
|
+
} catch {
|
|
78
|
+
return 'download.txt';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -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
|
+
);
|