@link-assistant/web-capture 1.10.3 → 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 +6 -0
- package/package.json +1 -1
- package/src/html.js +11 -1
- package/src/image.js +6 -1
- package/src/lib.js +80 -1
- package/src/txt.js +11 -1
- package/tests/integration/stackoverflow-download.test.js +173 -0
- package/tests/unit/stackoverflow.test.js +32 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
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/txt.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
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';
|
|
4
9
|
import {
|
|
5
10
|
fetchGithubRepositorySnapshot,
|
|
6
11
|
formatGithubRepositoryText,
|
|
@@ -21,6 +26,11 @@ export async function txtHandler(req, res) {
|
|
|
21
26
|
return sendTextResponse(res, url, text);
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
if (isStackOverflowQuestionUrl(url)) {
|
|
30
|
+
const text = await fetchHtml(url);
|
|
31
|
+
return sendTextResponse(res, url, text);
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
// Normalize URL to get text content (e.g., xpaste.pro -> xpaste.pro/raw)
|
|
25
35
|
const textUrl = normalizeUrlForTextContent(url);
|
|
26
36
|
|
|
@@ -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,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
|
+
});
|