@coflyn/mori 0.0.0-stage → 1.0.0

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/engine.js ADDED
@@ -0,0 +1,1201 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { exec, execSync } from "child_process";
5
+ import { fileURLToPath } from "url";
6
+ import { DOMParser } from "linkedom";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const ROOT_DIR = path.resolve(__dirname, "..");
10
+
11
+ // 1. Polyfill browser / native globals for scraper bundle
12
+ globalThis.window = globalThis;
13
+ globalThis.DOMParser = DOMParser;
14
+
15
+ const mockStorage = new Map([
16
+ ["mori_request_timeout", "30"],
17
+ ["mori_header_spoofing", "true"],
18
+ ["mori_bypass_ssl", "false"],
19
+ ["mori_force_ipv4", "false"],
20
+ ["mori_doh", "off"],
21
+ ]);
22
+
23
+ globalThis.localStorage = {
24
+ getItem: (key) => (mockStorage.has(key) ? mockStorage.get(key) : null),
25
+ setItem: (key, val) => mockStorage.set(key, String(val)),
26
+ removeItem: (key) => mockStorage.delete(key),
27
+ clear: () => mockStorage.clear(),
28
+ };
29
+
30
+ const CHROME_DESKTOP_UA =
31
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
32
+ const CHROME_MOBILE_UA =
33
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
34
+ export const CHROME_UA =
35
+ "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36";
36
+
37
+ function getUserAgent() {
38
+ return CHROME_UA;
39
+ }
40
+
41
+ function getCookiesFromHeaders(headers) {
42
+ const raw = headers["Set-Cookie"] || headers["set-cookie"] || "";
43
+ if (!raw) return "";
44
+ if (Array.isArray(raw)) return raw.map((c) => c.split(";")[0]).join("; ");
45
+ return raw
46
+ .split(/,(?=\s*[^;,=]+=[^;]*)/)
47
+ .map((c) => c.trim().split(";")[0])
48
+ .filter((c) => c && c.includes("="))
49
+ .join("; ");
50
+ }
51
+
52
+ function serializeData(obj) {
53
+ return Object.keys(obj)
54
+ .map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(obj[k]))
55
+ .join("&");
56
+ }
57
+
58
+ function cleanUrl(u) {
59
+ return u ? u.split("?")[0] : "";
60
+ }
61
+
62
+ globalThis.window.__moriDeps = {
63
+ utils: {
64
+ CHROME_UA,
65
+ CHROME_DESKTOP_UA,
66
+ CHROME_MOBILE_UA,
67
+ getUserAgent,
68
+ cleanUrl,
69
+ getCookiesFromHeaders,
70
+ serializeData,
71
+ },
72
+ urlUtils: {
73
+ getCleanUrl: (u) => (u ? u.trim() : ""),
74
+ cleanUrl,
75
+ extractCleanUrl: (text) => {
76
+ if (!text || typeof text !== "string") return "";
77
+ const match = text.match(/https?:\/\/[^\s]+/i);
78
+ return match ? match[0] : text.trim();
79
+ },
80
+ },
81
+ core: {
82
+ t: (k) => k,
83
+ },
84
+ };
85
+
86
+ // 2. Persistent User Configuration & History
87
+ export const CONFIG_DIR = path.join(os.homedir(), ".mori");
88
+ export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
89
+ export const HISTORY_PATH = path.join(CONFIG_DIR, "history.json");
90
+ export const DEFAULT_DOWNLOAD_DIR = path.join(
91
+ os.homedir(),
92
+ "Downloads",
93
+ "Mori",
94
+ );
95
+
96
+ export function getSavedConfig() {
97
+ if (fs.existsSync(CONFIG_PATH)) {
98
+ try {
99
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
100
+ } catch (_) {}
101
+ }
102
+ return {};
103
+ }
104
+
105
+ export function saveConfig(updates) {
106
+ if (!fs.existsSync(CONFIG_DIR)) {
107
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
108
+ }
109
+ const current = getSavedConfig();
110
+ const merged = { ...current, ...updates };
111
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), "utf8");
112
+ return merged;
113
+ }
114
+
115
+ export function openDirectory(dirPath) {
116
+ const plat = process.platform;
117
+ const cmd =
118
+ plat === "darwin"
119
+ ? `open "${dirPath}"`
120
+ : plat === "win32"
121
+ ? `start "" "${dirPath}"`
122
+ : `xdg-open "${dirPath}"`;
123
+ return new Promise((resolve, reject) => {
124
+ exec(cmd, (err) => (err ? reject(err) : resolve()));
125
+ });
126
+ }
127
+
128
+ export function readClipboard() {
129
+ const plat = process.platform;
130
+ let cmd = "";
131
+ if (plat === "darwin") {
132
+ cmd = "pbpaste";
133
+ } else if (plat === "win32") {
134
+ cmd = 'powershell.exe -NoProfile -Command "Get-Clipboard"';
135
+ } else {
136
+ cmd =
137
+ "which wl-paste >/dev/null 2>&1 && wl-paste || xclip -selection clipboard -o 2>/dev/null || xsel -b -o 2>/dev/null";
138
+ }
139
+ return new Promise((resolve) => {
140
+ exec(cmd, { encoding: "utf8", timeout: 2000 }, (err, stdout) => {
141
+ if (err || !stdout) return resolve("");
142
+ resolve(stdout.trim());
143
+ });
144
+ });
145
+ }
146
+
147
+ export function decodeHtmlEntities(str) {
148
+ if (!str || typeof str !== "string") return "";
149
+ return str
150
+ .replace(/&/g, "&")
151
+ .replace(/&lt;/g, "<")
152
+ .replace(/&gt;/g, ">")
153
+ .replace(/&quot;/g, '"')
154
+ .replace(/&#039;/g, "'")
155
+ .replace(/&#39;/g, "'")
156
+ .replace(/&apos;/g, "'")
157
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) =>
158
+ String.fromCharCode(parseInt(hex, 16)),
159
+ )
160
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)));
161
+ }
162
+
163
+ export function getNonCollidingPath(dir, filename) {
164
+ let fullPath = path.join(dir, filename);
165
+ if (!fs.existsSync(fullPath)) return fullPath;
166
+
167
+ const ext = path.extname(filename);
168
+ const name = path.basename(filename, ext);
169
+ let count = 1;
170
+ while (fs.existsSync(path.join(dir, `${name}_${count}${ext}`))) {
171
+ count++;
172
+ }
173
+ return path.join(dir, `${name}_${count}${ext}`);
174
+ }
175
+
176
+ export function extractUrls(text) {
177
+ if (!text || typeof text !== "string") return [];
178
+ const matches = text.match(/https?:\/\/[^\s"'<>]+/gi) || [];
179
+ return [...new Set(matches)];
180
+ }
181
+
182
+ export function getHistory(limit = 15) {
183
+ if (fs.existsSync(HISTORY_PATH)) {
184
+ try {
185
+ const list = JSON.parse(fs.readFileSync(HISTORY_PATH, "utf8"));
186
+ return Array.isArray(list) ? list.slice(0, limit) : [];
187
+ } catch (_) {}
188
+ }
189
+ return [];
190
+ }
191
+
192
+ export function addToHistory(item) {
193
+ try {
194
+ const list = getHistory(100);
195
+ const record = {
196
+ url: item.url || "",
197
+ title: item.title || "Untitled",
198
+ platform: item.platform || "Unknown",
199
+ filename: item.filename || "",
200
+ path: item.path || "",
201
+ bytes: item.bytes || 0,
202
+ timestamp: Date.now(),
203
+ };
204
+ list.unshift(record);
205
+ if (!fs.existsSync(CONFIG_DIR)) {
206
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
207
+ }
208
+ fs.writeFileSync(
209
+ HISTORY_PATH,
210
+ JSON.stringify(list.slice(0, 100), null, 2),
211
+ "utf8",
212
+ );
213
+ } catch (_) {}
214
+ }
215
+
216
+ export function clearHistory() {
217
+ try {
218
+ if (fs.existsSync(HISTORY_PATH)) {
219
+ fs.unlinkSync(HISTORY_PATH);
220
+ }
221
+ return true;
222
+ } catch (_) {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ export function deleteHistoryItem(index, deleteFile = true) {
228
+ try {
229
+ const list = getHistory(200);
230
+ if (index < 0 || index >= list.length) {
231
+ return { success: false, error: "Index out of range" };
232
+ }
233
+ const [removed] = list.splice(index, 1);
234
+ let fileDeleted = false;
235
+
236
+ if (deleteFile && removed.path && fs.existsSync(removed.path)) {
237
+ try {
238
+ fs.unlinkSync(removed.path);
239
+ fileDeleted = true;
240
+ } catch (_) {}
241
+ }
242
+
243
+ fs.writeFileSync(HISTORY_PATH, JSON.stringify(list, null, 2), "utf8");
244
+
245
+ return {
246
+ success: true,
247
+ item: removed,
248
+ fileDeleted,
249
+ };
250
+ } catch (err) {
251
+ return { success: false, error: err.message };
252
+ }
253
+ }
254
+
255
+ export function getStats() {
256
+ const items = getHistory(200);
257
+ const totalDownloads = items.length;
258
+ const platformCounts = {};
259
+ let totalBytes = 0;
260
+
261
+ for (const item of items) {
262
+ const plat = (item.platform || "unknown").toLowerCase();
263
+ platformCounts[plat] = (platformCounts[plat] || 0) + 1;
264
+ if (item.bytes && typeof item.bytes === "number") {
265
+ totalBytes += item.bytes;
266
+ } else if (item.path && fs.existsSync(item.path)) {
267
+ try {
268
+ totalBytes += fs.statSync(item.path).size;
269
+ } catch (_) {}
270
+ }
271
+ }
272
+
273
+ let topPlatform = "-";
274
+ let maxCount = 0;
275
+ for (const [p, c] of Object.entries(platformCounts)) {
276
+ if (c > maxCount) {
277
+ maxCount = c;
278
+ topPlatform = p;
279
+ }
280
+ }
281
+
282
+ return {
283
+ totalDownloads,
284
+ totalBytes,
285
+ topPlatform,
286
+ platformCounts,
287
+ };
288
+ }
289
+
290
+ // 3. OTA Hot-Patching Engine
291
+ const GITHUB_REPO = "coflyn/Mori";
292
+ const OTA_DIR = CONFIG_DIR;
293
+ export const OTA_BUNDLE_PATH = path.join(OTA_DIR, "scrapers-bundle.js");
294
+ export const OTA_META_PATH = path.join(OTA_DIR, "scrapers-meta.json");
295
+
296
+ export function resetMori() {
297
+ const filesToDelete = [
298
+ CONFIG_PATH,
299
+ HISTORY_PATH,
300
+ OTA_BUNDLE_PATH,
301
+ OTA_META_PATH,
302
+ ];
303
+ for (const f of filesToDelete) {
304
+ if (fs.existsSync(f)) {
305
+ try {
306
+ fs.unlinkSync(f);
307
+ } catch (_) {}
308
+ }
309
+ }
310
+ }
311
+
312
+ export const OTA_VERSION_URL = `https://raw.githubusercontent.com/${GITHUB_REPO}/main/public/scrapers-version.json`;
313
+ export const OTA_BUNDLE_URL = `https://raw.githubusercontent.com/${GITHUB_REPO}/main/public/js/scrapers/bundle.js`;
314
+
315
+ function getBundledVersion() {
316
+ const localMetaPath = path.join(__dirname, "scrapers-version.json");
317
+ const rootMetaPath = path.join(ROOT_DIR, "public/scrapers-version.json");
318
+ const bundledMetaPath = fs.existsSync(localMetaPath)
319
+ ? localMetaPath
320
+ : rootMetaPath;
321
+
322
+ if (fs.existsSync(bundledMetaPath)) {
323
+ try {
324
+ const data = JSON.parse(fs.readFileSync(bundledMetaPath, "utf8"));
325
+ return data.scraperVersion || 1;
326
+ } catch (_) {}
327
+ }
328
+ return 1;
329
+ }
330
+
331
+ export function getActiveScraperVersion() {
332
+ if (fs.existsSync(OTA_META_PATH)) {
333
+ try {
334
+ const meta = JSON.parse(fs.readFileSync(OTA_META_PATH, "utf8"));
335
+ if (meta.scraperVersion && meta.scraperVersion >= getBundledVersion()) {
336
+ return meta.scraperVersion;
337
+ }
338
+ } catch (_) {}
339
+ }
340
+ return getBundledVersion();
341
+ }
342
+
343
+ /**
344
+ * Checks for OTA updates from GitHub raw.
345
+ * If force=true, checks immediately and throws on failure.
346
+ * If force=false, checks silently with low timeout (2s).
347
+ */
348
+ export async function checkForOtaUpdate(force = false) {
349
+ try {
350
+ const metaRes = await fetch(OTA_VERSION_URL, {
351
+ signal: AbortSignal.timeout(force ? 8000 : 2500),
352
+ headers: { "User-Agent": "Mori-CLI-OTA", "Cache-Control": "no-cache" },
353
+ });
354
+ if (!metaRes.ok) return { updated: false, reason: "Fetch error" };
355
+
356
+ const remoteMeta = await metaRes.json();
357
+ const currentVer = getActiveScraperVersion();
358
+
359
+ if (remoteMeta.scraperVersion && remoteMeta.scraperVersion > currentVer) {
360
+ // Pull new bundle
361
+ const bundleRes = await fetch(OTA_BUNDLE_URL, {
362
+ signal: AbortSignal.timeout(15000),
363
+ headers: { "User-Agent": "Mori-CLI-OTA", "Cache-Control": "no-cache" },
364
+ });
365
+ if (!bundleRes.ok)
366
+ return { updated: false, reason: "Bundle download error" };
367
+
368
+ const bundleText = await bundleRes.text();
369
+ if (!bundleText || bundleText.length < 5000) {
370
+ return { updated: false, reason: "Invalid bundle payload" };
371
+ }
372
+
373
+ if (!fs.existsSync(OTA_DIR)) {
374
+ fs.mkdirSync(OTA_DIR, { recursive: true });
375
+ }
376
+
377
+ fs.writeFileSync(OTA_BUNDLE_PATH, bundleText, "utf8");
378
+ fs.writeFileSync(
379
+ OTA_META_PATH,
380
+ JSON.stringify(
381
+ {
382
+ scraperVersion: remoteMeta.scraperVersion,
383
+ updatedAt: Date.now(),
384
+ sourceHash: remoteMeta.sourceHash || "",
385
+ changelog: remoteMeta.changelog || "",
386
+ },
387
+ null,
388
+ 2,
389
+ ),
390
+ "utf8",
391
+ );
392
+
393
+ return {
394
+ updated: true,
395
+ oldVersion: currentVer,
396
+ newVersion: remoteMeta.scraperVersion,
397
+ changelog: remoteMeta.changelog || "",
398
+ };
399
+ }
400
+
401
+ return { updated: false, currentVersion: currentVer, latest: true };
402
+ } catch (err) {
403
+ if (force) throw err;
404
+ return { updated: false, error: err.message };
405
+ }
406
+ }
407
+
408
+ // 3. Load Scraper Core (OTA Patch > Bundled Asset, with Safe-Mode Recovery)
409
+ function loadScraperEngine() {
410
+ const localBundlePath = path.join(__dirname, "bundle.js");
411
+ const rootBundlePath = path.join(ROOT_DIR, "public/js/scrapers/bundle.js");
412
+ const bundledBundlePath = fs.existsSync(localBundlePath)
413
+ ? localBundlePath
414
+ : rootBundlePath;
415
+
416
+ // Attempt A: Load OTA patch from ~/.mori/scrapers-bundle.js
417
+ if (fs.existsSync(OTA_BUNDLE_PATH) && fs.existsSync(OTA_META_PATH)) {
418
+ try {
419
+ const meta = JSON.parse(fs.readFileSync(OTA_META_PATH, "utf8"));
420
+ if (meta.scraperVersion && meta.scraperVersion >= getBundledVersion()) {
421
+ const patchCode = fs.readFileSync(OTA_BUNDLE_PATH, "utf8");
422
+ (0, eval)(patchCode);
423
+ if (globalThis.__MoriCoreScrapers) {
424
+ return globalThis.__MoriCoreScrapers;
425
+ }
426
+ }
427
+ } catch (err) {
428
+ // Safe-mode recovery: patch corrupted, delete it and fallback
429
+ try {
430
+ fs.unlinkSync(OTA_BUNDLE_PATH);
431
+ fs.unlinkSync(OTA_META_PATH);
432
+ } catch (_) {}
433
+ }
434
+ }
435
+
436
+ // Attempt B: Bundled fallback
437
+ if (!fs.existsSync(bundledBundlePath)) {
438
+ throw new Error(
439
+ `Scraper bundle not found at ${bundledBundlePath}. Run npm run compile:scrapers.`,
440
+ );
441
+ }
442
+
443
+ const bundleCode = fs.readFileSync(bundledBundlePath, "utf8");
444
+ (0, eval)(bundleCode);
445
+
446
+ if (!globalThis.__MoriCoreScrapers) {
447
+ throw new Error("Failed to initialize __MoriCoreScrapers from bundle.");
448
+ }
449
+ return globalThis.__MoriCoreScrapers;
450
+ }
451
+
452
+ export const scrapers = loadScraperEngine();
453
+
454
+ // 4. Platform Detection
455
+ export function detectPlatform(rawUrl) {
456
+ if (!rawUrl || typeof rawUrl !== "string") return null;
457
+ const url = rawUrl.toLowerCase();
458
+ if (url.includes("tiktok.com")) return "tiktok";
459
+ if (url.includes("instagram.com")) return "instagram";
460
+ if (url.includes("youtube.com") || url.includes("youtu.be")) return "youtube";
461
+ if (
462
+ url.includes("twitter.com") ||
463
+ /(?:^|[/.])x\.com(?:[/?#:]|$)/.test(url) ||
464
+ url.includes("fxtwitter.com") ||
465
+ url.includes("vxtwitter.com") ||
466
+ url.includes("fixupx.com")
467
+ )
468
+ return "twitter";
469
+ if (url.includes("spotify.com")) return "spotify";
470
+ if (url.includes("pinterest.com") || url.includes("pin.it"))
471
+ return "pinterest";
472
+ if (url.includes("music.apple.com")) return "applemusic";
473
+ if (url.includes("facebook.com") || url.includes("fb.watch"))
474
+ return "facebook";
475
+ if (
476
+ url.includes("xiaohongshu.com") ||
477
+ url.includes("rednote.com") ||
478
+ url.includes("xhslink.com") ||
479
+ url.includes("xhslink.cn")
480
+ )
481
+ return "rednote";
482
+ if (url.includes("douyin.com")) return "douyin";
483
+ if (
484
+ url.includes("bilibili.com") ||
485
+ url.includes("b23.tv") ||
486
+ url.includes("bili.im") ||
487
+ url.includes("bilibili.tv")
488
+ )
489
+ return "bilibili";
490
+ if (url.includes("threads.net") || url.includes("threads.com"))
491
+ return "threads";
492
+ if (url.includes("bandcamp.com")) return "bandcamp";
493
+ if (url.includes("pixiv.net")) return "pixiv";
494
+ if (url.includes("reddit.com") || url.includes("redd.it")) return "reddit";
495
+ if (
496
+ url.includes("terabox") ||
497
+ url.includes("1024tera") ||
498
+ url.includes("nephobox") ||
499
+ url.includes("4funbox") ||
500
+ url.includes("mirrobox") ||
501
+ url.includes("momerybox") ||
502
+ url.includes("tibibox")
503
+ )
504
+ return "terabox";
505
+ return null;
506
+ }
507
+
508
+ export const PLATFORM_SERVERS = {
509
+ tiktok: [
510
+ { id: "auto", name: "Auto (Server 1 → Server 2 fallback)" },
511
+ { id: "server1", name: "Server 1 (TikTokIO)" },
512
+ { id: "server2", name: "Server 2 (TikDownloader)" },
513
+ ],
514
+ instagram: [
515
+ { id: "auto", name: "Auto (Server 1 → Server 2 fallback)" },
516
+ { id: "server1", name: "Server 1 (InDown)" },
517
+ { id: "server2", name: "Server 2 (SnapSave)" },
518
+ ],
519
+ youtube: [
520
+ { id: "auto", name: "Auto (Server 1 → Server 2 fallback)" },
521
+ { id: "server1", name: "Server 1 (ytmp3.gg)" },
522
+ { id: "server2", name: "Server 2 (ytmp3.mobi)" },
523
+ ],
524
+ twitter: [
525
+ { id: "auto", name: "Auto (Server 1 → Server 2 fallback)" },
526
+ { id: "server1", name: "Server 1 (TwitterVideoDownloader)" },
527
+ { id: "server2", name: "Server 2 (SaveTWT)" },
528
+ ],
529
+ spotify: [
530
+ { id: "auto", name: "Auto (Server 1 → Server 2 fallback)" },
531
+ { id: "server1", name: "Server 1 (SpotiDown)" },
532
+ { id: "server2", name: "Server 2 (SoundLoaders)" },
533
+ ],
534
+ };
535
+
536
+ // 5. Unified Scraper with Automatic Server Fallbacks
537
+ export async function scrapeUrl(url, serverPreference = "auto") {
538
+ const platform = detectPlatform(url);
539
+ if (!platform) {
540
+ throw new Error("URL is not supported by Mori.");
541
+ }
542
+
543
+ // Normalize server input (e.g. "1" -> "server1", "2" -> "server2")
544
+ let srv = serverPreference;
545
+ if (srv === "1") srv = "server1";
546
+ if (srv === "2") srv = "server2";
547
+
548
+ let res = null;
549
+
550
+ switch (platform) {
551
+ case "tiktok": {
552
+ if (srv === "server2") {
553
+ scrapers.setTikTokSource("tikdownloader");
554
+ res = await scrapers.scrapeTikTok(url);
555
+ } else if (srv === "server1") {
556
+ scrapers.setTikTokSource("tiktokio");
557
+ res = await scrapers.scrapeTikTok(url);
558
+ } else {
559
+ scrapers.setTikTokSource("tiktokio");
560
+ res = await scrapers.scrapeTikTok(url);
561
+ if (!res?.status) {
562
+ scrapers.setTikTokSource("tikdownloader");
563
+ res = await scrapers.scrapeTikTok(url);
564
+ }
565
+ }
566
+ break;
567
+ }
568
+ case "instagram": {
569
+ if (srv === "server2") {
570
+ scrapers.setInstagramSource("savevid");
571
+ res = await scrapers.scrapeInstagram(url);
572
+ } else if (srv === "server1") {
573
+ scrapers.setInstagramSource("indown");
574
+ res = await scrapers.scrapeInstagram(url);
575
+ } else {
576
+ scrapers.setInstagramSource("indown");
577
+ res = await scrapers.scrapeInstagram(url);
578
+ if (!res?.status) {
579
+ scrapers.setInstagramSource("savevid");
580
+ res = await scrapers.scrapeInstagram(url);
581
+ }
582
+ }
583
+ break;
584
+ }
585
+ case "youtube": {
586
+ if (srv === "server2") {
587
+ scrapers.setYouTubeSource("mobi");
588
+ res = await scrapers.scrapeYouTube(url);
589
+ } else if (srv === "server1") {
590
+ scrapers.setYouTubeSource("gg");
591
+ res = await scrapers.scrapeYouTube(url);
592
+ } else {
593
+ scrapers.setYouTubeSource("gg");
594
+ res = await scrapers.scrapeYouTube(url);
595
+ if (!res?.status) {
596
+ scrapers.setYouTubeSource("mobi");
597
+ res = await scrapers.scrapeYouTube(url);
598
+ }
599
+ }
600
+ break;
601
+ }
602
+ case "twitter": {
603
+ if (srv === "server2") {
604
+ scrapers.setTwitterSource("savetwt");
605
+ res = await scrapers.scrapeTwitter(url);
606
+ } else if (srv === "server1") {
607
+ scrapers.setTwitterSource("tvd");
608
+ res = await scrapers.scrapeTwitter(url);
609
+ } else {
610
+ scrapers.setTwitterSource("tvd");
611
+ res = await scrapers.scrapeTwitter(url);
612
+ if (!res?.status) {
613
+ scrapers.setTwitterSource("savetwt");
614
+ res = await scrapers.scrapeTwitter(url);
615
+ }
616
+ }
617
+ break;
618
+ }
619
+ case "spotify": {
620
+ if (srv === "server2") {
621
+ scrapers.setSpotifySource("soundloaders");
622
+ res = await scrapers.scrapeSpotify(url);
623
+ } else if (srv === "server1") {
624
+ scrapers.setSpotifySource("spotidown");
625
+ res = await scrapers.scrapeSpotify(url);
626
+ } else {
627
+ scrapers.setSpotifySource("spotidown");
628
+ res = await scrapers.scrapeSpotify(url);
629
+ if (!res?.status) {
630
+ scrapers.setSpotifySource("soundloaders");
631
+ res = await scrapers.scrapeSpotify(url);
632
+ }
633
+ }
634
+ break;
635
+ }
636
+ case "pinterest":
637
+ res = await scrapers.scrapePinterest(url);
638
+ break;
639
+ case "applemusic":
640
+ res = await scrapers.scrapeAppleMusic(url);
641
+ break;
642
+ case "facebook":
643
+ res = await scrapers.scrapeFacebook(url);
644
+ break;
645
+ case "rednote":
646
+ res = await scrapers.scrapeRedNote(url);
647
+ break;
648
+ case "douyin":
649
+ res = await scrapers.scrapeDouyin(url);
650
+ break;
651
+ case "bilibili":
652
+ res = await scrapers.scrapeBilibili(url);
653
+ break;
654
+ case "threads":
655
+ res = await scrapers.scrapeThreads(url);
656
+ break;
657
+ case "bandcamp":
658
+ res = await scrapers.scrapeBandcamp(url);
659
+ break;
660
+ case "pixiv":
661
+ res = await scrapers.scrapePixiv(url);
662
+ break;
663
+ case "reddit":
664
+ res = await scrapers.scrapeReddit(url);
665
+ break;
666
+ case "terabox":
667
+ res = await scrapers.scrapeTeraBox(url);
668
+ break;
669
+ default:
670
+ throw new Error(`Platform handler missing for ${platform}`);
671
+ }
672
+
673
+ if (!res || !res.status) {
674
+ throw new Error(res?.message || `Failed to scrape media from ${platform}.`);
675
+ }
676
+
677
+ const result = res.result || {};
678
+ if (result.title) {
679
+ result.title = decodeHtmlEntities(result.title);
680
+ }
681
+
682
+ return {
683
+ platform,
684
+ ...result,
685
+ };
686
+ }
687
+
688
+ // 6. Asynchronous Direct URL Resolvers
689
+ export async function resolveUrl(url, onStatus) {
690
+ if (url.startsWith("applemusic_resolve:")) {
691
+ if (onStatus) onStatus("Resolving Apple Music direct stream...");
692
+ const payloadStr = url.replace("applemusic_resolve:", "");
693
+ const res = await scrapers.scraperFetch({
694
+ method: "POST",
695
+ url: "https://aplmate.com/action/track",
696
+ headers: {
697
+ "Content-Type": "application/x-www-form-urlencoded",
698
+ "User-Agent": CHROME_UA,
699
+ "X-Requested-With": "XMLHttpRequest",
700
+ Referer: "https://aplmate.com/",
701
+ Origin: "https://aplmate.com",
702
+ },
703
+ data: payloadStr,
704
+ });
705
+ let dd = typeof res === "string" ? JSON.parse(res) : res;
706
+ let dlHtml = (typeof dd === "object" ? dd?.data : dd) || "";
707
+ const parser = new DOMParser();
708
+ const doc = parser.parseFromString(dlHtml, "text/html");
709
+ let foundLink = "";
710
+ doc.querySelectorAll("a").forEach((a) => {
711
+ const href = a.getAttribute("href");
712
+ const text = a.textContent.trim();
713
+ if (
714
+ href &&
715
+ (href.includes("/dl?token=") || a.classList?.contains("abutton"))
716
+ ) {
717
+ if (
718
+ !href.includes("ko-fi.com") &&
719
+ !href.includes("premium.html") &&
720
+ !text.toLowerCase().includes("another song")
721
+ ) {
722
+ if (!foundLink)
723
+ foundLink = href.startsWith("http")
724
+ ? href
725
+ : "https://aplmate.com" + href;
726
+ }
727
+ }
728
+ });
729
+ if (!foundLink)
730
+ throw new Error("Could not resolve Apple Music download link.");
731
+ return foundLink;
732
+ }
733
+
734
+ if (url.startsWith("spotidown_resolve:")) {
735
+ if (onStatus) onStatus("Resolving Spotify direct track...");
736
+ const parts = url.replace("spotidown_resolve:", "").split("|||");
737
+ const payloadStr = parts[0];
738
+ let cookiesStr = parts[1] ? decodeURIComponent(parts[1]) : "";
739
+
740
+ const reqHeaders = {
741
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
742
+ "User-Agent": CHROME_UA,
743
+ "X-Requested-With": "XMLHttpRequest",
744
+ Referer: "https://spotidown.app/",
745
+ Origin: "https://spotidown.app",
746
+ };
747
+ if (cookiesStr) reqHeaders["Cookie"] = cookiesStr;
748
+
749
+ const res = await scrapers.scraperFetch({
750
+ url: "https://spotidown.app/action/track",
751
+ method: "POST",
752
+ headers: reqHeaders,
753
+ data: payloadStr,
754
+ rawResponse: true,
755
+ });
756
+ let dd = res?.data ?? res;
757
+ if (typeof dd === "string") {
758
+ try {
759
+ dd = JSON.parse(dd);
760
+ } catch (_) {}
761
+ }
762
+ let dlHtml = (typeof dd === "object" ? dd?.data || dd?.html : dd) || "";
763
+ const parser = new DOMParser();
764
+ const doc = parser.parseFromString(dlHtml, "text/html");
765
+ let foundLink = "";
766
+ doc.querySelectorAll("a").forEach((a) => {
767
+ const href = a.getAttribute("href");
768
+ const text = a.textContent.trim();
769
+ if (
770
+ href &&
771
+ href.startsWith("http") &&
772
+ !href.includes("premium.html") &&
773
+ text !== "Download Another Song"
774
+ ) {
775
+ if (!foundLink) foundLink = href;
776
+ }
777
+ });
778
+ if (!foundLink) throw new Error("Could not resolve Spotify download link.");
779
+ return foundLink;
780
+ }
781
+
782
+ if (
783
+ url.startsWith("youtube_resolve:") ||
784
+ url.startsWith("ytmp3gg_resolve:")
785
+ ) {
786
+ if (onStatus) onStatus("Resolving YouTube audio via converter...");
787
+ const raw = url.startsWith("youtube_resolve:")
788
+ ? url.replace("youtube_resolve:", "")
789
+ : url.replace("ytmp3gg_resolve:", "");
790
+ const [ytId, format = "mp3"] = raw.split("|||");
791
+
792
+ const mobiHeaders = {
793
+ Origin: "https://ytmp3.mobi",
794
+ Referer: "https://ytmp3.mobi/en8/",
795
+ "User-Agent": CHROME_UA,
796
+ "Sec-Fetch-Dest": "empty",
797
+ "Sec-Fetch-Mode": "cors",
798
+ "Sec-Fetch-Site": "cross-site",
799
+ Accept: "*/*",
800
+ };
801
+
802
+ const initData = await scrapers.scraperFetch({
803
+ url: `https://a.ymcdn.org/api/v1/init?p=y&23=1llum1n471&_=${Math.random()}`,
804
+ headers: mobiHeaders,
805
+ });
806
+
807
+ if (initData && !initData.error && initData.convertURL) {
808
+ let convData = await scrapers.scraperFetch({
809
+ url: `${initData.convertURL}&v=${ytId}&f=${format}&_=${Math.random()}`,
810
+ headers: mobiHeaders,
811
+ });
812
+
813
+ while (convData && convData.redirect > 0 && convData.redirectURL) {
814
+ convData = await scrapers.scraperFetch({
815
+ url: `${convData.redirectURL}&v=${ytId}&f=${format}&_=${Math.random()}`,
816
+ headers: mobiHeaders,
817
+ });
818
+ }
819
+
820
+ if (convData && !convData.error) {
821
+ let dlUrl = convData.downloadURL;
822
+ let progUrl = convData.progressURL;
823
+ let progress = 0;
824
+ let attempts = 0;
825
+
826
+ while (progress < 3 && attempts < 25) {
827
+ await new Promise((r) => setTimeout(r, 1200));
828
+ const pData = await scrapers.scraperFetch({
829
+ url: `${progUrl}&_=${Math.random()}`,
830
+ headers: mobiHeaders,
831
+ });
832
+ if (pData && !pData.error) {
833
+ progress = pData.progress;
834
+ if (pData.downloadURL) dlUrl = pData.downloadURL;
835
+ if (progress >= 3) break;
836
+ }
837
+ attempts++;
838
+ }
839
+
840
+ if (dlUrl && progress >= 3) {
841
+ if (dlUrl.startsWith("//")) dlUrl = "https:" + dlUrl;
842
+ if (dlUrl.startsWith("/")) dlUrl = "https://ytmp3.mobi" + dlUrl;
843
+ return dlUrl;
844
+ }
845
+ }
846
+ }
847
+ throw new Error("YouTube direct conversion timed out or failed.");
848
+ }
849
+
850
+ return url;
851
+ }
852
+
853
+ // 7. Filename Sanitizer
854
+ export function sanitizeFilename(title, ext = "mp4") {
855
+ const decoded = decodeHtmlEntities(title || "Mori_Download");
856
+ const safe = decoded
857
+ .replace(/[\\/:*?"<>|#%&{}\[\]@$^+=~`';,]/g, "")
858
+ .replace(/\s+/g, "_")
859
+ .replace(/_+/g, "_")
860
+ .trim()
861
+ .substring(0, 75);
862
+ const cleanExt = ext.replace(/^\./, "");
863
+ return `${safe || "Media"}.${cleanExt}`;
864
+ }
865
+
866
+ // 8. Stream Downloader with Progress Reporting
867
+ export async function downloadMediaFile(downloadUrl, outputPath, onProgress) {
868
+ const res = await fetch(downloadUrl, {
869
+ headers: {
870
+ "User-Agent": CHROME_UA,
871
+ Accept: "*/*",
872
+ },
873
+ });
874
+
875
+ if (!res.ok) {
876
+ throw new Error(`Download HTTP error: ${res.status} ${res.statusText}`);
877
+ }
878
+
879
+ const totalBytes = parseInt(res.headers.get("content-length") || "0", 10);
880
+ const fileStream = fs.createWriteStream(outputPath);
881
+
882
+ let downloadedBytes = 0;
883
+ let startTime = Date.now();
884
+ let lastReport = 0;
885
+
886
+ if (res.body?.getReader) {
887
+ const reader = res.body.getReader();
888
+ while (true) {
889
+ const { done, value } = await reader.read();
890
+ if (done) break;
891
+ downloadedBytes += value.length;
892
+ fileStream.write(Buffer.from(value));
893
+
894
+ const now = Date.now();
895
+ if (now - lastReport > 150 || downloadedBytes === totalBytes) {
896
+ lastReport = now;
897
+ const elapsedSec = (now - startTime) / 1000 || 0.001;
898
+ const speedBps = downloadedBytes / elapsedSec;
899
+ if (onProgress) {
900
+ onProgress({
901
+ downloadedBytes,
902
+ totalBytes,
903
+ percent: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
904
+ speedBps,
905
+ });
906
+ }
907
+ }
908
+ }
909
+ fileStream.end();
910
+ } else {
911
+ const buffer = Buffer.from(await res.arrayBuffer());
912
+ fs.writeFileSync(outputPath, buffer);
913
+ if (onProgress) {
914
+ onProgress({
915
+ downloadedBytes: buffer.length,
916
+ totalBytes: buffer.length,
917
+ percent: 100,
918
+ speedBps: buffer.length,
919
+ });
920
+ }
921
+ }
922
+
923
+ return outputPath;
924
+ }
925
+
926
+ // 9. Media & Network Utilities
927
+
928
+ export function openFile(filePath) {
929
+ const plat = process.platform;
930
+ const cmd =
931
+ plat === "darwin"
932
+ ? `open "${filePath}"`
933
+ : plat === "win32"
934
+ ? `start "" "${filePath}"`
935
+ : `xdg-open "${filePath}"`;
936
+ return new Promise((resolve, reject) => {
937
+ exec(cmd, (err) => (err ? reject(err) : resolve()));
938
+ });
939
+ }
940
+
941
+ export async function pingNetwork(host = "1.1.1.1") {
942
+ const cleanHost = (host || "1.1.1.1")
943
+ .trim()
944
+ .replace(/^https?:\/\//i, "")
945
+ .split("/")[0];
946
+ const isWin = process.platform === "win32";
947
+ const cmd = isWin
948
+ ? `ping -n 3 -w 2000 ${cleanHost}`
949
+ : `ping -c 3 -W 2 ${cleanHost}`;
950
+
951
+ return new Promise((resolve) => {
952
+ exec(cmd, { encoding: "utf8", timeout: 8000 }, (err, stdout) => {
953
+ if (!err && stdout) {
954
+ const unixMatch = stdout.match(
955
+ /(?:round-trip|rtt)[^=]*=\s*([0-9.]+)\/([0-9.]+)\/([0-9.]+)/i,
956
+ );
957
+ const winMatch = stdout.match(
958
+ /Minimum\s*=\s*([0-9]+)ms.*Maximum\s*=\s*([0-9]+)ms.*Average\s*=\s*([0-9]+)ms/i,
959
+ );
960
+ const lossMatch =
961
+ stdout.match(/([0-9.]+)%\s*packet loss/i) ||
962
+ stdout.match(/loss\s*\(([0-9.]+)%/i);
963
+ const loss = lossMatch ? `${lossMatch[1]}%` : "0%";
964
+
965
+ if (unixMatch) {
966
+ return resolve({
967
+ host: cleanHost,
968
+ avg: Math.round(parseFloat(unixMatch[2])),
969
+ min: Math.round(parseFloat(unixMatch[1])),
970
+ max: Math.round(parseFloat(unixMatch[3])),
971
+ loss,
972
+ ok: true,
973
+ });
974
+ }
975
+ if (winMatch) {
976
+ return resolve({
977
+ host: cleanHost,
978
+ avg: parseInt(winMatch[3], 10),
979
+ min: parseInt(winMatch[1], 10),
980
+ max: parseInt(winMatch[2], 10),
981
+ loss,
982
+ ok: true,
983
+ });
984
+ }
985
+ }
986
+
987
+ // Fallback: fast HTTP latency check if ICMP blocked
988
+ const start = Date.now();
989
+ fetch(`https://${cleanHost}`, {
990
+ method: "HEAD",
991
+ signal: AbortSignal.timeout(3000),
992
+ })
993
+ .then(() => {
994
+ const ms = Date.now() - start;
995
+ resolve({
996
+ host: cleanHost,
997
+ avg: ms,
998
+ min: ms,
999
+ max: ms,
1000
+ loss: "0%",
1001
+ ok: true,
1002
+ });
1003
+ })
1004
+ .catch(() => {
1005
+ resolve({
1006
+ host: cleanHost,
1007
+ avg: -1,
1008
+ min: -1,
1009
+ max: -1,
1010
+ loss: "100%",
1011
+ ok: false,
1012
+ });
1013
+ });
1014
+ });
1015
+ });
1016
+ }
1017
+
1018
+ // 10. Native Zero-Dependency Multi-Image PDF 1.4 Builder
1019
+
1020
+ export function ensureJpegBuffer(buf) {
1021
+ if (!buf || buf.length < 4) return buf;
1022
+ // If already JPEG
1023
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
1024
+ return buf;
1025
+ }
1026
+ // Convert via sips on macOS if available
1027
+ if (process.platform === "darwin") {
1028
+ try {
1029
+ const tmpIn = path.join(
1030
+ os.tmpdir(),
1031
+ `mori_convert_${Date.now()}_${Math.random().toString(36).slice(2)}.tmp`,
1032
+ );
1033
+ const tmpOut = `${tmpIn}.jpg`;
1034
+ fs.writeFileSync(tmpIn, buf);
1035
+ execSync(
1036
+ `sips -s format jpeg "${tmpIn}" --out "${tmpOut}" >/dev/null 2>&1`,
1037
+ );
1038
+ if (fs.existsSync(tmpOut)) {
1039
+ const outBuf = fs.readFileSync(tmpOut);
1040
+ try {
1041
+ fs.unlinkSync(tmpIn);
1042
+ } catch (_) {}
1043
+ try {
1044
+ fs.unlinkSync(tmpOut);
1045
+ } catch (_) {}
1046
+ return outBuf;
1047
+ }
1048
+ } catch (_) {}
1049
+ }
1050
+ // Fallback: try ffmpeg if installed
1051
+ try {
1052
+ const tmpIn = path.join(
1053
+ os.tmpdir(),
1054
+ `mori_convert_${Date.now()}_${Math.random().toString(36).slice(2)}.tmp`,
1055
+ );
1056
+ const tmpOut = `${tmpIn}.jpg`;
1057
+ fs.writeFileSync(tmpIn, buf);
1058
+ execSync(`ffmpeg -y -i "${tmpIn}" "${tmpOut}" >/dev/null 2>&1`);
1059
+ if (fs.existsSync(tmpOut)) {
1060
+ const outBuf = fs.readFileSync(tmpOut);
1061
+ try {
1062
+ fs.unlinkSync(tmpIn);
1063
+ } catch (_) {}
1064
+ try {
1065
+ fs.unlinkSync(tmpOut);
1066
+ } catch (_) {}
1067
+ return outBuf;
1068
+ }
1069
+ } catch (_) {}
1070
+
1071
+ return buf;
1072
+ }
1073
+
1074
+ export function getImageDimensions(buf) {
1075
+ if (!buf || buf.length < 8) return { width: 595, height: 842 };
1076
+
1077
+ // JPEG
1078
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
1079
+ let offset = 2;
1080
+ while (offset < buf.length - 8) {
1081
+ if (buf[offset] !== 0xff) {
1082
+ offset++;
1083
+ continue;
1084
+ }
1085
+ const marker = buf[offset + 1];
1086
+ if (
1087
+ (marker >= 0xc0 && marker <= 0xc3) ||
1088
+ (marker >= 0xc5 && marker <= 0xc7) ||
1089
+ (marker >= 0xc9 && marker <= 0xcb) ||
1090
+ (marker >= 0xcd && marker <= 0xcf)
1091
+ ) {
1092
+ if (marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
1093
+ return {
1094
+ height: buf.readUInt16BE(offset + 5),
1095
+ width: buf.readUInt16BE(offset + 7),
1096
+ };
1097
+ }
1098
+ }
1099
+ const len = buf.readUInt16BE(offset + 2);
1100
+ offset += 2 + len;
1101
+ }
1102
+ }
1103
+
1104
+ // PNG
1105
+ if (
1106
+ buf[0] === 0x89 &&
1107
+ buf[1] === 0x50 &&
1108
+ buf[2] === 0x4e &&
1109
+ buf[3] === 0x47 &&
1110
+ buf.length >= 24
1111
+ ) {
1112
+ return {
1113
+ width: buf.readUInt32BE(16),
1114
+ height: buf.readUInt32BE(20),
1115
+ };
1116
+ }
1117
+
1118
+ return { width: 595, height: 842 };
1119
+ }
1120
+
1121
+ export function generateImagesPdf(images) {
1122
+ // images: array of { buffer, width, height }
1123
+ const pageObjIds = images.map((_, i) => 3 + i * 3);
1124
+
1125
+ const obj1 = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
1126
+ const kidsStr = pageObjIds.map((id) => `${id} 0 R`).join(" ");
1127
+ const obj2 = `2 0 obj\n<< /Type /Pages /Kids [${kidsStr}] /Count ${images.length} >>\nendobj\n`;
1128
+
1129
+ const parts = [
1130
+ Buffer.from("%PDF-1.4\n"),
1131
+ Buffer.from(obj1),
1132
+ Buffer.from(obj2),
1133
+ ];
1134
+
1135
+ const offsets = [];
1136
+ const totalObjs = 2 + images.length * 3;
1137
+ let currentOffset = "%PDF-1.4\n".length;
1138
+
1139
+ offsets.push(0);
1140
+ offsets.push(currentOffset);
1141
+ currentOffset += Buffer.byteLength(obj1);
1142
+ offsets.push(currentOffset);
1143
+ currentOffset += Buffer.byteLength(obj2);
1144
+
1145
+ for (let i = 0; i < images.length; i++) {
1146
+ const img = images[i];
1147
+ const w = img.width || 595;
1148
+ const h = img.height || 842;
1149
+ const pageId = 3 + i * 3;
1150
+ const imgId = 4 + i * 3;
1151
+ const contentId = 5 + i * 3;
1152
+
1153
+ // Page Object
1154
+ offsets.push(currentOffset);
1155
+ const pageStr = `${pageId} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] /Resources << /XObject << /Im${i} ${imgId} 0 R >> >> /Contents ${contentId} 0 R >>\nendobj\n`;
1156
+ currentOffset += Buffer.byteLength(pageStr);
1157
+ parts.push(Buffer.from(pageStr));
1158
+
1159
+ // Image XObject (Raw DCTDecode stream)
1160
+ offsets.push(currentOffset);
1161
+ const imgHeader = `${imgId} 0 obj\n<< /Type /XObject /Subtype /Image /Width ${w} /Height ${h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${img.buffer.length} >>\nstream\n`;
1162
+ const imgFooter = "\nendstream\nendobj\n";
1163
+ currentOffset +=
1164
+ Buffer.byteLength(imgHeader) +
1165
+ img.buffer.length +
1166
+ Buffer.byteLength(imgFooter);
1167
+ parts.push(Buffer.from(imgHeader));
1168
+ parts.push(img.buffer);
1169
+ parts.push(Buffer.from(imgFooter));
1170
+
1171
+ // Content Stream
1172
+ offsets.push(currentOffset);
1173
+ const contentData = `q ${w} 0 0 ${h} 0 0 cm /Im${i} Do Q`;
1174
+ const contentStr = `${contentId} 0 obj\n<< /Length ${contentData.length} >>\nstream\n${contentData}\nendstream\nendobj\n`;
1175
+ currentOffset += Buffer.byteLength(contentStr);
1176
+ parts.push(Buffer.from(contentStr));
1177
+ }
1178
+
1179
+ const startxref = currentOffset;
1180
+ let xref = `xref\n0 ${totalObjs + 1}\n0000000000 65535 f \n`;
1181
+ for (let i = 1; i <= totalObjs; i++) {
1182
+ xref += String(offsets[i]).padStart(10, "0") + " 00000 n \n";
1183
+ }
1184
+ const trailer = `trailer\n<< /Size ${totalObjs + 1} /Root 1 0 R >>\nstartxref\n${startxref}\n%%EOF\n`;
1185
+ parts.push(Buffer.from(xref));
1186
+ parts.push(Buffer.from(trailer));
1187
+
1188
+ return Buffer.concat(parts);
1189
+ }
1190
+
1191
+ export function compileImagesToPdf(imageBuffers, outputPath) {
1192
+ const images = [];
1193
+ for (const rawBuf of imageBuffers) {
1194
+ const jpegBuf = ensureJpegBuffer(rawBuf);
1195
+ const { width, height } = getImageDimensions(jpegBuf);
1196
+ images.push({ buffer: jpegBuf, width, height });
1197
+ }
1198
+ const pdfBuffer = generateImagesPdf(images);
1199
+ fs.writeFileSync(outputPath, pdfBuffer);
1200
+ return { outputPath, bytes: pdfBuffer.length, pages: images.length };
1201
+ }