@link-assistant/web-capture 1.10.4 → 1.10.5
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/bin/web-capture.js +77 -18
- package/package.json +1 -1
- package/src/archive.js +20 -1
- package/src/browser.js +12 -1
- package/src/docx.js +20 -2
- package/src/fetch.js +2 -1
- package/src/image.js +12 -1
- package/src/lib.js +163 -0
- package/src/pdf.js +22 -8
- package/src/stream.js +2 -1
- package/src/txt.js +10 -0
- package/tests/integration/google-drive-image.test.js +246 -0
- package/tests/unit/convertGoogleDriveUrl.test.js +89 -0
package/CHANGELOG.md
CHANGED
package/bin/web-capture.js
CHANGED
|
@@ -497,6 +497,17 @@ function archiveExtension(archiveFormat) {
|
|
|
497
497
|
return 'zip';
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
+
function sanitizeImageExtension(extension) {
|
|
501
|
+
if (!extension) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const normalized = extension.toLowerCase().replace(/^\./, '');
|
|
506
|
+
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(normalized)
|
|
507
|
+
? normalized
|
|
508
|
+
: null;
|
|
509
|
+
}
|
|
510
|
+
|
|
500
511
|
function writeGoogleDocsTextOutput({
|
|
501
512
|
content,
|
|
502
513
|
absoluteUrl,
|
|
@@ -601,6 +612,10 @@ async function captureUrl(url, options) {
|
|
|
601
612
|
// Import required modules
|
|
602
613
|
const {
|
|
603
614
|
fetchHtml,
|
|
615
|
+
fetchGoogleDriveImage,
|
|
616
|
+
fetchGoogleDriveImageHtml,
|
|
617
|
+
googleDriveImageHtmlForUrl,
|
|
618
|
+
googleDriveImageTextForUrl,
|
|
604
619
|
convertToUtf8,
|
|
605
620
|
convertRelativeUrls,
|
|
606
621
|
normalizeUrlForTextContent,
|
|
@@ -926,22 +941,42 @@ async function captureUrl(url, options) {
|
|
|
926
941
|
}
|
|
927
942
|
|
|
928
943
|
try {
|
|
944
|
+
if (['image', 'png', 'jpeg', 'screenshot'].includes(normalizedFormat)) {
|
|
945
|
+
const googleDriveImage = await fetchGoogleDriveImage(absoluteUrl);
|
|
946
|
+
if (googleDriveImage) {
|
|
947
|
+
if (explicitOutput === '-') {
|
|
948
|
+
process.stdout.write(googleDriveImage.buffer);
|
|
949
|
+
} else {
|
|
950
|
+
const outPath = explicitOutput || googleDriveImage.filename;
|
|
951
|
+
fs.writeFileSync(outPath, googleDriveImage.buffer);
|
|
952
|
+
console.error(`Google Drive image saved to: ${outPath}`);
|
|
953
|
+
}
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
929
958
|
if (normalizedFormat === 'txt' || normalizedFormat === 'text') {
|
|
930
959
|
let text;
|
|
931
960
|
if (isGithubRepositoryUrl(absoluteUrl)) {
|
|
932
961
|
const snapshot = await fetchGithubRepositorySnapshot(absoluteUrl);
|
|
933
962
|
text = formatGithubRepositoryText(snapshot);
|
|
934
963
|
} else {
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
response
|
|
941
|
-
|
|
942
|
-
|
|
964
|
+
const googleDriveImageHtml =
|
|
965
|
+
await fetchGoogleDriveImageHtml(absoluteUrl);
|
|
966
|
+
if (googleDriveImageHtml) {
|
|
967
|
+
text = googleDriveImageTextForUrl(absoluteUrl);
|
|
968
|
+
} else {
|
|
969
|
+
const response = await fetch(normalizeUrlForTextContent(absoluteUrl));
|
|
970
|
+
if (!response.ok) {
|
|
971
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
972
|
+
}
|
|
973
|
+
const contentType =
|
|
974
|
+
response.headers.get('content-type') || 'text/plain';
|
|
975
|
+
if (!contentType.includes('text/')) {
|
|
976
|
+
throw new Error(`Expected text content, got ${contentType}`);
|
|
977
|
+
}
|
|
978
|
+
text = await response.text();
|
|
943
979
|
}
|
|
944
|
-
text = await response.text();
|
|
945
980
|
}
|
|
946
981
|
const output =
|
|
947
982
|
explicitOutput === '-'
|
|
@@ -992,17 +1027,30 @@ async function captureUrl(url, options) {
|
|
|
992
1027
|
const { dismissPopups, scrollToLoadContent } =
|
|
993
1028
|
await import('../src/popups.js');
|
|
994
1029
|
const browserOpts = theme ? { colorScheme: theme } : {};
|
|
1030
|
+
const googleDriveImage = await fetchGoogleDriveImage(absoluteUrl);
|
|
1031
|
+
const googleDriveHtml = googleDriveImage
|
|
1032
|
+
? googleDriveImageHtmlForUrl(absoluteUrl, {
|
|
1033
|
+
imageSrc: `data:${googleDriveImage.contentType};base64,${googleDriveImage.buffer.toString('base64')}`,
|
|
1034
|
+
})
|
|
1035
|
+
: null;
|
|
995
1036
|
const browser = await createBrowser(engine, browserOpts);
|
|
996
1037
|
try {
|
|
997
1038
|
const page = await browser.newPage();
|
|
998
1039
|
await page.setViewport({ width, height });
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1040
|
+
if (googleDriveHtml) {
|
|
1041
|
+
await page.setContent(googleDriveHtml, {
|
|
1042
|
+
waitUntil: 'networkidle0',
|
|
1043
|
+
timeout: 30000,
|
|
1044
|
+
});
|
|
1045
|
+
} else {
|
|
1046
|
+
await page.goto(absoluteUrl, {
|
|
1047
|
+
waitUntil: 'networkidle0',
|
|
1048
|
+
timeout: 30000,
|
|
1049
|
+
});
|
|
1050
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
1051
|
+
await scrollToLoadContent(page);
|
|
1052
|
+
await dismissPopups(page);
|
|
1053
|
+
}
|
|
1006
1054
|
const rawPage = page.rawPage || page;
|
|
1007
1055
|
const pdfBuffer = await rawPage.pdf({
|
|
1008
1056
|
format: 'A4',
|
|
@@ -1079,11 +1127,19 @@ async function captureUrl(url, options) {
|
|
|
1079
1127
|
// Collect images
|
|
1080
1128
|
const $ = cheerio.load(html);
|
|
1081
1129
|
const images = [];
|
|
1130
|
+
const imageExtensionByUrl = new Map();
|
|
1082
1131
|
$('img').each(function () {
|
|
1083
1132
|
const src = $(this).attr('src');
|
|
1084
1133
|
if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
|
|
1085
1134
|
try {
|
|
1086
|
-
|
|
1135
|
+
const imgUrl = new URL(src, absoluteUrl).href;
|
|
1136
|
+
images.push(imgUrl);
|
|
1137
|
+
const hintedExtension = sanitizeImageExtension(
|
|
1138
|
+
$(this).attr('data-web-capture-extension')
|
|
1139
|
+
);
|
|
1140
|
+
if (hintedExtension && !imageExtensionByUrl.has(imgUrl)) {
|
|
1141
|
+
imageExtensionByUrl.set(imgUrl, hintedExtension);
|
|
1142
|
+
}
|
|
1087
1143
|
} catch {
|
|
1088
1144
|
/* skip */
|
|
1089
1145
|
}
|
|
@@ -1100,7 +1156,10 @@ async function captureUrl(url, options) {
|
|
|
1100
1156
|
) {
|
|
1101
1157
|
let idx = 1;
|
|
1102
1158
|
for (const imgUrl of uniqueImages) {
|
|
1103
|
-
const ext =
|
|
1159
|
+
const ext =
|
|
1160
|
+
imageExtensionByUrl.get(imgUrl) ||
|
|
1161
|
+
imgUrl.match(/\.(jpe?g|gif|webp|svg|png)/i)?.[1] ||
|
|
1162
|
+
'png';
|
|
1104
1163
|
imageMap.set(imgUrl, `images/image-${idx}.${ext}`);
|
|
1105
1164
|
idx++;
|
|
1106
1165
|
}
|
package/package.json
CHANGED
package/src/archive.js
CHANGED
|
@@ -58,12 +58,19 @@ export async function archiveHandler(req, res) {
|
|
|
58
58
|
// Collect images from the HTML
|
|
59
59
|
const $ = cheerio.load(html);
|
|
60
60
|
const images = [];
|
|
61
|
+
const imageExtensionByUrl = new Map();
|
|
61
62
|
$('img').each(function () {
|
|
62
63
|
const src = $(this).attr('src');
|
|
63
64
|
if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
|
|
64
65
|
try {
|
|
65
66
|
const imgUrl = new URL(src, absoluteUrl).href;
|
|
66
67
|
images.push(imgUrl);
|
|
68
|
+
const hintedExtension = sanitizeImageExtension(
|
|
69
|
+
$(this).attr('data-web-capture-extension')
|
|
70
|
+
);
|
|
71
|
+
if (hintedExtension && !imageExtensionByUrl.has(imgUrl)) {
|
|
72
|
+
imageExtensionByUrl.set(imgUrl, hintedExtension);
|
|
73
|
+
}
|
|
67
74
|
} catch {
|
|
68
75
|
/* skip invalid URLs */
|
|
69
76
|
}
|
|
@@ -89,7 +96,8 @@ export async function archiveHandler(req, res) {
|
|
|
89
96
|
if (localImages && uniqueImages.length > 0) {
|
|
90
97
|
let idx = 1;
|
|
91
98
|
for (const imgUrl of uniqueImages) {
|
|
92
|
-
const ext =
|
|
99
|
+
const ext =
|
|
100
|
+
imageExtensionByUrl.get(imgUrl) || guessImageExtension(imgUrl);
|
|
93
101
|
const filename = `image-${idx}.${ext}`;
|
|
94
102
|
imageMap.set(imgUrl, `images/${filename}`);
|
|
95
103
|
idx++;
|
|
@@ -277,3 +285,14 @@ function guessImageExtension(url) {
|
|
|
277
285
|
}
|
|
278
286
|
return 'png';
|
|
279
287
|
}
|
|
288
|
+
|
|
289
|
+
function sanitizeImageExtension(extension) {
|
|
290
|
+
if (!extension) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const normalized = extension.toLowerCase().replace(/^\./, '');
|
|
295
|
+
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(normalized)
|
|
296
|
+
? normalized
|
|
297
|
+
: null;
|
|
298
|
+
}
|
package/src/browser.js
CHANGED
|
@@ -23,7 +23,7 @@ const SERVER_CHROME_ARGS = [
|
|
|
23
23
|
* Unified page interface wrapping browser-commander's makeBrowserCommander.
|
|
24
24
|
*
|
|
25
25
|
* Exposes:
|
|
26
|
-
* - setExtraHTTPHeaders / setUserAgent / setViewport / goto / content / screenshot / close
|
|
26
|
+
* - setExtraHTTPHeaders / setUserAgent / setViewport / goto / setContent / content / screenshot / close
|
|
27
27
|
* (via the underlying raw page for cross-engine compatibility)
|
|
28
28
|
* - commander.pdf() – PDF generation (browser-commander v0.8.0)
|
|
29
29
|
* - commander.emulateMedia() – Color scheme emulation (browser-commander v0.7.0)
|
|
@@ -37,6 +37,7 @@ const SERVER_CHROME_ARGS = [
|
|
|
37
37
|
* @property {Function} setUserAgent - Set user agent
|
|
38
38
|
* @property {Function} setViewport - Set viewport size
|
|
39
39
|
* @property {Function} goto - Navigate to URL
|
|
40
|
+
* @property {Function} setContent - Set page HTML content
|
|
40
41
|
* @property {Function} content - Get page HTML content
|
|
41
42
|
* @property {Function} screenshot - Take screenshot
|
|
42
43
|
* @property {Function} close - Close the page
|
|
@@ -162,6 +163,16 @@ function createPageAdapter(rawPage, commander, engineType) {
|
|
|
162
163
|
await rawPage.goto(url, options);
|
|
163
164
|
}
|
|
164
165
|
},
|
|
166
|
+
async setContent(html, options = {}) {
|
|
167
|
+
const contentOptions = { ...options };
|
|
168
|
+
if (
|
|
169
|
+
engineType === 'playwright' &&
|
|
170
|
+
contentOptions.waitUntil === 'networkidle0'
|
|
171
|
+
) {
|
|
172
|
+
contentOptions.waitUntil = 'networkidle';
|
|
173
|
+
}
|
|
174
|
+
await rawPage.setContent(html, contentOptions);
|
|
175
|
+
},
|
|
165
176
|
async content() {
|
|
166
177
|
return await rawPage.content();
|
|
167
178
|
},
|
package/src/docx.js
CHANGED
|
@@ -76,13 +76,14 @@ export async function docxHandler(req, res) {
|
|
|
76
76
|
const imgResp = await fetch(imgUrl);
|
|
77
77
|
if (imgResp.ok) {
|
|
78
78
|
const buffer = await imgResp.buffer();
|
|
79
|
+
const contentType = imgResp.headers.get('content-type') || '';
|
|
79
80
|
children.push(
|
|
80
81
|
new Paragraph({
|
|
81
82
|
children: [
|
|
82
83
|
new ImageRun({
|
|
83
84
|
data: buffer,
|
|
84
85
|
transformation: { width: 600, height: 400 },
|
|
85
|
-
type: guessImageType(imgUrl),
|
|
86
|
+
type: guessImageType(imgUrl, contentType),
|
|
86
87
|
}),
|
|
87
88
|
],
|
|
88
89
|
})
|
|
@@ -170,7 +171,24 @@ export async function docxHandler(req, res) {
|
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
function guessImageType(url) {
|
|
174
|
+
function guessImageType(url, contentType = '') {
|
|
175
|
+
const normalizedContentType = contentType.toLowerCase().split(';')[0].trim();
|
|
176
|
+
if (normalizedContentType === 'image/jpeg') {
|
|
177
|
+
return 'jpg';
|
|
178
|
+
}
|
|
179
|
+
if (normalizedContentType === 'image/png') {
|
|
180
|
+
return 'png';
|
|
181
|
+
}
|
|
182
|
+
if (normalizedContentType === 'image/gif') {
|
|
183
|
+
return 'gif';
|
|
184
|
+
}
|
|
185
|
+
if (normalizedContentType === 'image/bmp') {
|
|
186
|
+
return 'bmp';
|
|
187
|
+
}
|
|
188
|
+
if (normalizedContentType === 'image/svg+xml') {
|
|
189
|
+
return 'svg';
|
|
190
|
+
}
|
|
191
|
+
|
|
174
192
|
const pathname = new URL(url).pathname.toLowerCase();
|
|
175
193
|
if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) {
|
|
176
194
|
return 'jpg';
|
package/src/fetch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
|
+
import { convertGoogleDriveUrl } from './lib.js';
|
|
2
3
|
|
|
3
4
|
export async function fetchHandler(req, res) {
|
|
4
5
|
const url = req.query.url;
|
|
@@ -6,7 +7,7 @@ export async function fetchHandler(req, res) {
|
|
|
6
7
|
return res.status(400).send('Missing `url` parameter');
|
|
7
8
|
}
|
|
8
9
|
try {
|
|
9
|
-
const response = await fetch(url);
|
|
10
|
+
const response = await fetch(convertGoogleDriveUrl(url));
|
|
10
11
|
// Copy status and headers
|
|
11
12
|
res.status(response.status);
|
|
12
13
|
|
package/src/image.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createBrowser, getBrowserEngine } from './browser.js';
|
|
2
|
-
import { isStackOverflowQuestionUrl } from './lib.js';
|
|
2
|
+
import { fetchGoogleDriveImage, isStackOverflowQuestionUrl } from './lib.js';
|
|
3
3
|
import { dismissPopups, scrollToLoadContent } from './popups.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -39,6 +39,17 @@ export async function imageHandler(req, res) {
|
|
|
39
39
|
|
|
40
40
|
try {
|
|
41
41
|
const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
42
|
+
const googleDriveImage = await fetchGoogleDriveImage(absoluteUrl);
|
|
43
|
+
if (googleDriveImage) {
|
|
44
|
+
res.set('Content-Type', googleDriveImage.contentType);
|
|
45
|
+
res.set(
|
|
46
|
+
'Content-Disposition',
|
|
47
|
+
`inline; filename="${googleDriveImage.filename}"`
|
|
48
|
+
);
|
|
49
|
+
res.end(googleDriveImage.buffer);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
const engine = getBrowserEngine(req);
|
|
43
54
|
const waitUntil =
|
|
44
55
|
engine === 'playwright' && isStackOverflowQuestionUrl(absoluteUrl)
|
package/src/lib.js
CHANGED
|
@@ -14,11 +14,17 @@ import { retry } from './retry.js';
|
|
|
14
14
|
|
|
15
15
|
const STACKPRINTER_RETRIES = 3;
|
|
16
16
|
const STACKPRINTER_RETRY_BASE_DELAY_MS = 1000;
|
|
17
|
+
const GOOGLE_DRIVE_FILE_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
17
18
|
|
|
18
19
|
export async function fetchHtml(url) {
|
|
19
20
|
if (!url) {
|
|
20
21
|
throw new Error('Missing URL parameter');
|
|
21
22
|
}
|
|
23
|
+
const googleDriveImageHtml = await fetchGoogleDriveImageHtml(url);
|
|
24
|
+
if (googleDriveImageHtml) {
|
|
25
|
+
return googleDriveImageHtml;
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
const stackPrinterUrl = stackPrinterUrlForQuestion(url);
|
|
23
29
|
if (stackPrinterUrl) {
|
|
24
30
|
return await fetchStackPrinterHtml(stackPrinterUrl);
|
|
@@ -28,6 +34,163 @@ export async function fetchHtml(url) {
|
|
|
28
34
|
return response.text();
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
export function getGoogleDriveFileId(url) {
|
|
38
|
+
if (!url) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const parsed = new URL(url);
|
|
44
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
45
|
+
const pathnameParts = parsed.pathname.split('/').filter(Boolean);
|
|
46
|
+
let fileId = null;
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
hostname === 'drive.google.com' ||
|
|
50
|
+
hostname === 'www.drive.google.com'
|
|
51
|
+
) {
|
|
52
|
+
if (pathnameParts[0] === 'file' && pathnameParts[1] === 'd') {
|
|
53
|
+
fileId = pathnameParts[2] || null;
|
|
54
|
+
} else if (pathnameParts[0] === 'open' || pathnameParts[0] === 'uc') {
|
|
55
|
+
fileId = parsed.searchParams.get('id');
|
|
56
|
+
}
|
|
57
|
+
} else if (
|
|
58
|
+
hostname === 'drive.usercontent.google.com' &&
|
|
59
|
+
pathnameParts[0] === 'download'
|
|
60
|
+
) {
|
|
61
|
+
fileId = parsed.searchParams.get('id');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return fileId && GOOGLE_DRIVE_FILE_ID_RE.test(fileId) ? fileId : null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isGoogleDriveFileUrl(url) {
|
|
71
|
+
return Boolean(getGoogleDriveFileId(url));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function convertGoogleDriveUrl(url) {
|
|
75
|
+
const fileId = getGoogleDriveFileId(url);
|
|
76
|
+
if (!fileId) {
|
|
77
|
+
return url;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `https://drive.google.com/uc?export=download&id=${fileId}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function googleDriveImageHtmlForUrl(url, options = {}) {
|
|
84
|
+
const fileId = getGoogleDriveFileId(url);
|
|
85
|
+
if (!fileId) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const imageUrl = options.imageSrc || convertGoogleDriveUrl(url);
|
|
90
|
+
const escapedImageUrl = he.escape(imageUrl);
|
|
91
|
+
const escapedSourceUrl = he.escape(url);
|
|
92
|
+
const extensionAttribute = options.imageExtension
|
|
93
|
+
? ` data-web-capture-extension="${he.escape(options.imageExtension)}"`
|
|
94
|
+
: '';
|
|
95
|
+
|
|
96
|
+
return `<!DOCTYPE html>
|
|
97
|
+
<html>
|
|
98
|
+
<head>
|
|
99
|
+
<meta charset="utf-8">
|
|
100
|
+
<title>Google Drive image</title>
|
|
101
|
+
</head>
|
|
102
|
+
<body>
|
|
103
|
+
<h1>Google Drive image</h1>
|
|
104
|
+
<p><a href="${escapedSourceUrl}">Original Google Drive file</a></p>
|
|
105
|
+
<img src="${escapedImageUrl}" alt="Google Drive image"${extensionAttribute}>
|
|
106
|
+
</body>
|
|
107
|
+
</html>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function googleDriveImageTextForUrl(url) {
|
|
111
|
+
if (!isGoogleDriveFileUrl(url)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return [
|
|
116
|
+
'Google Drive image',
|
|
117
|
+
'',
|
|
118
|
+
`Original Google Drive file: ${url}`,
|
|
119
|
+
`Image: ${convertGoogleDriveUrl(url)}`,
|
|
120
|
+
'',
|
|
121
|
+
].join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function fetchGoogleDriveImage(url) {
|
|
125
|
+
const fileId = getGoogleDriveFileId(url);
|
|
126
|
+
if (!fileId) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const downloadUrl = convertGoogleDriveUrl(url);
|
|
131
|
+
const response = await fetch(downloadUrl);
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error(`Google Drive returned HTTP ${response.status}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const contentType = response.headers.get('content-type') || '';
|
|
137
|
+
if (!isImageContentType(contentType)) {
|
|
138
|
+
throw new Error(`Google Drive file is not an image (${contentType})`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
buffer: await response.buffer(),
|
|
143
|
+
contentType,
|
|
144
|
+
downloadUrl,
|
|
145
|
+
filename: `google-drive-${fileId}.${imageExtensionForContentType(contentType)}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function fetchGoogleDriveImageHtml(url) {
|
|
150
|
+
if (!isGoogleDriveFileUrl(url)) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const downloadUrl = convertGoogleDriveUrl(url);
|
|
155
|
+
let imageExtension = null;
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch(downloadUrl, { method: 'HEAD' });
|
|
158
|
+
if (response.ok) {
|
|
159
|
+
const contentType = response.headers.get('content-type');
|
|
160
|
+
if (!isImageContentType(contentType)) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
imageExtension = imageExtensionForContentType(contentType);
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// Some public Drive responses reject HEAD even though GET works. In that
|
|
167
|
+
// case still render the direct image document and let consumers fetch it.
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return googleDriveImageHtmlForUrl(url, { imageExtension });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isImageContentType(contentType) {
|
|
174
|
+
return (contentType || '').toLowerCase().startsWith('image/');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function imageExtensionForContentType(contentType) {
|
|
178
|
+
const normalized = (contentType || '').toLowerCase().split(';')[0].trim();
|
|
179
|
+
if (normalized === 'image/jpeg' || normalized === 'image/jpg') {
|
|
180
|
+
return 'jpg';
|
|
181
|
+
}
|
|
182
|
+
if (normalized === 'image/gif') {
|
|
183
|
+
return 'gif';
|
|
184
|
+
}
|
|
185
|
+
if (normalized === 'image/webp') {
|
|
186
|
+
return 'webp';
|
|
187
|
+
}
|
|
188
|
+
if (normalized === 'image/svg+xml') {
|
|
189
|
+
return 'svg';
|
|
190
|
+
}
|
|
191
|
+
return 'png';
|
|
192
|
+
}
|
|
193
|
+
|
|
31
194
|
async function fetchStackPrinterHtml(url) {
|
|
32
195
|
return await retry(
|
|
33
196
|
async () => {
|
package/src/pdf.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { createBrowser, getBrowserEngine } from './browser.js';
|
|
17
17
|
import { dismissPopups, scrollToLoadContent } from './popups.js';
|
|
18
|
+
import { fetchGoogleDriveImage, googleDriveImageHtmlForUrl } from './lib.js';
|
|
18
19
|
|
|
19
20
|
export async function pdfHandler(req, res) {
|
|
20
21
|
const url = req.query.url;
|
|
@@ -29,6 +30,12 @@ export async function pdfHandler(req, res) {
|
|
|
29
30
|
|
|
30
31
|
try {
|
|
31
32
|
const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
33
|
+
const googleDriveImage = await fetchGoogleDriveImage(absoluteUrl);
|
|
34
|
+
const googleDriveHtml = googleDriveImage
|
|
35
|
+
? googleDriveImageHtmlForUrl(absoluteUrl, {
|
|
36
|
+
imageSrc: `data:${googleDriveImage.contentType};base64,${googleDriveImage.buffer.toString('base64')}`,
|
|
37
|
+
})
|
|
38
|
+
: null;
|
|
32
39
|
const engine = getBrowserEngine(req);
|
|
33
40
|
const browserOpts = theme ? { colorScheme: theme } : {};
|
|
34
41
|
const browser = await createBrowser(engine, browserOpts);
|
|
@@ -42,16 +49,23 @@ export async function pdfHandler(req, res) {
|
|
|
42
49
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
43
50
|
);
|
|
44
51
|
await page.setViewport({ width, height });
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
if (googleDriveHtml) {
|
|
53
|
+
await page.setContent(googleDriveHtml, {
|
|
54
|
+
waitUntil: 'networkidle0',
|
|
55
|
+
timeout: 30000,
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
await page.goto(absoluteUrl, {
|
|
59
|
+
waitUntil: 'networkidle0',
|
|
60
|
+
timeout: 30000,
|
|
61
|
+
});
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
64
|
+
await scrollToLoadContent(page);
|
|
52
65
|
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
if (shouldDismissPopups) {
|
|
67
|
+
await dismissPopups(page);
|
|
68
|
+
}
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
// Generate PDF using browser-commander's unified pdf() API (v0.8.0+)
|
package/src/stream.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
2
|
import { pipeline } from 'stream';
|
|
3
|
+
import { convertGoogleDriveUrl } from './lib.js';
|
|
3
4
|
|
|
4
5
|
export async function streamHandler(req, res) {
|
|
5
6
|
const url = req.query.url;
|
|
@@ -7,7 +8,7 @@ export async function streamHandler(req, res) {
|
|
|
7
8
|
return res.status(400).send('Missing `url` parameter');
|
|
8
9
|
}
|
|
9
10
|
try {
|
|
10
|
-
const response = await fetch(url);
|
|
11
|
+
const response = await fetch(convertGoogleDriveUrl(url));
|
|
11
12
|
// Copy status and headers
|
|
12
13
|
res.status(response.status);
|
|
13
14
|
|
package/src/txt.js
CHANGED
|
@@ -2,7 +2,10 @@ import fetch from 'node-fetch';
|
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
3
|
import {
|
|
4
4
|
fetchHtml,
|
|
5
|
+
fetchGoogleDriveImageHtml,
|
|
6
|
+
googleDriveImageTextForUrl,
|
|
5
7
|
getTextPasteFilename,
|
|
8
|
+
isGoogleDriveFileUrl,
|
|
6
9
|
isStackOverflowQuestionUrl,
|
|
7
10
|
normalizeUrlForTextContent,
|
|
8
11
|
} from './lib.js';
|
|
@@ -31,6 +34,13 @@ export async function txtHandler(req, res) {
|
|
|
31
34
|
return sendTextResponse(res, url, text);
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
if (isGoogleDriveFileUrl(url)) {
|
|
38
|
+
const googleDriveImageHtml = await fetchGoogleDriveImageHtml(url);
|
|
39
|
+
if (googleDriveImageHtml) {
|
|
40
|
+
return sendTextResponse(res, url, googleDriveImageTextForUrl(url));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
// Normalize URL to get text content (e.g., xpaste.pro -> xpaste.pro/raw)
|
|
35
45
|
const textUrl = normalizeUrlForTextContent(url);
|
|
36
46
|
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import nock from 'nock';
|
|
3
|
+
import unzipper from 'unzipper';
|
|
4
|
+
import { app } from '../../src/index.js';
|
|
5
|
+
|
|
6
|
+
const FIRST_FILE_ID = '1Cxkx6-428EQAX0-eiaq66H829ohnPp7q';
|
|
7
|
+
const SECOND_FILE_ID = '1fgJaftjv53xCN7vgiJOaQlbfWkUqPgyd';
|
|
8
|
+
const JPEG_BYTES = Buffer.from(
|
|
9
|
+
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAH/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAEFAqf/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/ASP/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/ASP/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAY/Ap//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/IV//2gAMAwEAAgADAAAAEP/EFBQRAQAAAAAAAAAAAAAAAAAAARD/2gAIAQMBAT8QH//EFBQRAQAAAAAAAAAAAAAAAAAAARD/2gAIAQIBAT8QH//EFBABAQAAAAAAAAAAAAAAAAAAARD/2gAIAQEAAT8QH//Z',
|
|
10
|
+
'base64'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const DRIVE_URLS = [
|
|
14
|
+
{
|
|
15
|
+
name: 'first issue URL with /view',
|
|
16
|
+
fileId: FIRST_FILE_ID,
|
|
17
|
+
url: `https://drive.google.com/file/d/${FIRST_FILE_ID}/view`,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'first issue URL without /view',
|
|
21
|
+
fileId: FIRST_FILE_ID,
|
|
22
|
+
url: `https://drive.google.com/file/d/${FIRST_FILE_ID}`,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'second issue URL with /view',
|
|
26
|
+
fileId: SECOND_FILE_ID,
|
|
27
|
+
url: `https://drive.google.com/file/d/${SECOND_FILE_ID}/view`,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'second issue URL without /view',
|
|
31
|
+
fileId: SECOND_FILE_ID,
|
|
32
|
+
url: `https://drive.google.com/file/d/${SECOND_FILE_ID}`,
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function directDownloadUrl(fileId) {
|
|
37
|
+
return `https://drive.google.com/uc?export=download&id=${fileId}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mockDriveHead(fileId) {
|
|
41
|
+
return nock('https://drive.google.com')
|
|
42
|
+
.head('/uc')
|
|
43
|
+
.query({ export: 'download', id: fileId })
|
|
44
|
+
.reply(200, '', { 'content-type': 'image/jpeg' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mockDriveGet(fileId) {
|
|
48
|
+
return nock('https://drive.google.com')
|
|
49
|
+
.get('/uc')
|
|
50
|
+
.query({ export: 'download', id: fileId })
|
|
51
|
+
.reply(200, JPEG_BYTES, {
|
|
52
|
+
'content-type': 'image/jpeg',
|
|
53
|
+
'content-length': String(JPEG_BYTES.length),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function bufferParser(res, callback) {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
60
|
+
res.on('end', () => callback(null, Buffer.concat(chunks)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
nock.cleanAll();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('Google Drive public image links', () => {
|
|
68
|
+
it.each(DRIVE_URLS)(
|
|
69
|
+
'/image downloads image bytes for $name',
|
|
70
|
+
async (item) => {
|
|
71
|
+
mockDriveGet(item.fileId);
|
|
72
|
+
|
|
73
|
+
const res = await request(app)
|
|
74
|
+
.get('/image')
|
|
75
|
+
.query({ url: item.url })
|
|
76
|
+
.buffer(true)
|
|
77
|
+
.parse(bufferParser)
|
|
78
|
+
.expect(200);
|
|
79
|
+
|
|
80
|
+
expect(res.headers['content-type']).toMatch(/^image\/jpeg/);
|
|
81
|
+
expect(res.body.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
|
82
|
+
expect(nock.isDone()).toBe(true);
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
it.each(DRIVE_URLS)(
|
|
87
|
+
'/fetch proxies direct image bytes for $name',
|
|
88
|
+
async (item) => {
|
|
89
|
+
mockDriveGet(item.fileId);
|
|
90
|
+
|
|
91
|
+
const res = await request(app)
|
|
92
|
+
.get('/fetch')
|
|
93
|
+
.query({ url: item.url })
|
|
94
|
+
.buffer(true)
|
|
95
|
+
.parse(bufferParser)
|
|
96
|
+
.expect(200);
|
|
97
|
+
|
|
98
|
+
expect(res.headers['content-type']).toMatch(/^image\/jpeg/);
|
|
99
|
+
expect(res.body.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
|
100
|
+
expect(nock.isDone()).toBe(true);
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
it.each(DRIVE_URLS)(
|
|
105
|
+
'/stream proxies direct image bytes for $name',
|
|
106
|
+
async (item) => {
|
|
107
|
+
mockDriveGet(item.fileId);
|
|
108
|
+
|
|
109
|
+
const res = await request(app)
|
|
110
|
+
.get('/stream')
|
|
111
|
+
.query({ url: item.url })
|
|
112
|
+
.buffer(true)
|
|
113
|
+
.parse(bufferParser)
|
|
114
|
+
.expect(200);
|
|
115
|
+
|
|
116
|
+
expect(res.headers['content-type']).toMatch(/^image\/jpeg/);
|
|
117
|
+
expect(res.body.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
|
118
|
+
expect(nock.isDone()).toBe(true);
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
it.each(DRIVE_URLS)(
|
|
123
|
+
'/markdown includes a direct image link for $name',
|
|
124
|
+
async (item) => {
|
|
125
|
+
mockDriveHead(item.fileId);
|
|
126
|
+
|
|
127
|
+
const res = await request(app)
|
|
128
|
+
.get('/markdown')
|
|
129
|
+
.query({ url: item.url })
|
|
130
|
+
.expect(200);
|
|
131
|
+
|
|
132
|
+
expect(res.headers['content-type']).toMatch(/text\/markdown/);
|
|
133
|
+
expect(res.text).toContain(
|
|
134
|
+
`})`
|
|
135
|
+
);
|
|
136
|
+
expect(nock.isDone()).toBe(true);
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
it.each(DRIVE_URLS)(
|
|
141
|
+
'/txt includes the original and direct image links for $name',
|
|
142
|
+
async (item) => {
|
|
143
|
+
mockDriveHead(item.fileId);
|
|
144
|
+
|
|
145
|
+
const res = await request(app)
|
|
146
|
+
.get('/txt')
|
|
147
|
+
.query({ url: item.url })
|
|
148
|
+
.expect(200);
|
|
149
|
+
|
|
150
|
+
expect(res.headers['content-type']).toMatch(/text\/plain/);
|
|
151
|
+
expect(res.text).toContain('Google Drive image');
|
|
152
|
+
expect(res.text).toContain(`Original Google Drive file: ${item.url}`);
|
|
153
|
+
expect(res.text).toContain(`Image: ${directDownloadUrl(item.fileId)}`);
|
|
154
|
+
expect(nock.isDone()).toBe(true);
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
it.each(DRIVE_URLS)(
|
|
159
|
+
'/html includes a direct image tag for $name',
|
|
160
|
+
async (item) => {
|
|
161
|
+
mockDriveHead(item.fileId);
|
|
162
|
+
|
|
163
|
+
const res = await request(app)
|
|
164
|
+
.get('/html')
|
|
165
|
+
.query({ url: item.url })
|
|
166
|
+
.expect(200);
|
|
167
|
+
|
|
168
|
+
expect(res.headers['content-type']).toMatch(/text\/html/);
|
|
169
|
+
expect(res.text).toContain(
|
|
170
|
+
`src="${directDownloadUrl(item.fileId).replace('&', '&')}"`
|
|
171
|
+
);
|
|
172
|
+
expect(nock.isDone()).toBe(true);
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
it('archive includes markdown and the downloaded Drive image', async () => {
|
|
177
|
+
const item = DRIVE_URLS[0];
|
|
178
|
+
mockDriveHead(item.fileId);
|
|
179
|
+
mockDriveGet(item.fileId);
|
|
180
|
+
|
|
181
|
+
const res = await request(app)
|
|
182
|
+
.get('/archive')
|
|
183
|
+
.query({ url: item.url })
|
|
184
|
+
.buffer(true)
|
|
185
|
+
.parse(bufferParser)
|
|
186
|
+
.expect(200);
|
|
187
|
+
|
|
188
|
+
const zip = await unzipper.Open.buffer(res.body);
|
|
189
|
+
const document = zip.files.find((file) => file.path === 'document.md');
|
|
190
|
+
const image = zip.files.find((file) => file.path.startsWith('images/'));
|
|
191
|
+
|
|
192
|
+
expect(document).toBeDefined();
|
|
193
|
+
expect(image).toBeDefined();
|
|
194
|
+
expect((await document.buffer()).toString('utf8')).toContain(
|
|
195
|
+
''
|
|
196
|
+
);
|
|
197
|
+
expect((await image.buffer()).subarray(0, 3)).toEqual(
|
|
198
|
+
Buffer.from([0xff, 0xd8, 0xff])
|
|
199
|
+
);
|
|
200
|
+
expect(nock.isDone()).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('docx includes the downloaded Drive image', async () => {
|
|
204
|
+
const item = DRIVE_URLS[0];
|
|
205
|
+
mockDriveHead(item.fileId);
|
|
206
|
+
mockDriveGet(item.fileId);
|
|
207
|
+
|
|
208
|
+
const res = await request(app)
|
|
209
|
+
.get('/docx')
|
|
210
|
+
.query({ url: item.url })
|
|
211
|
+
.buffer(true)
|
|
212
|
+
.parse(bufferParser)
|
|
213
|
+
.expect(200);
|
|
214
|
+
|
|
215
|
+
expect(res.headers['content-type']).toMatch(
|
|
216
|
+
/application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document/
|
|
217
|
+
);
|
|
218
|
+
expect(res.body.subarray(0, 2)).toEqual(Buffer.from('PK'));
|
|
219
|
+
|
|
220
|
+
const docx = await unzipper.Open.buffer(res.body);
|
|
221
|
+
const mediaEntry = docx.files.find(
|
|
222
|
+
(file) => file.path.startsWith('word/media/') && !file.path.endsWith('/')
|
|
223
|
+
);
|
|
224
|
+
expect(mediaEntry).toBeDefined();
|
|
225
|
+
expect((await mediaEntry.buffer()).subarray(0, 3)).toEqual(
|
|
226
|
+
Buffer.from([0xff, 0xd8, 0xff])
|
|
227
|
+
);
|
|
228
|
+
expect(nock.isDone()).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('pdf renders the downloaded Drive image document', async () => {
|
|
232
|
+
const item = DRIVE_URLS[0];
|
|
233
|
+
mockDriveGet(item.fileId);
|
|
234
|
+
|
|
235
|
+
const res = await request(app)
|
|
236
|
+
.get('/pdf')
|
|
237
|
+
.query({ url: item.url })
|
|
238
|
+
.buffer(true)
|
|
239
|
+
.parse(bufferParser)
|
|
240
|
+
.expect(200);
|
|
241
|
+
|
|
242
|
+
expect(res.headers['content-type']).toMatch(/application\/pdf/);
|
|
243
|
+
expect(res.body.subarray(0, 4).toString('utf8')).toBe('%PDF');
|
|
244
|
+
expect(nock.isDone()).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
convertGoogleDriveUrl,
|
|
3
|
+
getGoogleDriveFileId,
|
|
4
|
+
googleDriveImageTextForUrl,
|
|
5
|
+
isGoogleDriveFileUrl,
|
|
6
|
+
} from '../../src/lib.js';
|
|
7
|
+
|
|
8
|
+
const FIRST_FILE_ID = '1Cxkx6-428EQAX0-eiaq66H829ohnPp7q';
|
|
9
|
+
const SECOND_FILE_ID = '1fgJaftjv53xCN7vgiJOaQlbfWkUqPgyd';
|
|
10
|
+
|
|
11
|
+
function directDownloadUrl(fileId) {
|
|
12
|
+
return `https://drive.google.com/uc?export=download&id=${fileId}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('Google Drive URL helpers', () => {
|
|
16
|
+
it.each([
|
|
17
|
+
[
|
|
18
|
+
'issue URL with /view',
|
|
19
|
+
`https://drive.google.com/file/d/${FIRST_FILE_ID}/view`,
|
|
20
|
+
FIRST_FILE_ID,
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
'issue URL without /view',
|
|
24
|
+
`https://drive.google.com/file/d/${FIRST_FILE_ID}`,
|
|
25
|
+
FIRST_FILE_ID,
|
|
26
|
+
],
|
|
27
|
+
[
|
|
28
|
+
'second issue URL with /view',
|
|
29
|
+
`https://drive.google.com/file/d/${SECOND_FILE_ID}/view`,
|
|
30
|
+
SECOND_FILE_ID,
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
'second issue URL without /view',
|
|
34
|
+
`https://drive.google.com/file/d/${SECOND_FILE_ID}`,
|
|
35
|
+
SECOND_FILE_ID,
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
'open?id URL',
|
|
39
|
+
`https://drive.google.com/open?id=${FIRST_FILE_ID}`,
|
|
40
|
+
FIRST_FILE_ID,
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
'direct uc URL',
|
|
44
|
+
`https://drive.google.com/uc?export=download&id=${FIRST_FILE_ID}`,
|
|
45
|
+
FIRST_FILE_ID,
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
'redirected usercontent URL',
|
|
49
|
+
`https://drive.usercontent.google.com/download?id=${FIRST_FILE_ID}&export=download`,
|
|
50
|
+
FIRST_FILE_ID,
|
|
51
|
+
],
|
|
52
|
+
])('extracts and converts %s', (_name, input, fileId) => {
|
|
53
|
+
expect(getGoogleDriveFileId(input)).toBe(fileId);
|
|
54
|
+
expect(isGoogleDriveFileUrl(input)).toBe(true);
|
|
55
|
+
expect(convertGoogleDriveUrl(input)).toBe(directDownloadUrl(fileId));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('ignores non-Google Drive URLs and malformed Drive IDs', () => {
|
|
59
|
+
const example = 'https://example.com/image.jpg';
|
|
60
|
+
const malformed = 'https://drive.google.com/file/d/not%20valid/view';
|
|
61
|
+
|
|
62
|
+
expect(getGoogleDriveFileId(example)).toBeNull();
|
|
63
|
+
expect(isGoogleDriveFileUrl(example)).toBe(false);
|
|
64
|
+
expect(convertGoogleDriveUrl(example)).toBe(example);
|
|
65
|
+
expect(getGoogleDriveFileId(malformed)).toBeNull();
|
|
66
|
+
expect(isGoogleDriveFileUrl(malformed)).toBe(false);
|
|
67
|
+
expect(convertGoogleDriveUrl(malformed)).toBe(malformed);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('keeps empty or missing inputs unchanged when converting', () => {
|
|
71
|
+
expect(convertGoogleDriveUrl(null)).toBeNull();
|
|
72
|
+
expect(convertGoogleDriveUrl(undefined)).toBeUndefined();
|
|
73
|
+
expect(convertGoogleDriveUrl('')).toBe('');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('renders text output with the original file URL and direct image URL', () => {
|
|
77
|
+
const input = `https://drive.google.com/file/d/${FIRST_FILE_ID}/view`;
|
|
78
|
+
|
|
79
|
+
expect(googleDriveImageTextForUrl(input)).toContain(
|
|
80
|
+
`Original Google Drive file: ${input}`
|
|
81
|
+
);
|
|
82
|
+
expect(googleDriveImageTextForUrl(input)).toContain(
|
|
83
|
+
`Image: ${directDownloadUrl(FIRST_FILE_ID)}`
|
|
84
|
+
);
|
|
85
|
+
expect(
|
|
86
|
+
googleDriveImageTextForUrl('https://example.com/image.jpg')
|
|
87
|
+
).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
});
|