@link-assistant/web-capture 1.10.1 → 1.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +27 -0
- package/bin/web-capture.js +38 -6
- package/jest.config.mjs +1 -0
- package/package.json +1 -1
- package/src/index.js +2 -0
- package/src/lib.js +107 -0
- package/src/markdown.js +72 -5
- package/src/txt.js +60 -0
- package/tests/xpaste/xpaste-screenshot-markdown.mock.test.js +375 -0
- package/tests/xpaste/xpaste.e2e.test.js +36 -0
- package/tests/xpaste/xpaste.integration.test.js +41 -0
- package/tests/xpaste/xpaste.mock.test.js +261 -0
- package/tests/xpaste/xpaste.unit.test.js +119 -0
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ A CLI and microservice to fetch URLs and render them as:
|
|
|
10
10
|
|
|
11
11
|
- **Markdown**: Converted from HTML with image extraction (default)
|
|
12
12
|
- **HTML**: Rendered page content
|
|
13
|
+
- **Plain text**: Raw text downloads for paste-like URLs such as xpaste.pro
|
|
13
14
|
- **PNG/JPEG screenshot**: Viewport or full-page capture with theme support
|
|
14
15
|
- **ZIP archive**: Markdown + locally downloaded images
|
|
15
16
|
- **PDF**: Print-quality document with embedded images
|
|
@@ -48,6 +49,9 @@ web-capture https://example.com -o -
|
|
|
48
49
|
# Capture as HTML
|
|
49
50
|
web-capture https://example.com --format html -o page.html
|
|
50
51
|
|
|
52
|
+
# Capture raw paste text
|
|
53
|
+
web-capture https://xpaste.pro/p/t4q0Lsp0 --format txt -o paste.txt
|
|
54
|
+
|
|
51
55
|
# Take a PNG screenshot
|
|
52
56
|
web-capture https://example.com --format png -o screenshot.png
|
|
53
57
|
|
|
@@ -141,6 +145,14 @@ GET /markdown?url=<URL>
|
|
|
141
145
|
|
|
142
146
|
Converts the HTML content of the specified URL to Markdown format. By default, original remote image URLs are preserved and base64 data URIs are stripped (clean single-file output). Use `keepOriginalLinks=false` to strip all images, or `embedImages=true` to keep base64 images inline.
|
|
143
147
|
|
|
148
|
+
For xpaste.pro paste URLs, `/markdown` fetches the visual paste page, keeps
|
|
149
|
+
visible header, language, metadata, paste body, and footer text in visual order,
|
|
150
|
+
and appends the raw paste text as a fenced block named `xpaste-pro-<id>.txt`
|
|
151
|
+
when the final Markdown stays under 1500 lines. Larger paste pages return a ZIP
|
|
152
|
+
containing `index.md`, `xpaste-pro-<id>.md`, and `xpaste-pro-<id>.txt`.
|
|
153
|
+
Canonical `/p/<id>`, `/p/<id>/raw`, `/ru/p/<id>`, and `/en/p/<id>` URLs are
|
|
154
|
+
normalized before capture.
|
|
155
|
+
|
|
144
156
|
| Parameter | Required | Description | Default |
|
|
145
157
|
| ------------------- | -------- | ------------------------------------------------------------------------- | -------- |
|
|
146
158
|
| `url` | Yes | URL to fetch | - |
|
|
@@ -149,6 +161,20 @@ Converts the HTML content of the specified URL to Markdown format. By default, o
|
|
|
149
161
|
| `embedImages` | No | Keep base64 images inline (`true`/`false`) | `false` |
|
|
150
162
|
| `keepOriginalLinks` | No | Keep original remote URLs, strip base64 | `true` |
|
|
151
163
|
|
|
164
|
+
### Text Endpoint
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
GET /txt?url=<URL>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Returns raw text content as a `.txt` attachment. xpaste.pro paste URLs are
|
|
171
|
+
normalized to their `/raw` endpoint, including localized `/ru/p/<id>` and
|
|
172
|
+
`/en/p/<id>` URLs.
|
|
173
|
+
|
|
174
|
+
| Parameter | Required | Description | Default |
|
|
175
|
+
| --------- | -------- | ------------ | ------- |
|
|
176
|
+
| `url` | Yes | URL to fetch | - |
|
|
177
|
+
|
|
152
178
|
### Image Endpoint
|
|
153
179
|
|
|
154
180
|
```
|
|
@@ -334,6 +360,7 @@ web-capture <url> [options]
|
|
|
334
360
|
| ----------------- | ---------------------------------------- |
|
|
335
361
|
| `markdown` / `md` | Markdown conversion (default) |
|
|
336
362
|
| `html` | Rendered HTML |
|
|
363
|
+
| `txt` / `text` | Raw text download |
|
|
337
364
|
| `image` / `png` | PNG screenshot (lossless) |
|
|
338
365
|
| `jpeg` | JPEG screenshot (configurable quality) |
|
|
339
366
|
| `pdf` | PDF with embedded images |
|
package/bin/web-capture.js
CHANGED
|
@@ -78,7 +78,7 @@ const config = makeConfig({
|
|
|
78
78
|
alias: 'f',
|
|
79
79
|
type: 'string',
|
|
80
80
|
description:
|
|
81
|
-
'Output format: markdown, md, html, image, png, jpeg, pdf, docx, archive',
|
|
81
|
+
'Output format: markdown, md, html, txt, text, image, png, jpeg, pdf, docx, archive',
|
|
82
82
|
default: 'markdown',
|
|
83
83
|
})
|
|
84
84
|
.option('theme', {
|
|
@@ -289,7 +289,7 @@ const config = makeConfig({
|
|
|
289
289
|
'Capture private Google Doc with API token'
|
|
290
290
|
)
|
|
291
291
|
.epilogue(
|
|
292
|
-
'API Endpoints (in server mode):\n GET /html?url=<URL>&engine=<ENGINE> Get rendered HTML\n GET /markdown?url=<URL> Get Markdown conversion\n GET /image?url=<URL>&format=png|jpeg&theme=light|dark Screenshot\n GET /archive?url=<URL>&localImages=true&documentFormat=markdown|html ZIP archive\n GET /pdf?url=<URL>&theme=light|dark PDF with embedded images\n GET /docx?url=<URL> DOCX with embedded images\n GET /fetch?url=<URL> Proxy fetch\n GET /stream?url=<URL> Streaming proxy'
|
|
292
|
+
'API Endpoints (in server mode):\n GET /html?url=<URL>&engine=<ENGINE> Get rendered HTML\n GET /markdown?url=<URL> Get Markdown conversion\n GET /txt?url=<URL> Get text content\n GET /image?url=<URL>&format=png|jpeg&theme=light|dark Screenshot\n GET /archive?url=<URL>&localImages=true&documentFormat=markdown|html ZIP archive\n GET /pdf?url=<URL>&theme=light|dark PDF with embedded images\n GET /docx?url=<URL> DOCX with embedded images\n GET /fetch?url=<URL> Proxy fetch\n GET /stream?url=<URL> Streaming proxy'
|
|
293
293
|
)
|
|
294
294
|
.strict(),
|
|
295
295
|
lenv: {
|
|
@@ -322,6 +322,7 @@ async function startServer(port) {
|
|
|
322
322
|
console.log('Available endpoints:');
|
|
323
323
|
console.log(` GET /html?url=<URL> - Render page as HTML`);
|
|
324
324
|
console.log(` GET /markdown?url=<URL> - Convert page to Markdown`);
|
|
325
|
+
console.log(` GET /txt?url=<URL> - Fetch text content`);
|
|
325
326
|
console.log(` GET /image?url=<URL> - Screenshot (PNG/JPEG)`);
|
|
326
327
|
console.log(
|
|
327
328
|
` GET /archive?url=<URL> - ZIP archive with markdown + images`
|
|
@@ -598,8 +599,12 @@ async function captureUrl(url, options) {
|
|
|
598
599
|
}
|
|
599
600
|
|
|
600
601
|
// Import required modules
|
|
601
|
-
const {
|
|
602
|
-
|
|
602
|
+
const {
|
|
603
|
+
fetchHtml,
|
|
604
|
+
convertToUtf8,
|
|
605
|
+
convertRelativeUrls,
|
|
606
|
+
normalizeUrlForTextContent,
|
|
607
|
+
} = await import('../src/lib.js');
|
|
603
608
|
const { createBrowser } = await import('../src/browser.js');
|
|
604
609
|
const {
|
|
605
610
|
isGoogleDocsUrl,
|
|
@@ -915,7 +920,28 @@ async function captureUrl(url, options) {
|
|
|
915
920
|
}
|
|
916
921
|
|
|
917
922
|
try {
|
|
918
|
-
if (normalizedFormat === '
|
|
923
|
+
if (normalizedFormat === 'txt' || normalizedFormat === 'text') {
|
|
924
|
+
const response = await fetch(normalizeUrlForTextContent(absoluteUrl));
|
|
925
|
+
if (!response.ok) {
|
|
926
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
927
|
+
}
|
|
928
|
+
const contentType = response.headers.get('content-type') || 'text/plain';
|
|
929
|
+
if (!contentType.includes('text/')) {
|
|
930
|
+
throw new Error(`Expected text content, got ${contentType}`);
|
|
931
|
+
}
|
|
932
|
+
const text = await response.text();
|
|
933
|
+
const output =
|
|
934
|
+
explicitOutput === '-'
|
|
935
|
+
? null
|
|
936
|
+
: explicitOutput || deriveOutputPath(absoluteUrl, 'txt', dataDir);
|
|
937
|
+
if (output) {
|
|
938
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
939
|
+
fs.writeFileSync(output, text, 'utf-8');
|
|
940
|
+
console.error(`Text saved to: ${output}`);
|
|
941
|
+
} else {
|
|
942
|
+
process.stdout.write(text);
|
|
943
|
+
}
|
|
944
|
+
} else if (normalizedFormat === 'jpeg') {
|
|
919
945
|
// JPEG screenshot
|
|
920
946
|
const { createBrowser } = await import('../src/browser.js');
|
|
921
947
|
const { dismissPopups, scrollToLoadContent } =
|
|
@@ -1160,6 +1186,8 @@ async function captureUrl(url, options) {
|
|
|
1160
1186
|
normalizedFormat === 'screenshot'
|
|
1161
1187
|
) {
|
|
1162
1188
|
// Image/screenshot format
|
|
1189
|
+
const { dismissPopups, scrollToLoadContent } =
|
|
1190
|
+
await import('../src/popups.js');
|
|
1163
1191
|
const browser = await createBrowser(engine);
|
|
1164
1192
|
try {
|
|
1165
1193
|
const page = await browser.newPage();
|
|
@@ -1177,8 +1205,12 @@ async function captureUrl(url, options) {
|
|
|
1177
1205
|
});
|
|
1178
1206
|
// Wait for 5 seconds after page load
|
|
1179
1207
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
1208
|
+
if (fullPage) {
|
|
1209
|
+
await scrollToLoadContent(page);
|
|
1210
|
+
}
|
|
1211
|
+
await dismissPopups(page);
|
|
1180
1212
|
|
|
1181
|
-
const buffer = await page.screenshot({ type: 'png' });
|
|
1213
|
+
const buffer = await page.screenshot({ type: 'png', fullPage });
|
|
1182
1214
|
|
|
1183
1215
|
if (explicitOutput) {
|
|
1184
1216
|
fs.writeFileSync(explicitOutput, buffer);
|
package/jest.config.mjs
CHANGED
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { markdownHandler } from './markdown.js';
|
|
|
5
5
|
import { imageHandler } from './image.js';
|
|
6
6
|
import { streamHandler } from './stream.js';
|
|
7
7
|
import { fetchHandler } from './fetch.js';
|
|
8
|
+
import { txtHandler } from './txt.js';
|
|
8
9
|
import { archiveHandler } from './archive.js';
|
|
9
10
|
import { pdfHandler } from './pdf.js';
|
|
10
11
|
import { docxHandler } from './docx.js';
|
|
@@ -22,6 +23,7 @@ app.get('/markdown', markdownHandler);
|
|
|
22
23
|
app.get('/image', imageHandler);
|
|
23
24
|
app.get('/stream', streamHandler);
|
|
24
25
|
app.get('/fetch', fetchHandler);
|
|
26
|
+
app.get('/txt', txtHandler);
|
|
25
27
|
app.get('/archive', archiveHandler);
|
|
26
28
|
app.get('/pdf', pdfHandler);
|
|
27
29
|
app.get('/docx', docxHandler);
|
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,19 @@
|
|
|
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';
|
|
6
11
|
import { convertWithKreuzberg, isKreuzbergAvailable } from './kreuzberg.js';
|
|
7
12
|
import { applyImageMode } from './extract-images.js';
|
|
13
|
+
import archiver from 'archiver';
|
|
14
|
+
import fetch from 'node-fetch';
|
|
15
|
+
|
|
16
|
+
const INLINE_MARKDOWN_LINE_LIMIT = 1500;
|
|
8
17
|
|
|
9
18
|
export async function markdownHandler(req, res) {
|
|
10
19
|
const url = req.query.url;
|
|
@@ -29,7 +38,8 @@ export async function markdownHandler(req, res) {
|
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
try {
|
|
32
|
-
const
|
|
41
|
+
const pageUrl = normalizeUrlForTextPage(url);
|
|
42
|
+
const html = await fetchHtml(pageUrl);
|
|
33
43
|
|
|
34
44
|
if (converter === 'kreuzberg') {
|
|
35
45
|
const available = await isKreuzbergAvailable();
|
|
@@ -45,7 +55,9 @@ export async function markdownHandler(req, res) {
|
|
|
45
55
|
contentSelector: req.query.contentSelector,
|
|
46
56
|
bodySelector: req.query.bodySelector,
|
|
47
57
|
});
|
|
48
|
-
const result = await convertWithKreuzberg(scopedHtml, {
|
|
58
|
+
const result = await convertWithKreuzberg(scopedHtml, {
|
|
59
|
+
baseUrl: pageUrl,
|
|
60
|
+
});
|
|
49
61
|
const imageResult = await applyImageMode(result.content, {
|
|
50
62
|
mode: embedImages ? 'embed' : 'default',
|
|
51
63
|
});
|
|
@@ -54,10 +66,10 @@ export async function markdownHandler(req, res) {
|
|
|
54
66
|
if (format === 'json') {
|
|
55
67
|
return res.json(result);
|
|
56
68
|
}
|
|
57
|
-
return res
|
|
69
|
+
return await sendMarkdownResponse(res, url, result.content);
|
|
58
70
|
}
|
|
59
71
|
|
|
60
|
-
|
|
72
|
+
let { markdown } = convertHtmlToMarkdownEnhanced(html, pageUrl, {
|
|
61
73
|
contentSelector: req.query.contentSelector,
|
|
62
74
|
bodySelector: req.query.bodySelector,
|
|
63
75
|
});
|
|
@@ -67,9 +79,64 @@ export async function markdownHandler(req, res) {
|
|
|
67
79
|
const result = await applyImageMode(markdown, {
|
|
68
80
|
mode: embedImages ? 'embed' : 'default',
|
|
69
81
|
});
|
|
70
|
-
|
|
82
|
+
markdown = result.markdown;
|
|
83
|
+
return await sendMarkdownResponse(res, url, markdown);
|
|
71
84
|
} catch (err) {
|
|
72
85
|
console.error(err);
|
|
73
86
|
res.status(500).send('Error converting to Markdown');
|
|
74
87
|
}
|
|
75
88
|
}
|
|
89
|
+
|
|
90
|
+
async function sendMarkdownResponse(res, url, markdown) {
|
|
91
|
+
if (!isTextPasteUrl(url)) {
|
|
92
|
+
return res.type('text/markdown').send(markdown);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rawText = await fetchRawText(url);
|
|
96
|
+
const markdownWithText = appendTextPasteMarkdownAttachment(
|
|
97
|
+
markdown,
|
|
98
|
+
url,
|
|
99
|
+
rawText
|
|
100
|
+
);
|
|
101
|
+
if (markdownWithText.split('\n').length >= INLINE_MARKDOWN_LINE_LIMIT) {
|
|
102
|
+
return await sendTextPasteMarkdownArchive(res, url, markdown, rawText);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return res.type('text/markdown').send(markdownWithText);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function sendTextPasteMarkdownArchive(res, url, markdown, rawText) {
|
|
109
|
+
const pasteId = getTextPasteId(url) || 'paste';
|
|
110
|
+
const markdownFilename = `xpaste-pro-${pasteId}.md`;
|
|
111
|
+
const textFilename = `xpaste-pro-${pasteId}.txt`;
|
|
112
|
+
const lineCount = markdown.split('\n').length;
|
|
113
|
+
|
|
114
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
115
|
+
res.setHeader('Content-Disposition', `attachment; filename="${pasteId}.zip"`);
|
|
116
|
+
|
|
117
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
118
|
+
archive.pipe(res);
|
|
119
|
+
archive.append(
|
|
120
|
+
[
|
|
121
|
+
`# ${url}`,
|
|
122
|
+
'',
|
|
123
|
+
`Content from: ${url}`,
|
|
124
|
+
'',
|
|
125
|
+
`The page markdown is available in [${markdownFilename}](${markdownFilename}) (${lineCount} lines).`,
|
|
126
|
+
`The raw text content is available in [${textFilename}](${textFilename}).`,
|
|
127
|
+
'',
|
|
128
|
+
].join('\n'),
|
|
129
|
+
{ name: 'index.md' }
|
|
130
|
+
);
|
|
131
|
+
archive.append(markdown, { name: markdownFilename });
|
|
132
|
+
archive.append(rawText, { name: textFilename });
|
|
133
|
+
await archive.finalize();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function fetchRawText(url) {
|
|
137
|
+
const response = await fetch(normalizeUrlForTextContent(url));
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
140
|
+
}
|
|
141
|
+
return response.text();
|
|
142
|
+
}
|
package/src/txt.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { getTextPasteFilename, normalizeUrlForTextContent } from './lib.js';
|
|
4
|
+
|
|
5
|
+
export async function txtHandler(req, res) {
|
|
6
|
+
const url = req.query.url;
|
|
7
|
+
if (!url) {
|
|
8
|
+
return res.status(400).send('Missing `url` parameter');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
// Normalize URL to get text content (e.g., xpaste.pro -> xpaste.pro/raw)
|
|
13
|
+
const textUrl = normalizeUrlForTextContent(url);
|
|
14
|
+
|
|
15
|
+
const response = await fetch(textUrl);
|
|
16
|
+
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const contentType = response.headers.get('content-type') || 'text/plain';
|
|
22
|
+
|
|
23
|
+
// Only accept text content types
|
|
24
|
+
if (!contentType.includes('text/')) {
|
|
25
|
+
throw new Error(`Expected text content, got ${contentType}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const text = await response.text();
|
|
29
|
+
|
|
30
|
+
// Set appropriate headers for text file download
|
|
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);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error('Text fetch error:', err);
|
|
39
|
+
res.status(500).send('Error fetching text content');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getFilenameFromUrl(url) {
|
|
44
|
+
const textPasteFilename = getTextPasteFilename(url);
|
|
45
|
+
if (textPasteFilename) {
|
|
46
|
+
return textPasteFilename;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const urlObj = new URL(url);
|
|
51
|
+
const hostname = urlObj.hostname.replace(/\./g, '-');
|
|
52
|
+
const path = urlObj.pathname
|
|
53
|
+
.replace(/\//g, '-')
|
|
54
|
+
.replace(/^-/, '')
|
|
55
|
+
.replace(/-raw$/, '');
|
|
56
|
+
return `${hostname}${path}.txt`;
|
|
57
|
+
} catch {
|
|
58
|
+
return 'download.txt';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests to verify that markdown extraction from xpaste.pro
|
|
3
|
+
* includes ALL text visible in screenshots of the same page
|
|
4
|
+
*
|
|
5
|
+
* This ensures 100% coverage of visual content extraction as required in issue #15
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import request from 'supertest';
|
|
9
|
+
import nock from 'nock';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
let app;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
app = (await import('../../src/index.js')).app;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('xpaste.pro screenshot-to-markdown content matching', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
nock.cleanAll();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('GET /markdown for t4q0Lsp0', () => {
|
|
28
|
+
const testUrl = 'https://xpaste.pro/p/t4q0Lsp0';
|
|
29
|
+
|
|
30
|
+
// Load the actual HTML page content that was saved
|
|
31
|
+
const htmlPath = path.join(
|
|
32
|
+
__dirname,
|
|
33
|
+
'../../../tests/xpaste/data/t4q0Lsp0-page.html'
|
|
34
|
+
);
|
|
35
|
+
const rawTextPath = path.join(
|
|
36
|
+
__dirname,
|
|
37
|
+
'../../../tests/xpaste/data/t4q0Lsp0-actual-content.txt'
|
|
38
|
+
);
|
|
39
|
+
let testHtml;
|
|
40
|
+
let rawText;
|
|
41
|
+
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
if (fs.existsSync(htmlPath)) {
|
|
44
|
+
testHtml = fs.readFileSync(htmlPath, 'utf-8');
|
|
45
|
+
}
|
|
46
|
+
if (fs.existsSync(rawTextPath)) {
|
|
47
|
+
rawText = fs.readFileSync(rawTextPath, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
if (rawText) {
|
|
53
|
+
nock('https://xpaste.pro')
|
|
54
|
+
.persist()
|
|
55
|
+
.get('/p/t4q0Lsp0/raw')
|
|
56
|
+
.reply(200, rawText, {
|
|
57
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should include all header/metadata text visible in screenshot', async () => {
|
|
63
|
+
if (!testHtml) {
|
|
64
|
+
console.warn('Skipping test - HTML file not found');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
nock('https://xpaste.pro')
|
|
69
|
+
.get('/p/t4q0Lsp0')
|
|
70
|
+
.reply(200, testHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
71
|
+
|
|
72
|
+
const response = await request(app)
|
|
73
|
+
.get('/markdown')
|
|
74
|
+
.query({ url: testUrl });
|
|
75
|
+
|
|
76
|
+
expect(response.status).toBe(200);
|
|
77
|
+
expect(response.type).toBe('text/markdown');
|
|
78
|
+
|
|
79
|
+
// Verify page title/branding (top of screenshot)
|
|
80
|
+
expect(response.text).toContain('Xpaste');
|
|
81
|
+
expect(response.text).toContain('xPaste');
|
|
82
|
+
|
|
83
|
+
// Verify metadata section (visible in screenshot)
|
|
84
|
+
expect(response.text).toContain('Формат');
|
|
85
|
+
expect(response.text).toContain('text');
|
|
86
|
+
expect(response.text).toContain('Время создания');
|
|
87
|
+
expect(response.text).toContain('07.07.2021');
|
|
88
|
+
expect(response.text).toContain('12:48 UTC');
|
|
89
|
+
expect(response.text).toContain('Будет удалена');
|
|
90
|
+
expect(response.text).toContain('05.07.2031');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should include all SQL query content visible in screenshot', async () => {
|
|
94
|
+
if (!testHtml) {
|
|
95
|
+
console.warn('Skipping test - HTML file not found');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
nock('https://xpaste.pro')
|
|
100
|
+
.get('/p/t4q0Lsp0')
|
|
101
|
+
.reply(200, testHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
102
|
+
|
|
103
|
+
const response = await request(app)
|
|
104
|
+
.get('/markdown')
|
|
105
|
+
.query({ url: testUrl });
|
|
106
|
+
|
|
107
|
+
expect(response.status).toBe(200);
|
|
108
|
+
|
|
109
|
+
// Verify all 4 SQL queries visible in screenshot are present
|
|
110
|
+
|
|
111
|
+
// Query #1 metadata (lines 1-8 in screenshot)
|
|
112
|
+
expect(response.text).toContain('# 1');
|
|
113
|
+
expect(response.text).toContain('Time: 210707 15:39:36');
|
|
114
|
+
expect(response.text).toMatch(
|
|
115
|
+
/User@Host:\s+1703313381\[1703313381\]\s+@\s+\[136\.243\.53\.188\]/
|
|
116
|
+
);
|
|
117
|
+
expect(response.text).toContain('Id: 1138102510');
|
|
118
|
+
expect(response.text).toContain('Schema: xxxxxx');
|
|
119
|
+
expect(response.text).toContain('Query_time: 2.182754');
|
|
120
|
+
expect(response.text).toContain('Rows_examined: 324036');
|
|
121
|
+
|
|
122
|
+
// Query #2 metadata (lines 19-28 in screenshot)
|
|
123
|
+
expect(response.text).toContain('# 2');
|
|
124
|
+
expect(response.text).toContain('Time: 210707 15:38:11');
|
|
125
|
+
expect(response.text).toContain('Id: 1138100591');
|
|
126
|
+
expect(response.text).toContain('Query_time: 9.259469');
|
|
127
|
+
expect(response.text).toContain('DELETE FROM phpbb_post_revisions');
|
|
128
|
+
expect(response.text).toContain('WHERE post_id = 10472158');
|
|
129
|
+
|
|
130
|
+
// Query #3 metadata (lines 30-46 in screenshot)
|
|
131
|
+
expect(response.text).toContain('# 3');
|
|
132
|
+
expect(response.text).toContain('Time: 210707 15:32:18');
|
|
133
|
+
expect(response.text).toContain('Id: 1138094492');
|
|
134
|
+
expect(response.text).toContain('Query_time: 1.113014');
|
|
135
|
+
expect(response.text).toContain('Rows_examined: 521597');
|
|
136
|
+
|
|
137
|
+
// Query #4 metadata (lines 48-64 in screenshot)
|
|
138
|
+
expect(response.text).toContain('# 4');
|
|
139
|
+
expect(response.text).toContain('Time: 210707 15:27:56');
|
|
140
|
+
expect(response.text).toContain('Id: 1138089558');
|
|
141
|
+
expect(response.text).toContain('Query_time: 4.682828');
|
|
142
|
+
expect(response.text).toContain('Rows_examined: 2457916');
|
|
143
|
+
|
|
144
|
+
// Verify SQL statements contain key elements
|
|
145
|
+
expect(response.text).toContain('SELECT f.*, t.*, p.*, u.*');
|
|
146
|
+
expect(response.text).toContain('phpbb_posts');
|
|
147
|
+
expect(response.text).toContain('phpbb_users');
|
|
148
|
+
expect(response.text).toContain('phpbb_topics');
|
|
149
|
+
expect(response.text).toContain('phpbb_forums');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should include all footer text visible in screenshot', async () => {
|
|
153
|
+
if (!testHtml) {
|
|
154
|
+
console.warn('Skipping test - HTML file not found');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
nock('https://xpaste.pro')
|
|
159
|
+
.get('/p/t4q0Lsp0')
|
|
160
|
+
.reply(200, testHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
161
|
+
|
|
162
|
+
const response = await request(app)
|
|
163
|
+
.get('/markdown')
|
|
164
|
+
.query({ url: testUrl });
|
|
165
|
+
|
|
166
|
+
expect(response.status).toBe(200);
|
|
167
|
+
|
|
168
|
+
// Verify footer elements (bottom of screenshot)
|
|
169
|
+
expect(response.text).toContain('xPaste');
|
|
170
|
+
expect(response.text).toContain('Упакуем пароль или код в');
|
|
171
|
+
expect(response.text).toMatch(/cсылку для передачи/); // Note: 'c' can be different Unicode char
|
|
172
|
+
expect(response.text).toContain('Сделано в');
|
|
173
|
+
expect(response.text).toContain('Southbridge');
|
|
174
|
+
expect(response.text).toContain('Справка');
|
|
175
|
+
expect(response.text).toContain('Политика конфиденциальности');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should include language selector links visible in screenshot', async () => {
|
|
179
|
+
if (!testHtml) {
|
|
180
|
+
console.warn('Skipping test - HTML file not found');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
nock('https://xpaste.pro')
|
|
185
|
+
.get('/p/t4q0Lsp0')
|
|
186
|
+
.reply(200, testHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
187
|
+
|
|
188
|
+
const response = await request(app)
|
|
189
|
+
.get('/markdown')
|
|
190
|
+
.query({ url: testUrl });
|
|
191
|
+
|
|
192
|
+
expect(response.status).toBe(200);
|
|
193
|
+
|
|
194
|
+
// Verify language links (top right of screenshot)
|
|
195
|
+
expect(response.text).toContain('Ru');
|
|
196
|
+
expect(response.text).toContain('En');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should include RAW link visible in screenshot', async () => {
|
|
200
|
+
if (!testHtml) {
|
|
201
|
+
console.warn('Skipping test - HTML file not found');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
nock('https://xpaste.pro')
|
|
206
|
+
.get('/p/t4q0Lsp0')
|
|
207
|
+
.reply(200, testHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
208
|
+
|
|
209
|
+
const response = await request(app)
|
|
210
|
+
.get('/markdown')
|
|
211
|
+
.query({ url: testUrl });
|
|
212
|
+
|
|
213
|
+
expect(response.status).toBe(200);
|
|
214
|
+
|
|
215
|
+
// Verify RAW button (visible in screenshot)
|
|
216
|
+
expect(response.text).toContain('RAW');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should embed the raw text file as a markdown text block', async () => {
|
|
220
|
+
if (!testHtml || !rawText) {
|
|
221
|
+
console.warn('Skipping test - fixture files not found');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
nock('https://xpaste.pro')
|
|
226
|
+
.get('/p/t4q0Lsp0')
|
|
227
|
+
.reply(200, testHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
228
|
+
|
|
229
|
+
const response = await request(app)
|
|
230
|
+
.get('/markdown')
|
|
231
|
+
.query({ url: testUrl });
|
|
232
|
+
|
|
233
|
+
expect(response.status).toBe(200);
|
|
234
|
+
expect(response.type).toBe('text/markdown');
|
|
235
|
+
expect(response.text).toContain('## xpaste-pro-t4q0Lsp0.txt');
|
|
236
|
+
expect(response.text).toContain('```text');
|
|
237
|
+
expect(response.text).toContain(
|
|
238
|
+
rawText.split('\n')[0].replace(/\r/g, '')
|
|
239
|
+
);
|
|
240
|
+
expect(response.text).toContain('DELETE FROM phpbb_post_revisions');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should verify correct element ordering - heading and languages at top', async () => {
|
|
244
|
+
if (!testHtml) {
|
|
245
|
+
console.warn('Skipping test - HTML file not found');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
nock('https://xpaste.pro')
|
|
250
|
+
.get('/p/t4q0Lsp0')
|
|
251
|
+
.reply(200, testHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
252
|
+
|
|
253
|
+
const response = await request(app)
|
|
254
|
+
.get('/markdown')
|
|
255
|
+
.query({ url: testUrl });
|
|
256
|
+
|
|
257
|
+
expect(response.status).toBe(200);
|
|
258
|
+
|
|
259
|
+
const lines = response.text.split('\n');
|
|
260
|
+
|
|
261
|
+
// Find line numbers of key elements
|
|
262
|
+
const headingLine = lines.findIndex((l) => l.includes('Упакуем пароль'));
|
|
263
|
+
const languageLine = lines.findIndex(
|
|
264
|
+
(l) => l.includes('[Ru]') || l.includes('[En]')
|
|
265
|
+
);
|
|
266
|
+
const formatLine = lines.findIndex((l) => l.includes('Формат:'));
|
|
267
|
+
const firstQueryLine = lines.findIndex(
|
|
268
|
+
(l) => l.includes('# 1') && l.includes('#')
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// Verify heading comes before metadata (as shown in screenshot)
|
|
272
|
+
expect(headingLine).toBeGreaterThan(0);
|
|
273
|
+
expect(headingLine).toBeLessThan(formatLine);
|
|
274
|
+
|
|
275
|
+
// Verify languages come before metadata (as shown in screenshot)
|
|
276
|
+
expect(languageLine).toBeGreaterThan(0);
|
|
277
|
+
expect(languageLine).toBeLessThan(formatLine);
|
|
278
|
+
|
|
279
|
+
// Verify heading comes before main content
|
|
280
|
+
expect(headingLine).toBeLessThan(firstQueryLine);
|
|
281
|
+
|
|
282
|
+
// Verify metadata comes before main content
|
|
283
|
+
expect(formatLine).toBeLessThan(firstQueryLine);
|
|
284
|
+
|
|
285
|
+
console.log(
|
|
286
|
+
`✓ Element ordering verified: heading (line ${headingLine + 1}) -> languages (line ${languageLine + 1}) -> metadata (line ${formatLine + 1}) -> content (line ${firstQueryLine + 1})`
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should verify markdown extraction matches screenshot - comprehensive check', async () => {
|
|
291
|
+
if (!testHtml) {
|
|
292
|
+
console.warn('Skipping test - HTML file not found');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
nock('https://xpaste.pro')
|
|
297
|
+
.get('/p/t4q0Lsp0')
|
|
298
|
+
.reply(200, testHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
299
|
+
|
|
300
|
+
const response = await request(app)
|
|
301
|
+
.get('/markdown')
|
|
302
|
+
.query({ url: testUrl });
|
|
303
|
+
|
|
304
|
+
expect(response.status).toBe(200);
|
|
305
|
+
|
|
306
|
+
// Define all key text elements visible in the screenshot
|
|
307
|
+
const requiredElements = [
|
|
308
|
+
// Header/branding
|
|
309
|
+
'Xpaste',
|
|
310
|
+
|
|
311
|
+
// Metadata section
|
|
312
|
+
'Формат:',
|
|
313
|
+
'text',
|
|
314
|
+
'Время создания:',
|
|
315
|
+
'07.07.2021, 12:48 UTC',
|
|
316
|
+
'Будет удалена:',
|
|
317
|
+
'05.07.2031, 12:48 UTC',
|
|
318
|
+
|
|
319
|
+
// Query #1
|
|
320
|
+
'# 1',
|
|
321
|
+
'Time: 210707 15:39:36',
|
|
322
|
+
'User@Host: 1703313381[1703313381]',
|
|
323
|
+
'[136.243.53.188]',
|
|
324
|
+
'Id: 1138102510',
|
|
325
|
+
'Query_time: 2.182754',
|
|
326
|
+
|
|
327
|
+
// Query #2
|
|
328
|
+
'# 2',
|
|
329
|
+
'Time: 210707 15:38:11',
|
|
330
|
+
'Id: 1138100591',
|
|
331
|
+
'DELETE FROM phpbb_post_revisions',
|
|
332
|
+
|
|
333
|
+
// Query #3
|
|
334
|
+
'# 3',
|
|
335
|
+
'Time: 210707 15:32:18',
|
|
336
|
+
'Id: 1138094492',
|
|
337
|
+
|
|
338
|
+
// Query #4
|
|
339
|
+
'# 4',
|
|
340
|
+
'Time: 210707 15:27:56',
|
|
341
|
+
'Id: 1138089558',
|
|
342
|
+
|
|
343
|
+
// Footer
|
|
344
|
+
'Упакуем пароль',
|
|
345
|
+
'Southbridge',
|
|
346
|
+
'Справка',
|
|
347
|
+
'Политика конфиденциальности',
|
|
348
|
+
|
|
349
|
+
// Language links
|
|
350
|
+
'Ru',
|
|
351
|
+
'En',
|
|
352
|
+
|
|
353
|
+
// RAW button
|
|
354
|
+
'RAW',
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
// Verify ALL elements are present
|
|
358
|
+
const missingElements = [];
|
|
359
|
+
requiredElements.forEach((element) => {
|
|
360
|
+
if (!response.text.includes(element)) {
|
|
361
|
+
missingElements.push(element);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
expect(missingElements).toEqual([]);
|
|
366
|
+
|
|
367
|
+
// Log success with coverage stats
|
|
368
|
+
if (missingElements.length === 0) {
|
|
369
|
+
console.log(
|
|
370
|
+
`✓ All ${requiredElements.length} screenshot text elements found in markdown`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import getPort from 'get-port';
|
|
2
|
+
import nock from 'nock';
|
|
3
|
+
import { app } from '../../src/index.js';
|
|
4
|
+
|
|
5
|
+
describe('xpaste.pro HTTP e2e', () => {
|
|
6
|
+
let server;
|
|
7
|
+
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
nock.cleanAll();
|
|
10
|
+
if (server) {
|
|
11
|
+
await new Promise((resolve) => server.close(resolve));
|
|
12
|
+
server = null;
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('serves raw paste text through the real HTTP server', async () => {
|
|
17
|
+
const pasteText = 'console.log("xpaste fixture");\n';
|
|
18
|
+
nock('https://xpaste.pro')
|
|
19
|
+
.get('/p/js123/raw')
|
|
20
|
+
.reply(200, pasteText, { 'content-type': 'text/plain; charset=utf-8' });
|
|
21
|
+
|
|
22
|
+
const port = await getPort();
|
|
23
|
+
server = app.listen(port);
|
|
24
|
+
|
|
25
|
+
const response = await fetch(
|
|
26
|
+
`http://127.0.0.1:${port}/txt?url=${encodeURIComponent('https://xpaste.pro/p/js123')}`
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(response.status).toBe(200);
|
|
30
|
+
expect(response.headers.get('content-type')).toMatch(/text\/plain/);
|
|
31
|
+
expect(response.headers.get('content-disposition')).toContain(
|
|
32
|
+
'xpaste-pro-js123.txt'
|
|
33
|
+
);
|
|
34
|
+
expect(await response.text()).toBe(pasteText);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { convertHtmlToMarkdownEnhanced } from '../../src/lib.js';
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const DATA_DIR = path.join(__dirname, '../../../tests/xpaste/data');
|
|
8
|
+
|
|
9
|
+
describe('xpaste.pro fixture integration', () => {
|
|
10
|
+
it('converts the captured page fixture with the same visual ordering as the screenshot', () => {
|
|
11
|
+
const html = fs.readFileSync(
|
|
12
|
+
path.join(DATA_DIR, 't4q0Lsp0-page.html'),
|
|
13
|
+
'utf-8'
|
|
14
|
+
);
|
|
15
|
+
const { markdown } = convertHtmlToMarkdownEnhanced(
|
|
16
|
+
html,
|
|
17
|
+
'https://xpaste.pro/p/t4q0Lsp0',
|
|
18
|
+
{
|
|
19
|
+
extractMetadata: false,
|
|
20
|
+
postProcess: false,
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const headingIndex = markdown.indexOf('Упакуем пароль');
|
|
25
|
+
const languageIndexes = ['[Ru]', '[En]']
|
|
26
|
+
.map((label) => markdown.indexOf(label))
|
|
27
|
+
.filter((index) => index >= 0);
|
|
28
|
+
const languageIndex = Math.min(...languageIndexes);
|
|
29
|
+
const formatIndex = markdown.indexOf('Формат:');
|
|
30
|
+
const firstQueryIndex = markdown.indexOf('# 1');
|
|
31
|
+
|
|
32
|
+
expect(headingIndex).toBeGreaterThanOrEqual(0);
|
|
33
|
+
expect(languageIndexes).not.toHaveLength(0);
|
|
34
|
+
expect(languageIndex).toBeGreaterThanOrEqual(0);
|
|
35
|
+
expect(formatIndex).toBeGreaterThanOrEqual(0);
|
|
36
|
+
expect(firstQueryIndex).toBeGreaterThanOrEqual(0);
|
|
37
|
+
expect(headingIndex).toBeLessThan(formatIndex);
|
|
38
|
+
expect(languageIndex).toBeLessThan(formatIndex);
|
|
39
|
+
expect(formatIndex).toBeLessThan(firstQueryIndex);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import nock from 'nock';
|
|
3
|
+
import { jest } from '@jest/globals';
|
|
4
|
+
import unzipper from 'unzipper';
|
|
5
|
+
|
|
6
|
+
let app;
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
app = (await import('../../src/index.js')).app;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('xpaste.pro integration tests', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
nock.cleanAll();
|
|
15
|
+
jest.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('GET /txt with xpaste.pro URL', () => {
|
|
19
|
+
const testUrl = 'https://xpaste.pro/p/t4q0Lsp0';
|
|
20
|
+
// Using actual data from https://xpaste.pro/p/t4q0Lsp0 (captured on 2025-11-14)
|
|
21
|
+
const testText = `# 1
|
|
22
|
+
#
|
|
23
|
+
# Time: 210707 15:39:36
|
|
24
|
+
# User@Host: 1703313381[1703313381] @ [136.243.53.188] Id: 1138102510
|
|
25
|
+
# Schema: xxxxxx Last_errno: 0 Killed: 0
|
|
26
|
+
# Query_time: 2.182754 Lock_time: 0.000120 Rows_sent: 0 Rows_examined: 324036 Rows_affected: 0
|
|
27
|
+
# Bytes_sent: 20494
|
|
28
|
+
SET timestamp=1625661576;
|
|
29
|
+
SELECT f.*, t.*, p.*, u.*, tt.mark_time AS topic_mark_time, ft.mark_time AS forum_mark_time FROM (phpbb_posts p CROSS JOIN phpbb_users u CROSS JOIN phpbb_topics t) LEFT JOIN phpbb_forums f ON (t.forum_id = f.forum_id) LEFT JOIN phpbb_topics_track tt ON (t.topic_id = tt.topic_id AND tt.user_id = 659822) LEFT JOIN phpbb_forums_track ft ON (f.forum_id = ft.forum_id AND ft.user_id = 659822) WHERE p.topic_id = t.topic_id
|
|
30
|
+
AND p.poster_id = u.user_id
|
|
31
|
+
AND p.post_time > 1625660099
|
|
32
|
+
AND p.forum_id = 326
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
AND p.post_approved = 1 ORDER BY t.topic_last_post_time DESC, p.post_time
|
|
37
|
+
LIMIT 100;`;
|
|
38
|
+
|
|
39
|
+
it('should fetch and return text content from xpaste.pro', async () => {
|
|
40
|
+
nock('https://xpaste.pro')
|
|
41
|
+
.get('/p/t4q0Lsp0/raw')
|
|
42
|
+
.reply(200, testText, { 'content-type': 'text/plain; charset=utf-8' });
|
|
43
|
+
|
|
44
|
+
const response = await request(app).get('/txt').query({ url: testUrl });
|
|
45
|
+
|
|
46
|
+
expect(response.status).toBe(200);
|
|
47
|
+
expect(response.type).toBe('text/plain');
|
|
48
|
+
expect(response.text).toBe(testText);
|
|
49
|
+
expect(response.headers['content-disposition']).toContain('attachment');
|
|
50
|
+
expect(response.headers['content-disposition']).toContain('.txt');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should return 400 when URL is missing', async () => {
|
|
54
|
+
const response = await request(app).get('/txt');
|
|
55
|
+
|
|
56
|
+
expect(response.status).toBe(400);
|
|
57
|
+
expect(response.text).toBe('Missing `url` parameter');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should return 500 when fetch fails', async () => {
|
|
61
|
+
nock('https://xpaste.pro')
|
|
62
|
+
.get('/p/t4q0Lsp0/raw')
|
|
63
|
+
.replyWithError('Network error');
|
|
64
|
+
|
|
65
|
+
const response = await request(app).get('/txt').query({ url: testUrl });
|
|
66
|
+
|
|
67
|
+
expect(response.status).toBe(500);
|
|
68
|
+
expect(response.text).toBe('Error fetching text content');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('GET /markdown with xpaste.pro URL (small content)', () => {
|
|
73
|
+
const testUrl = 'https://xpaste.pro/p/t4q0Lsp0';
|
|
74
|
+
const rawText = `# 1
|
|
75
|
+
#
|
|
76
|
+
# Time: 210707 15:39:36
|
|
77
|
+
SET timestamp=1625661576;
|
|
78
|
+
SELECT * FROM phpbb_posts WHERE topic_id = 123;`;
|
|
79
|
+
// Mock HTML that resembles actual xpaste.pro page structure
|
|
80
|
+
const testHtml = `<!DOCTYPE html>
|
|
81
|
+
<html>
|
|
82
|
+
<head><title>xPaste</title></head>
|
|
83
|
+
<body>
|
|
84
|
+
<h1>Xpaste</h1>
|
|
85
|
+
<div>
|
|
86
|
+
<strong>Формат:</strong> text<br>
|
|
87
|
+
<strong>Время создания:</strong> 07.07.2021, 12:48 UTC<br>
|
|
88
|
+
<strong>Будет удалена:</strong> 05.07.2031, 12:48 UTC
|
|
89
|
+
</div>
|
|
90
|
+
<pre># 1
|
|
91
|
+
#
|
|
92
|
+
# Time: 210707 15:39:36
|
|
93
|
+
# User@Host: 1703313381[1703313381] @ [136.243.53.188] Id: 1138102510
|
|
94
|
+
# Schema: xxxxxx Last_errno: 0 Killed: 0
|
|
95
|
+
# Query_time: 2.182754 Lock_time: 0.000120 Rows_sent: 0 Rows_examined: 324036 Rows_affected: 0
|
|
96
|
+
SET timestamp=1625661576;
|
|
97
|
+
SELECT * FROM phpbb_posts WHERE topic_id = 123;</pre>
|
|
98
|
+
<footer>
|
|
99
|
+
<a href="https://xpaste.pro/">xPaste</a>
|
|
100
|
+
<p>Упакуем пароль или код в cсылку для передачи</p>
|
|
101
|
+
<p>Сделано в <a href="https://southbridge.io/">Southbridge</a></p>
|
|
102
|
+
</footer>
|
|
103
|
+
</body>
|
|
104
|
+
</html>`;
|
|
105
|
+
|
|
106
|
+
it('should convert HTML to markdown with all page elements when content is less than 1500 lines', async () => {
|
|
107
|
+
nock('https://xpaste.pro')
|
|
108
|
+
.get('/p/t4q0Lsp0')
|
|
109
|
+
.reply(200, testHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
110
|
+
nock('https://xpaste.pro')
|
|
111
|
+
.get('/p/t4q0Lsp0/raw')
|
|
112
|
+
.reply(200, rawText, { 'content-type': 'text/plain; charset=utf-8' });
|
|
113
|
+
|
|
114
|
+
const response = await request(app)
|
|
115
|
+
.get('/markdown')
|
|
116
|
+
.query({ url: testUrl });
|
|
117
|
+
|
|
118
|
+
expect(response.status).toBe(200);
|
|
119
|
+
expect(response.type).toBe('text/markdown');
|
|
120
|
+
|
|
121
|
+
// Verify it includes page metadata (visible in screenshot)
|
|
122
|
+
expect(response.text).toContain('Xpaste');
|
|
123
|
+
expect(response.text).toContain('Формат:');
|
|
124
|
+
expect(response.text).toContain('text');
|
|
125
|
+
expect(response.text).toContain('07.07.2021');
|
|
126
|
+
|
|
127
|
+
// Verify it includes the SQL query content
|
|
128
|
+
expect(response.text).toContain('1703313381');
|
|
129
|
+
// Note: markdown escapes special characters like * and _
|
|
130
|
+
expect(response.text).toMatch(/SELECT.*FROM phpbb.*posts/);
|
|
131
|
+
|
|
132
|
+
// Verify it includes footer elements (visible in screenshot)
|
|
133
|
+
expect(response.text).toContain('Southbridge');
|
|
134
|
+
expect(response.text).toContain('Упакуем пароль или код');
|
|
135
|
+
|
|
136
|
+
// Verify the raw text file is embedded as a named markdown block.
|
|
137
|
+
expect(response.text).toContain('## xpaste-pro-t4q0Lsp0.txt');
|
|
138
|
+
expect(response.text).toContain('```text\n# 1\n#');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should normalize raw xpaste URLs before markdown conversion', async () => {
|
|
142
|
+
nock('https://xpaste.pro')
|
|
143
|
+
.get('/p/t4q0Lsp0')
|
|
144
|
+
.reply(200, testHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
145
|
+
nock('https://xpaste.pro')
|
|
146
|
+
.get('/p/t4q0Lsp0/raw')
|
|
147
|
+
.reply(200, rawText, { 'content-type': 'text/plain; charset=utf-8' });
|
|
148
|
+
|
|
149
|
+
const response = await request(app)
|
|
150
|
+
.get('/markdown')
|
|
151
|
+
.query({ url: `${testUrl}/raw` });
|
|
152
|
+
|
|
153
|
+
expect(response.status).toBe(200);
|
|
154
|
+
expect(response.type).toBe('text/markdown');
|
|
155
|
+
expect(response.text).toContain('Формат:');
|
|
156
|
+
expect(response.text).toContain('## xpaste-pro-t4q0Lsp0.txt');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('GET /markdown with xpaste.pro URL (large content)', () => {
|
|
161
|
+
const testUrl = 'https://xpaste.pro/p/largefile';
|
|
162
|
+
|
|
163
|
+
it('should create a zip archive when content is 1500 lines or more', async () => {
|
|
164
|
+
// Create HTML content that will result in exactly 1500 lines of markdown
|
|
165
|
+
const largeContent = Array(1480).fill('<p>Line of text</p>').join('\n');
|
|
166
|
+
const largeHtml = `<!DOCTYPE html>
|
|
167
|
+
<html><head><title>Large File</title></head><body>${largeContent}</body></html>`;
|
|
168
|
+
|
|
169
|
+
nock('https://xpaste.pro')
|
|
170
|
+
.get('/p/largefile')
|
|
171
|
+
.reply(200, largeHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
172
|
+
nock('https://xpaste.pro')
|
|
173
|
+
.get('/p/largefile/raw')
|
|
174
|
+
.reply(200, largeContent.replace(/<\/?p>/g, ''), {
|
|
175
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const response = await request(app)
|
|
179
|
+
.get('/markdown')
|
|
180
|
+
.query({ url: testUrl })
|
|
181
|
+
.buffer(true)
|
|
182
|
+
.parse((res, callback) => {
|
|
183
|
+
const chunks = [];
|
|
184
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
185
|
+
res.on('end', () => callback(null, Buffer.concat(chunks)));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(response.status).toBe(200);
|
|
189
|
+
expect(response.type).toBe('application/zip');
|
|
190
|
+
expect(response.headers['content-disposition']).toContain('attachment');
|
|
191
|
+
expect(response.headers['content-disposition']).toContain(
|
|
192
|
+
'largefile.zip'
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const dir = await unzipper.Open.buffer(response.body);
|
|
196
|
+
const names = dir.files.map((file) => file.path);
|
|
197
|
+
expect(names).toEqual(
|
|
198
|
+
expect.arrayContaining([
|
|
199
|
+
'index.md',
|
|
200
|
+
'xpaste-pro-largefile.md',
|
|
201
|
+
'xpaste-pro-largefile.txt',
|
|
202
|
+
])
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const index = await dir.files
|
|
206
|
+
.find((file) => file.path === 'index.md')
|
|
207
|
+
.buffer();
|
|
208
|
+
const rawTextFile = await dir.files
|
|
209
|
+
.find((file) => file.path === 'xpaste-pro-largefile.txt')
|
|
210
|
+
.buffer();
|
|
211
|
+
expect(index.toString()).toContain(
|
|
212
|
+
'[xpaste-pro-largefile.txt](xpaste-pro-largefile.txt)'
|
|
213
|
+
);
|
|
214
|
+
expect(rawTextFile.toString()).toContain('Line of text');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should create a zip archive for content with more than 1500 lines', async () => {
|
|
218
|
+
// Create HTML content that will result in more than 1500 lines of markdown
|
|
219
|
+
const largeContent = Array(1600).fill('<p>Line of text</p>').join('\n');
|
|
220
|
+
const largeHtml = `<!DOCTYPE html>
|
|
221
|
+
<html><head><title>Large File</title></head><body>${largeContent}</body></html>`;
|
|
222
|
+
|
|
223
|
+
nock('https://xpaste.pro')
|
|
224
|
+
.get('/p/largefile')
|
|
225
|
+
.reply(200, largeHtml, { 'content-type': 'text/html; charset=utf-8' });
|
|
226
|
+
nock('https://xpaste.pro')
|
|
227
|
+
.get('/p/largefile/raw')
|
|
228
|
+
.reply(200, largeContent.replace(/<\/?p>/g, ''), {
|
|
229
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const response = await request(app)
|
|
233
|
+
.get('/markdown')
|
|
234
|
+
.query({ url: testUrl });
|
|
235
|
+
|
|
236
|
+
expect(response.status).toBe(200);
|
|
237
|
+
expect(response.type).toBe('application/zip');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('GET /markdown with non-xpaste.pro URL', () => {
|
|
242
|
+
const testUrl = 'https://example.com';
|
|
243
|
+
const testHtml =
|
|
244
|
+
'<html><body><h1>Test Page</h1><p>Regular HTML content</p></body></html>';
|
|
245
|
+
|
|
246
|
+
it('should process regular HTML URLs normally', async () => {
|
|
247
|
+
nock(testUrl).get('/').reply(200, testHtml);
|
|
248
|
+
|
|
249
|
+
const response = await request(app)
|
|
250
|
+
.get('/markdown')
|
|
251
|
+
.query({ url: testUrl });
|
|
252
|
+
|
|
253
|
+
expect(response.status).toBe(200);
|
|
254
|
+
expect(response.type).toBe('text/markdown');
|
|
255
|
+
expect(response.text).toContain('Test Page');
|
|
256
|
+
expect(response.text).toContain('Regular HTML content');
|
|
257
|
+
// Should not be a zip file
|
|
258
|
+
expect(response.headers['content-type']).not.toBe('application/zip');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendTextPasteMarkdownAttachment,
|
|
3
|
+
getTextPasteFilename,
|
|
4
|
+
isTextPasteUrl,
|
|
5
|
+
normalizeUrlForTextContent,
|
|
6
|
+
normalizeUrlForTextPage,
|
|
7
|
+
} from '../../src/lib.js';
|
|
8
|
+
|
|
9
|
+
describe('xpaste.pro URL handling utilities', () => {
|
|
10
|
+
describe('normalizeUrlForTextContent', () => {
|
|
11
|
+
it('should convert xpaste.pro URL to raw endpoint', () => {
|
|
12
|
+
const url = 'https://xpaste.pro/p/t4q0Lsp0';
|
|
13
|
+
const normalized = normalizeUrlForTextContent(url);
|
|
14
|
+
expect(normalized).toBe('https://xpaste.pro/p/t4q0Lsp0/raw');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should convert localized xpaste.pro URLs to localized raw endpoints', () => {
|
|
18
|
+
expect(
|
|
19
|
+
normalizeUrlForTextContent('https://xpaste.pro/ru/p/t4q0Lsp0')
|
|
20
|
+
).toBe('https://xpaste.pro/ru/p/t4q0Lsp0/raw');
|
|
21
|
+
expect(
|
|
22
|
+
normalizeUrlForTextContent('https://xpaste.pro/en/p/t4q0Lsp0')
|
|
23
|
+
).toBe('https://xpaste.pro/en/p/t4q0Lsp0/raw');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should not modify xpaste.pro URL that already has /raw', () => {
|
|
27
|
+
const url = 'https://xpaste.pro/p/t4q0Lsp0/raw';
|
|
28
|
+
const normalized = normalizeUrlForTextContent(url);
|
|
29
|
+
expect(normalized).toBe('https://xpaste.pro/p/t4q0Lsp0/raw');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should not modify non-xpaste.pro URLs', () => {
|
|
33
|
+
const url = 'https://example.com/page';
|
|
34
|
+
const normalized = normalizeUrlForTextContent(url);
|
|
35
|
+
expect(normalized).toBe(url);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should handle invalid URLs gracefully', () => {
|
|
39
|
+
const url = 'not-a-valid-url';
|
|
40
|
+
const normalized = normalizeUrlForTextContent(url);
|
|
41
|
+
expect(normalized).toBe(url);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('normalizeUrlForTextPage', () => {
|
|
46
|
+
it('should convert xpaste.pro raw URLs back to visual page URLs', () => {
|
|
47
|
+
expect(normalizeUrlForTextPage('https://xpaste.pro/p/t4q0Lsp0/raw')).toBe(
|
|
48
|
+
'https://xpaste.pro/p/t4q0Lsp0'
|
|
49
|
+
);
|
|
50
|
+
expect(
|
|
51
|
+
normalizeUrlForTextPage('https://xpaste.pro/ru/p/t4q0Lsp0/raw')
|
|
52
|
+
).toBe('https://xpaste.pro/ru/p/t4q0Lsp0');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should not modify non-xpaste.pro URLs', () => {
|
|
56
|
+
const url = 'https://example.com/page';
|
|
57
|
+
expect(normalizeUrlForTextPage(url)).toBe(url);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('isTextPasteUrl', () => {
|
|
62
|
+
it('should return true for xpaste.pro paste URLs', () => {
|
|
63
|
+
expect(isTextPasteUrl('https://xpaste.pro/p/t4q0Lsp0')).toBe(true);
|
|
64
|
+
expect(isTextPasteUrl('https://xpaste.pro/p/abc123')).toBe(true);
|
|
65
|
+
expect(isTextPasteUrl('https://xpaste.pro/ru/p/t4q0Lsp0')).toBe(true);
|
|
66
|
+
expect(isTextPasteUrl('https://xpaste.pro/en/p/t4q0Lsp0')).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should return false for non-xpaste.pro URLs', () => {
|
|
70
|
+
expect(isTextPasteUrl('https://example.com')).toBe(false);
|
|
71
|
+
expect(isTextPasteUrl('https://pastebin.com/xyz')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should return false for xpaste.pro URLs without /p/ path', () => {
|
|
75
|
+
expect(isTextPasteUrl('https://xpaste.pro')).toBe(false);
|
|
76
|
+
expect(isTextPasteUrl('https://xpaste.pro/about')).toBe(false);
|
|
77
|
+
expect(isTextPasteUrl('https://xpaste.pro/foo/p/t4q0Lsp0')).toBe(false);
|
|
78
|
+
expect(isTextPasteUrl('https://xpaste.pro/p/t4q0Lsp0/raw/extra')).toBe(
|
|
79
|
+
false
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should return false for invalid URLs', () => {
|
|
84
|
+
expect(isTextPasteUrl('not-a-url')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('getTextPasteFilename', () => {
|
|
89
|
+
it('should derive a canonical text filename for paste URLs', () => {
|
|
90
|
+
expect(getTextPasteFilename('https://xpaste.pro/p/t4q0Lsp0')).toBe(
|
|
91
|
+
'xpaste-pro-t4q0Lsp0.txt'
|
|
92
|
+
);
|
|
93
|
+
expect(getTextPasteFilename('https://xpaste.pro/ru/p/t4q0Lsp0/raw')).toBe(
|
|
94
|
+
'xpaste-pro-t4q0Lsp0.txt'
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should return null for non-paste URLs', () => {
|
|
99
|
+
expect(getTextPasteFilename('https://example.com/page')).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('appendTextPasteMarkdownAttachment', () => {
|
|
104
|
+
it('should embed raw text as a named fenced attachment block', () => {
|
|
105
|
+
const markdown = '# Page\n\nVisible content';
|
|
106
|
+
const rawText = 'first line\n```inside paste```\nlast line';
|
|
107
|
+
const result = appendTextPasteMarkdownAttachment(
|
|
108
|
+
markdown,
|
|
109
|
+
'https://xpaste.pro/p/t4q0Lsp0',
|
|
110
|
+
rawText
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(result).toContain('## xpaste-pro-t4q0Lsp0.txt');
|
|
114
|
+
expect(result).toContain(
|
|
115
|
+
'````text\nfirst line\n```inside paste```\nlast line\n````'
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|