social-dl 0.9.2 → 0.9.6
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/README.md +9 -7
- package/bin/cli.js +12 -4
- package/package.json +5 -7
- package/src/index.js +9 -5
- package/src/platforms/facebook.js +11 -11
- package/src/platforms/instagram.js +81 -51
- package/src/platforms/tiktok.js +80 -59
- package/src/platforms/twitter.js +15 -2
- package/src/platforms/youtube.js +8 -8
- package/src/platforms/ytmp3.js +4 -4
- package/src/utils/constants.js +2 -0
- package/src/utils/ytdlp.js +18 -7
- package/index.d.ts +0 -32
package/README.md
CHANGED
|
@@ -31,10 +31,12 @@ npx social-dl https://youtu.be/BJs3DBrMTSc
|
|
|
31
31
|
npx social-dl https://youtu.be/BJs3DBrMTSc --mp3 -o audio.m4a
|
|
32
32
|
npx social-dl https://facebook.com/share/r/xxx/ --json
|
|
33
33
|
npx social-dl https://www.tiktok.com/@user/video/123 --no-watermark
|
|
34
|
+
npx social-dl https://www.instagram.com/reel/CODE/ --cookies ~/cookies.txt -o instagram.mp4
|
|
35
|
+
npx social-dl https://www.tiktok.com/@user/video/123 --cookies-from-browser chrome -o tiktok.mp4
|
|
34
36
|
npx social-dl doctor
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
Use `--quality 720p`, `--json`, `--no-watermark`, and `-o output.mp4` as needed. YouTube may reject datacenter IPs; `doctor` reports this as `BOT_DETECTED`.
|
|
39
|
+
Use `--quality 720p`, `--json`, `--no-watermark`, and `-o output.mp4` as needed. For login-required Instagram or TikTok posts, pass an exported cookie file with `--cookies path/to/cookies.txt` or use `--cookies-from-browser chrome`, `firefox`, `edge`, or `brave`. YouTube may reject datacenter IPs; `doctor` reports this as `BOT_DETECTED`.
|
|
38
40
|
|
|
39
41
|
## Library
|
|
40
42
|
|
|
@@ -81,8 +83,8 @@ await youtubeMp3Save('https://youtu.be/BJs3DBrMTSc', './audio.m4a');
|
|
|
81
83
|
```js
|
|
82
84
|
import { instagramDl, instagramSave } from 'social-dl/instagram';
|
|
83
85
|
|
|
84
|
-
const instagram = await instagramDl('https://www.instagram.com/reel/CODE/');
|
|
85
|
-
await instagramSave('https://www.instagram.com/reel/CODE/', './instagram.mp4');
|
|
86
|
+
const instagram = await instagramDl('https://www.instagram.com/reel/CODE/', { cookiesPath: './cookies.txt' });
|
|
87
|
+
await instagramSave('https://www.instagram.com/reel/CODE/', './instagram.mp4', 'best', { cookiesPath: './cookies.txt' });
|
|
86
88
|
```
|
|
87
89
|
|
|
88
90
|
#### TikTok
|
|
@@ -90,8 +92,8 @@ await instagramSave('https://www.instagram.com/reel/CODE/', './instagram.mp4');
|
|
|
90
92
|
```js
|
|
91
93
|
import { tiktokDl, tiktokSave } from 'social-dl/tiktok';
|
|
92
94
|
|
|
93
|
-
const tiktok = await tiktokDl('https://www.tiktok.com/@user/video/1234567890123456789');
|
|
94
|
-
await tiktokSave('https://www.tiktok.com/@user/video/1234567890123456789', './tiktok.mp4', 'NOWM');
|
|
95
|
+
const tiktok = await tiktokDl('https://www.tiktok.com/@user/video/1234567890123456789', { cookiesFromBrowser: 'chrome' });
|
|
96
|
+
await tiktokSave('https://www.tiktok.com/@user/video/1234567890123456789', './tiktok.mp4', 'NOWM', { cookiesFromBrowser: 'chrome' });
|
|
95
97
|
```
|
|
96
98
|
|
|
97
99
|
#### Twitter/X
|
|
@@ -145,8 +147,8 @@ Readable.fromWeb(stream).pipe(res);
|
|
|
145
147
|
|
|
146
148
|
## API reference
|
|
147
149
|
|
|
148
|
-
- `universalDl(url, { audioOnly, quality })` — return normalized direct-CDN formats.
|
|
149
|
-
- `universalSave(url, destination, { audioOnly, quality, noWatermark })` — save a selected format.
|
|
150
|
+
- `universalDl(url, { audioOnly, quality, cookiesPath, cookiesFromBrowser })` — return normalized direct-CDN formats.
|
|
151
|
+
- `universalSave(url, destination, { audioOnly, quality, noWatermark, cookiesPath, cookiesFromBrowser })` — save a selected format.
|
|
150
152
|
- `universalStream(url, options)` — return a Web `ReadableStream` for piping.
|
|
151
153
|
- `doctor({ log })` — test provider reachability, Node, and ffmpeg.
|
|
152
154
|
- Every platform exports `xxxDl()` and `xxxSave()`.
|
package/bin/cli.js
CHANGED
|
@@ -4,8 +4,11 @@ import { pipeline } from 'node:stream/promises';
|
|
|
4
4
|
import { universalDl, universalSave } from '../src/index.js';
|
|
5
5
|
import { doctor } from '../src/doctor.js';
|
|
6
6
|
import { randomUserAgent } from '../src/utils/fetcher.js';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { mkdir } from 'node:fs/promises';
|
|
7
9
|
|
|
8
|
-
const
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const VERSION = require('../package.json').version;
|
|
9
12
|
const HELP = `social-dl ${VERSION}\n\nUsage:\n social-dl <url> [-o file] [--quality 720p]\n social-dl <url> --mp3 -o audio.m4a\n social-dl <url> --json\n social-dl <url> --no-watermark\n social-dl doctor [--json]\n\nOptions: --quality <label> --mp3 --json --no-watermark -o, --output <file> --help`;
|
|
10
13
|
const color = (code, text) => process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
11
14
|
|
|
@@ -18,6 +21,8 @@ function parse(argv) {
|
|
|
18
21
|
else if (arg === '--json') opts.json = true;
|
|
19
22
|
else if (arg === '--no-watermark') opts.noWatermark = true;
|
|
20
23
|
else if (arg === '--quality') opts.quality = argv[++i];
|
|
24
|
+
else if (arg === '--cookies') opts.cookiesPath = argv[++i];
|
|
25
|
+
else if (arg === '--cookies-from-browser') opts.cookiesFromBrowser = argv[++i];
|
|
21
26
|
else if (arg === '-o' || arg === '--output') opts.output = argv[++i];
|
|
22
27
|
else opts._.push(arg);
|
|
23
28
|
}
|
|
@@ -43,14 +48,17 @@ async function streamSave(info, output, quality, noWatermark) {
|
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
async function main() {
|
|
46
|
-
const
|
|
51
|
+
const argv = process.argv.slice(2);
|
|
52
|
+
if (argv.includes('--version') || argv.includes('-v')) return console.log(VERSION);
|
|
53
|
+
await mkdir('cache/cookies', { recursive: true });
|
|
54
|
+
const opts = parse(argv);
|
|
47
55
|
if (opts.help || opts._[0] === undefined) return console.log(HELP);
|
|
48
56
|
if (opts._[0] === 'doctor') return opts.json ? console.log(JSON.stringify(await doctor({ log: false }), null, 2)) : await doctor();
|
|
49
57
|
const url = opts._[0];
|
|
50
|
-
const info = await universalDl(url,
|
|
58
|
+
const info = await universalDl(url, opts);
|
|
51
59
|
if (opts.json) return console.log(JSON.stringify(info, null, 2));
|
|
52
60
|
const output = opts.output || `${info.id}.${opts.audioOnly ? 'm4a' : (info.formats[0]?.type || 'mp4')}`;
|
|
53
|
-
await
|
|
61
|
+
await universalSave(url, output, opts);
|
|
54
62
|
console.log(`${color(32, 'Saved')} ${output}`);
|
|
55
63
|
}
|
|
56
64
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "social-dl",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "Direct CDN social downloader - YouTube, TikTok, Facebook, Instagram, Twitter, Pinterest, CapCut - No API, No keys, Never dies",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
|
-
"social-dl": "
|
|
9
|
+
"social-dl": "bin/cli.js"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
12
|
".": "./src/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"dev": "node src/index.js"
|
|
26
26
|
},
|
|
27
27
|
"optionalDependencies": {
|
|
28
|
-
"yt-dlp-exec": "^1.2
|
|
28
|
+
"yt-dlp-exec": "^1.0.2"
|
|
29
29
|
},
|
|
30
30
|
"keywords": [
|
|
31
31
|
"youtube downloader",
|
|
@@ -57,10 +57,8 @@
|
|
|
57
57
|
"node": ">=18.0.0"
|
|
58
58
|
},
|
|
59
59
|
"files": [
|
|
60
|
-
"src/",
|
|
61
60
|
"bin/",
|
|
62
|
-
"
|
|
63
|
-
"README.md"
|
|
64
|
-
"LICENSE"
|
|
61
|
+
"src/",
|
|
62
|
+
"README.md"
|
|
65
63
|
]
|
|
66
64
|
}
|
package/src/index.js
CHANGED
|
@@ -25,10 +25,10 @@ export function detectPlatform(value) {
|
|
|
25
25
|
/** Download metadata using the correct platform extractor. */
|
|
26
26
|
export async function universalDl(url, opts = {}) {
|
|
27
27
|
switch (detectPlatform(url)) {
|
|
28
|
-
case 'facebook': return facebookDl(url);
|
|
29
|
-
case 'youtube': return opts.audioOnly ? youtubeMp3Dl(url) : youtubeDl(url);
|
|
30
|
-
case 'instagram': return instagramDl(url);
|
|
31
|
-
case 'tiktok': return tiktokDl(url);
|
|
28
|
+
case 'facebook': return facebookDl(url, opts);
|
|
29
|
+
case 'youtube': return opts.audioOnly ? youtubeMp3Dl(url, opts) : youtubeDl(url, opts);
|
|
30
|
+
case 'instagram': return instagramDl(url, opts);
|
|
31
|
+
case 'tiktok': return tiktokDl(url, opts);
|
|
32
32
|
case 'twitter': return twitterDl(url);
|
|
33
33
|
case 'pinterest': return pinterestDl(url);
|
|
34
34
|
case 'capcut': return capcutDl(url);
|
|
@@ -39,7 +39,11 @@ export async function universalDl(url, opts = {}) {
|
|
|
39
39
|
/** Save a selected format from the correct platform. */
|
|
40
40
|
export async function universalSave(url, dest = './video.mp4', opts = {}) {
|
|
41
41
|
const quality = opts.quality || 'best';
|
|
42
|
-
|
|
42
|
+
const platform = detectPlatform(url);
|
|
43
|
+
if (platform === 'youtube') return opts.audioOnly ? youtubeMp3Save(url, dest, quality, opts) : youtubeSave(url, dest, quality, opts);
|
|
44
|
+
if (platform === 'facebook') return facebookSave(url, dest, quality, opts);
|
|
45
|
+
if (platform === 'instagram') return instagramSave(url, dest, quality, opts);
|
|
46
|
+
if (platform === 'tiktok') return tiktokSave(url, dest, quality, opts);
|
|
43
47
|
const result = await universalDl(url, opts);
|
|
44
48
|
const headers = detectPlatform(url) === 'youtube' ? { ...YOUTUBE_MEDIA_HEADERS, fallbackHeaders: IOS_MEDIA_HEADERS } : {};
|
|
45
49
|
return saveResult(result, dest, quality, headers, opts.noWatermark === true);
|
|
@@ -2,7 +2,7 @@ import { mkdir, rename, stat, unlink } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { fetchWithRetry } from '../utils/fetcher.js';
|
|
4
4
|
import { sortFormats, saveResult } from '../utils/media.js';
|
|
5
|
-
import { runYtDlp } from '../utils/ytdlp.js';
|
|
5
|
+
import { cookieArgs, runYtDlp } from '../utils/ytdlp.js';
|
|
6
6
|
import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
|
|
7
7
|
import { CREATOR } from '../constants.js';
|
|
8
8
|
|
|
@@ -38,8 +38,8 @@ function outputFile(base) { return join(TMP_DIR, `${base}.mp4`); }
|
|
|
38
38
|
async function validFile(file) { try { return (await stat(file)).isFile() && (await stat(file)).size > 4096; } catch { return false; } }
|
|
39
39
|
async function cleanup(file) { await unlink(file).catch(() => {}); }
|
|
40
40
|
|
|
41
|
-
async function ytdlpInfo(url) {
|
|
42
|
-
const { stdout } = await runYtDlp(['--dump-json', '--no-download', '--no-playlist', '--skip-download', '--no-warnings', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--user-agent', UA, url]);
|
|
41
|
+
async function ytdlpInfo(url, options = {}) {
|
|
42
|
+
const { stdout } = await runYtDlp(['--dump-json', '--no-download', '--no-playlist', '--skip-download', '--no-warnings', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--user-agent', UA, ...cookieArgs(options), url]);
|
|
43
43
|
const line = stdout.trim().split(/\r?\n/).find(Boolean);
|
|
44
44
|
if (!line) throw new Error('yt-dlp returned no Facebook video information.');
|
|
45
45
|
return JSON.parse(line);
|
|
@@ -48,14 +48,14 @@ function normalizeFallback(data, url) {
|
|
|
48
48
|
const direct = data.url || data.requested_formats?.find(item => item.url)?.url;
|
|
49
49
|
return { creator: CREATOR, platform: 'facebook', id: data.id || idOf(url), title: data.title, author: data.uploader || data.channel, originalUrl: url, formats: direct ? [{ tag: 'BEST', quality: `${data.height || 720}p`, type: 'mp4', url: direct, hasAudio: true }] : [], fallback: true };
|
|
50
50
|
}
|
|
51
|
-
async function facebookFallbackInfo(url) { return normalizeFallback(await ytdlpInfo(url), url); }
|
|
51
|
+
async function facebookFallbackInfo(url, options) { return normalizeFallback(await ytdlpInfo(url, options), url); }
|
|
52
52
|
|
|
53
|
-
async function facebookFallbackDownload(url, dest) {
|
|
53
|
+
async function facebookFallbackDownload(url, dest, options = {}) {
|
|
54
54
|
await mkdir(TMP_DIR, { recursive: true });
|
|
55
55
|
const base = `fb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
56
56
|
const temp = outputFile(base);
|
|
57
57
|
try {
|
|
58
|
-
await runYtDlp(['--no-playlist', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--no-warnings', '--user-agent', UA, '-f', 'bv*[ext=mp4][vcodec^=avc1][height<=
|
|
58
|
+
await runYtDlp(['--no-playlist', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--no-warnings', '--user-agent', UA, '-f', 'bv*[ext=mp4][vcodec^=avc1][height<=1080]+ba[ext=m4a]/b[ext=mp4]/best', '--merge-output-format', 'mp4', '-o', temp, ...cookieArgs(options), url]);
|
|
59
59
|
if (!await validFile(temp)) throw new Error('Facebook yt-dlp output was empty or too small.');
|
|
60
60
|
await rename(temp, dest);
|
|
61
61
|
return dest;
|
|
@@ -82,19 +82,19 @@ async function facebookTier1(url) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** Download metadata for a Facebook video or reel. */
|
|
85
|
-
export async function facebookDl(url) {
|
|
85
|
+
export async function facebookDl(url, options = {}) {
|
|
86
86
|
if (!isFacebookUrl(url)) throw new UnsupportedError('Invalid Facebook URL.');
|
|
87
87
|
try { return await facebookTier1(url); }
|
|
88
88
|
catch (error) {
|
|
89
89
|
if (!fallbackError(error)) throw error;
|
|
90
|
-
try { return await facebookFallbackInfo(url); }
|
|
90
|
+
try { return await facebookFallbackInfo(url, options); }
|
|
91
91
|
catch (fallback) { if (/ENOENT|not found|spawn/i.test(fallback.message)) throw new Error('Facebook blocked. Run npm i yt-dlp-exec or pip install yt-dlp'); throw fallback; }
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/** Save a Facebook video to disk. */
|
|
96
|
-
export async function facebookSave(url, dest = './video.mp4', quality = 'best') {
|
|
97
|
-
const result = await facebookDl(url);
|
|
98
|
-
if (result.fallback) return facebookFallbackDownload(url, dest);
|
|
96
|
+
export async function facebookSave(url, dest = './video.mp4', quality = 'best', options = {}) {
|
|
97
|
+
const result = await facebookDl(url, options);
|
|
98
|
+
if (result.fallback) return facebookFallbackDownload(url, dest, options);
|
|
99
99
|
return saveResult(result, dest, quality, FB_HEADERS);
|
|
100
100
|
}
|
|
@@ -1,59 +1,87 @@
|
|
|
1
|
-
import { mkdir, rename, stat, unlink } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readFile, rename, stat, unlink } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { fetchWithRetry } from '../utils/fetcher.js';
|
|
3
|
+
import { fetchWithRetry, downloadTo } from '../utils/fetcher.js';
|
|
4
4
|
import { saveResult, sortFormats } from '../utils/media.js';
|
|
5
|
-
import { runYtDlp } from '../utils/ytdlp.js';
|
|
5
|
+
import { cookieArgs, runYtDlp } from '../utils/ytdlp.js';
|
|
6
6
|
import { unescapeJson } from '../utils/parser.js';
|
|
7
7
|
import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
|
|
8
8
|
import { CREATOR } from '../constants.js';
|
|
9
9
|
|
|
10
10
|
export const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
|
|
11
11
|
export const TMP_DIR = 'cache/temp';
|
|
12
|
+
export const IG_APP_ID = '936619743392459';
|
|
12
13
|
const IG_HEADERS = { 'User-Agent': UA, Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', Referer: 'https://www.instagram.com/', Origin: 'https://www.instagram.com/' };
|
|
13
|
-
const codeOf = url => new URL(url).pathname.match(/\/(?:p|
|
|
14
|
+
const codeOf = url => new URL(url).pathname.match(/\/(?:reel|reels|p|tv)\/([A-Za-z0-9_-]+)/i)?.[1];
|
|
14
15
|
|
|
15
16
|
export function isInstagramUrl(value) {
|
|
16
|
-
try {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(String(value).trim());
|
|
19
|
+
const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
|
|
20
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && host.endsWith('instagram.com') && /\/(?:reel|reels|p|tv)\//i.test(parsed.pathname);
|
|
21
|
+
} catch { return false; }
|
|
17
22
|
}
|
|
18
23
|
export function cleanInstagramUrl(value) { const parsed = new URL(value); return `${parsed.origin}${parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`}`; }
|
|
24
|
+
async function cookieText(options = {}) { const path = options.cookies || options.cookiesPath; if (!path) return ''; try { return await readFile(path, 'utf8'); } catch { return ''; } }
|
|
25
|
+
function cookieHeader(text) {
|
|
26
|
+
if (!text) return '';
|
|
27
|
+
if (!text.includes('\t')) return text.match(/(?:^|\n)Cookie:\s*(.+)/i)?.[1]?.trim() || text.trim();
|
|
28
|
+
return text.split(/\r?\n/).filter(line => line && !line.startsWith('#')).map(line => line.split('\t')).filter(parts => parts.length >= 7 && parts[5] && parts[6]).map(parts => `${parts[5]}=${parts[6]}`).join('; ');
|
|
29
|
+
}
|
|
30
|
+
async function headersFor(options = {}) { const cookie = cookieHeader(await cookieText(options)); return cookie ? { ...IG_HEADERS, Cookie: cookie } : IG_HEADERS; }
|
|
19
31
|
function fromHtml(html) {
|
|
20
|
-
const find = key => { const
|
|
21
|
-
const urls = [...html.matchAll(/"url"
|
|
22
|
-
return sortFormats([find('video_url') || urls[0], ...urls].filter(Boolean).map((url,
|
|
32
|
+
const find = key => { const match = html.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)); return match && unescapeJson(match[1]); };
|
|
33
|
+
const urls = [...html.matchAll(/"url"\s*:\s*"(https:[^\"]+)"/g)].map(match => unescapeJson(match[1])).filter(url => /(?:scontent|cdninstagram)/i.test(url) && /\.mp4(?:[?&]|$)/i.test(url));
|
|
34
|
+
return sortFormats([find('video_url') || urls[0], ...urls].filter(Boolean).map((url, index) => ({ tag: index ? 'SD' : 'HD', quality: index ? '480p' : '720p', type: 'mp4', url })));
|
|
23
35
|
}
|
|
24
36
|
function fallbackError(error) { return error instanceof PrivateError || error instanceof NotFoundError || /PRIVATE|NOT_FOUND|LOGIN|404|403|empty|accounts\/login/i.test(error?.message || ''); }
|
|
25
|
-
function outputFile(base) { return join(TMP_DIR, `${base}.mp4`); }
|
|
26
37
|
async function validFile(file) { try { return (await stat(file)).isFile() && (await stat(file)).size > 4096; } catch { return false; } }
|
|
27
|
-
|
|
28
|
-
async function
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
async function instagramFallbackDownload(url, dest) {
|
|
40
|
-
await mkdir(TMP_DIR, { recursive: true });
|
|
41
|
-
const base = `ig-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
42
|
-
const temp = outputFile(base);
|
|
38
|
+
|
|
39
|
+
async function runInstagramYtDlp(url, options = {}) {
|
|
40
|
+
const dir = join(process.cwd(), TMP_DIR);
|
|
41
|
+
await mkdir(dir, { recursive: true });
|
|
42
|
+
const tmp = join(dir, `ig-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`);
|
|
43
|
+
const cookie = await cookieText(options);
|
|
44
|
+
const cookies = cookie ? cookieArgs({ cookiesPath: options.cookies || options.cookiesPath }) : [];
|
|
45
|
+
const common = ['--no-playlist', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--no-warnings', '--user-agent', UA, '--add-header', 'Referer:https://www.instagram.com/'];
|
|
46
|
+
const attempts = [
|
|
47
|
+
[...common, '-f', 'best', '-o', tmp, ...cookies, url],
|
|
48
|
+
[...common, '-f', 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best', '--merge-output-format', 'mp4', '-o', tmp, ...cookies, url]
|
|
49
|
+
];
|
|
43
50
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
for (const args of attempts) {
|
|
52
|
+
try { await runYtDlp(args); } catch { /* retry with progressive video plus audio */ }
|
|
53
|
+
if (await validFile(tmp)) return await readFile(tmp);
|
|
54
|
+
await unlink(tmp).catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
} finally { await unlink(tmp).catch(() => {}); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getDirect(id, options = {}) {
|
|
61
|
+
const cookie = cookieHeader(await cookieText(options));
|
|
62
|
+
const headers = cookie ? { 'User-Agent': UA, 'X-IG-App-ID': IG_APP_ID, Accept: '*/*', Cookie: cookie } : { 'User-Agent': UA, 'X-IG-App-ID': IG_APP_ID, Accept: '*/*' };
|
|
63
|
+
for (const pageUrl of [`https://www.instagram.com/reel/${id}/`, `https://www.instagram.com/p/${id}/`, `https://www.instagram.com/tv/${id}/`]) {
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(pageUrl, { headers, redirect: 'follow' });
|
|
66
|
+
if (!response.ok) continue;
|
|
67
|
+
const html = await response.text();
|
|
68
|
+
const match = html.match(/"video_url"\s*:\s*"(https:[^"]+)"/) || html.match(/"video_versions"\s*:\s*\[.*?\{"url"\s*:\s*"(https:[^"]+)"/s);
|
|
69
|
+
if (!match) continue;
|
|
70
|
+
const mediaUrl = unescapeJson(match[1]).replace(/\\u0026/g, '&').replace(/\\/g, '');
|
|
71
|
+
const media = await fetch(mediaUrl, { headers: { 'User-Agent': UA, Referer: 'https://www.instagram.com/' }, redirect: 'follow' });
|
|
72
|
+
if (!media.ok) continue;
|
|
73
|
+
const buffer = Buffer.from(await media.arrayBuffer());
|
|
74
|
+
if (buffer.length > 4096) return buffer;
|
|
75
|
+
} catch { /* try the next direct Instagram page */ }
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
52
78
|
}
|
|
53
79
|
|
|
54
|
-
async function instagramTier1(url, id) {
|
|
55
|
-
const
|
|
56
|
-
|
|
80
|
+
async function instagramTier1(url, id, options = {}) {
|
|
81
|
+
const requestHeaders = await headersFor(options);
|
|
82
|
+
const type = url.match(/\/(reel|reels|p|tv)\//i)?.[1] || 'p';
|
|
83
|
+
const base = `https://www.instagram.com/${type}/${id}/`;
|
|
84
|
+
let response = await fetchWithRetry(`${base}?__a=1&__d=dis`, { headers: requestHeaders });
|
|
57
85
|
let formats = [];
|
|
58
86
|
let item;
|
|
59
87
|
if (response.ok) {
|
|
@@ -62,13 +90,13 @@ async function instagramTier1(url, id) {
|
|
|
62
90
|
item = json.items?.[0] || json.data?.xdt_shortcode_media;
|
|
63
91
|
const media = item?.carousel_media || [item];
|
|
64
92
|
formats = sortFormats(media.flatMap((entry, index) => [
|
|
65
|
-
...(entry?.video_versions || []).map(
|
|
93
|
+
...(entry?.video_versions || []).map(video => ({ tag: index ? `${index + 1}` : 'HD', quality: `${video.height || 720}p`, type: 'mp4', url: video.url })),
|
|
66
94
|
entry?.image_versions2?.candidates?.[0] && { tag: 'IMG', quality: `${entry.image_versions2.candidates[0].width || 1080}x${entry.image_versions2.candidates[0].height || 1080}`, type: 'jpg', url: entry.image_versions2.candidates[0].url }
|
|
67
95
|
]));
|
|
68
96
|
} catch { /* use embed fallback */ }
|
|
69
97
|
}
|
|
70
98
|
if (!formats.length) {
|
|
71
|
-
response = await fetchWithRetry(`${base}embed/`, { headers:
|
|
99
|
+
response = await fetchWithRetry(`${base}embed/`, { headers: requestHeaders });
|
|
72
100
|
const html = await response.text();
|
|
73
101
|
if (response.url.includes('/accounts/login')) throw new PrivateError();
|
|
74
102
|
formats = fromHtml(html);
|
|
@@ -78,22 +106,24 @@ async function instagramTier1(url, id) {
|
|
|
78
106
|
return { creator: CREATOR, platform: 'instagram', id, username: item?.user?.username, author: item?.user?.username, caption: item?.caption?.text, originalUrl: url, formats };
|
|
79
107
|
}
|
|
80
108
|
|
|
81
|
-
|
|
82
|
-
export async function instagramDl(url) {
|
|
109
|
+
export async function instagramDl(url, options = {}) {
|
|
83
110
|
if (!isInstagramUrl(url)) throw new UnsupportedError('Invalid Instagram URL.');
|
|
84
111
|
const cleanUrl = cleanInstagramUrl(url);
|
|
85
112
|
const id = codeOf(cleanUrl);
|
|
86
|
-
try { return await instagramTier1(cleanUrl, id); }
|
|
87
|
-
catch (error) {
|
|
88
|
-
if (!fallbackError(error)) throw error;
|
|
89
|
-
try { return await instagramFallbackInfo(cleanUrl, id); }
|
|
90
|
-
catch (fallback) { if (/ENOENT|not found|spawn/i.test(fallback.message)) throw new Error('Instagram blocked. Run npm i yt-dlp-exec or pip install yt-dlp'); throw fallback; }
|
|
91
|
-
}
|
|
113
|
+
try { return await instagramTier1(cleanUrl, id, options); }
|
|
114
|
+
catch (error) { if (!fallbackError(error)) throw error; return { creator: CREATOR, platform: 'instagram', id, originalUrl: cleanUrl, formats: [], fallback: true }; }
|
|
92
115
|
}
|
|
93
116
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
export async function instagramSave(url, dest = './ig.mp4', quality = 'best', options = {}) {
|
|
118
|
+
if (!isInstagramUrl(url)) throw new UnsupportedError('Invalid Instagram URL.');
|
|
119
|
+
const cleanUrl = cleanInstagramUrl(url);
|
|
120
|
+
const id = codeOf(cleanUrl);
|
|
121
|
+
const ytDlpBuffer = await runInstagramYtDlp(cleanUrl, options);
|
|
122
|
+
if (ytDlpBuffer) { const { writeFile } = await import('node:fs/promises'); await writeFile(dest, ytDlpBuffer); return dest; }
|
|
123
|
+
const directBuffer = await getDirect(id, options);
|
|
124
|
+
if (directBuffer) { const { writeFile } = await import('node:fs/promises'); await writeFile(dest, directBuffer); return dest; }
|
|
125
|
+
try {
|
|
126
|
+
const result = await instagramTier1(cleanUrl, id, options);
|
|
127
|
+
return saveResult(result, dest, quality, await headersFor(options));
|
|
128
|
+
} catch { throw new NotFoundError('NOT_FOUND: Instagram empty media - use --cookies ~/cookies.txt'); }
|
|
99
129
|
}
|
package/src/platforms/tiktok.js
CHANGED
|
@@ -8,6 +8,7 @@ import { saveResult, sortFormats } from '../utils/media.js';
|
|
|
8
8
|
import { extractJson, getPath, unescapeJson } from '../utils/parser.js';
|
|
9
9
|
import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
|
|
10
10
|
import { CREATOR } from '../constants.js';
|
|
11
|
+
import { cookieArgs, runYtDlp, ytDlpDownloadUrl, ytDlpInfoUrl } from '../utils/ytdlp.js';
|
|
11
12
|
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
13
14
|
const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
|
|
@@ -15,64 +16,80 @@ export const TIKWM_API = 'https://www.tikwm.com/api/';
|
|
|
15
16
|
const clean = value => value && unescapeJson(value);
|
|
16
17
|
|
|
17
18
|
export function isTikTokUrl(value) {
|
|
18
|
-
try {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = new URL(String(value).trim());
|
|
21
|
+
const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
|
|
22
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && (host === 'tiktok.com' || host.endsWith('.tiktok.com'));
|
|
23
|
+
} catch { return false; }
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
export function validMediaBuffer(buffer) { return Buffer.isBuffer(buffer) && buffer.length > 4096; }
|
|
22
27
|
|
|
23
|
-
async function
|
|
28
|
+
async function resolveTikTokUrl(url) {
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(url, { method: 'HEAD', redirect: 'follow', headers: { 'User-Agent': UA } });
|
|
31
|
+
return response.url || url;
|
|
32
|
+
} catch { return url; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function tikwmApi(inputUrl) {
|
|
36
|
+
const url = await resolveTikTokUrl(inputUrl);
|
|
24
37
|
const response = await fetchWithRetry(TIKWM_API, {
|
|
25
38
|
method: 'POST',
|
|
26
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded
|
|
27
|
-
body: new URLSearchParams({ url, hd: '1' }).toString()
|
|
39
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA, Accept: '*/*', Referer: 'https://www.tikwm.com/', Origin: 'https://www.tikwm.com' },
|
|
40
|
+
body: new URLSearchParams({ url, hd: '1', full_data: '1' }).toString()
|
|
28
41
|
});
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
let json = response.ok ? await response.json() : null;
|
|
43
|
+
if (!json?.data) {
|
|
44
|
+
const retry = await fetchWithRetry(TIKWM_API, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA, Accept: '*/*', Referer: 'https://www.tikwm.com/' },
|
|
47
|
+
body: new URLSearchParams({ url, hd: '0' }).toString()
|
|
48
|
+
});
|
|
49
|
+
if (!retry.ok) throw new Error(`TIKWM_HTTP_${response.status || retry.status}`);
|
|
50
|
+
json = await retry.json();
|
|
51
|
+
}
|
|
31
52
|
if (json?.code !== 0 || !json.data) throw new NotFoundError('TIKTOK_PRIVATE_OR_NOT_FOUND');
|
|
32
53
|
return json.data;
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
async function getMedia(url) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
const headersList = [
|
|
58
|
+
{ Referer: 'https://www.tiktok.com/', Origin: 'https://www.tiktok.com' },
|
|
59
|
+
{ Referer: 'https://www.tikwm.com/', Origin: 'https://www.tikwm.com' },
|
|
60
|
+
{ Referer: 'https://m.tiktok.com/' }
|
|
61
|
+
];
|
|
62
|
+
let lastError;
|
|
63
|
+
for (const extra of headersList) {
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetchWithRetry(url, { headers: { 'User-Agent': UA, Accept: '*/*', ...extra } }, 0);
|
|
66
|
+
if (!response.ok) { lastError = new Error(`TIKTOK_MEDIA_HTTP_${response.status}`); continue; }
|
|
67
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
68
|
+
if (validMediaBuffer(buffer)) return buffer;
|
|
69
|
+
lastError = new Error('TIKTOK_MEDIA_TOO_SMALL');
|
|
70
|
+
} catch (error) { lastError = error; }
|
|
71
|
+
}
|
|
72
|
+
throw lastError || new Error('TIKTOK_MEDIA_FAILED');
|
|
41
73
|
}
|
|
42
74
|
|
|
43
75
|
function normalizeTikwm(data, originalUrl, id) {
|
|
44
76
|
const author = data.author || {};
|
|
45
|
-
const video = data.hdplay || data.play;
|
|
77
|
+
const video = data.hdplay || data.play || data.wmplay;
|
|
46
78
|
const formats = sortFormats([video && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: video }]);
|
|
47
|
-
return {
|
|
48
|
-
creator: CREATOR,
|
|
49
|
-
platform: 'tiktok',
|
|
50
|
-
id: String(data.id || id),
|
|
51
|
-
username: author.unique_id ? `@${author.unique_id}` : undefined,
|
|
52
|
-
author: author.nickname,
|
|
53
|
-
title: data.title,
|
|
54
|
-
views: data.play_count,
|
|
55
|
-
likes: data.digg_count,
|
|
56
|
-
comments: data.comment_count,
|
|
57
|
-
shares: data.share_count,
|
|
58
|
-
downloads: data.download_count,
|
|
59
|
-
originalUrl: data.share_url || originalUrl,
|
|
60
|
-
formats,
|
|
61
|
-
music: data.music,
|
|
62
|
-
fallback: true
|
|
63
|
-
};
|
|
79
|
+
return { creator: CREATOR, platform: 'tiktok', id: String(data.id || id), username: author.unique_id ? `@${author.unique_id}` : undefined, author: author.nickname, title: data.title, views: data.play_count, likes: data.digg_count, comments: data.comment_count, shares: data.share_count, downloads: data.download_count, originalUrl: data.share_url || originalUrl, formats, music: data.music, fallback: true };
|
|
64
80
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return normalizeTikwm(data, url, id);
|
|
81
|
+
function normalizeYtDlp(data, originalUrl, id) {
|
|
82
|
+
const direct = data.url || data.requested_formats?.find(item => item.url)?.url;
|
|
83
|
+
return { creator: CREATOR, platform: 'tiktok', id: data.id || id, author: data.uploader, title: data.title, originalUrl, formats: direct ? [{ tag: 'BEST', quality: `${data.height || 720}p`, type: 'mp4', url: direct, hasAudio: true }] : [], fallback: true, fallbackMethod: 'yt-dlp' };
|
|
69
84
|
}
|
|
70
85
|
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
async function tiktokFallback(url, id) { return normalizeTikwm(await tikwmApi(url), url, id); }
|
|
87
|
+
|
|
88
|
+
export async function tiktokDl(url, options = {}) {
|
|
73
89
|
if (!isTikTokUrl(url)) throw new UnsupportedError('Invalid TikTok URL.');
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
let resolved = url;
|
|
91
|
+
try { resolved = await resolveShortUrl(url); } catch { resolved = await resolveTikTokUrl(url); }
|
|
92
|
+
const id = resolved.match(/\/video\/(\d+)/)?.[1] || resolved.match(/\/v\/(\d+)/)?.[1] || new URL(resolved).pathname.split('/').filter(Boolean).pop() || url;
|
|
76
93
|
if (!id) throw new UnsupportedError('Invalid TikTok URL.');
|
|
77
94
|
const pageUrl = resolved.match(/\/video\//) ? resolved : `https://www.tiktok.com/@a/video/${id}`;
|
|
78
95
|
try {
|
|
@@ -83,36 +100,38 @@ export async function tiktokDl(url) {
|
|
|
83
100
|
const item = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct');
|
|
84
101
|
const play = clean(video?.playAddr) || clean(html.match(/"playAddr":"([^"]+)"/)?.[1]);
|
|
85
102
|
const download = clean(video?.downloadAddr) || clean(html.match(/"downloadAddr":"([^"]+)"/)?.[1]);
|
|
86
|
-
const formats = sortFormats([
|
|
87
|
-
download && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: download },
|
|
88
|
-
play && { tag: 'WM', quality: '720p', type: 'mp4', isNoWatermark: false, url: play }
|
|
89
|
-
]);
|
|
103
|
+
const formats = sortFormats([download && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: download }, play && { tag: 'WM', quality: '720p', type: 'mp4', isNoWatermark: false, url: play }]);
|
|
90
104
|
if (!formats.length) throw new PrivateError('PRIVATE_OR_NOT_FOUND');
|
|
91
105
|
return { creator: CREATOR, platform: 'tiktok', id, username: item?.author?.uniqueId ? `@${item.author.uniqueId}` : undefined, author: item?.author?.nickname, title: item?.desc, originalUrl: resolved, formats };
|
|
92
|
-
} catch
|
|
93
|
-
|
|
94
|
-
return tiktokFallback(url, id);
|
|
106
|
+
} catch {
|
|
107
|
+
try { return normalizeYtDlp(await ytDlpInfoUrl(url, options), url, id); }
|
|
108
|
+
catch { return tiktokFallback(url, id); }
|
|
95
109
|
}
|
|
96
110
|
}
|
|
97
111
|
|
|
98
|
-
|
|
99
|
-
export async function tiktokDownload(url, format = 'mp4') {
|
|
112
|
+
export async function tiktokDownload(url, format = 'mp4', options = {}) {
|
|
100
113
|
const normalizedFormat = String(format).toLowerCase();
|
|
101
114
|
if (!['mp3', 'mp4'].includes(normalizedFormat)) throw new UnsupportedError('Invalid TikTok format.');
|
|
102
|
-
const result = await tiktokDl(url);
|
|
115
|
+
const result = await tiktokDl(url, options);
|
|
116
|
+
if (result.fallbackMethod === 'yt-dlp') return ytDlpDownloadUrl(url, 'mp4', options);
|
|
103
117
|
if (!result.fallback) {
|
|
104
118
|
const wanted = result.formats[0];
|
|
105
119
|
if (!wanted) throw new NotFoundError('PRIVATE_OR_NOT_FOUND');
|
|
106
|
-
if (normalizedFormat === 'mp4')
|
|
120
|
+
if (normalizedFormat === 'mp4') {
|
|
121
|
+
try { return { buffer: await getMedia(wanted.url), mimetype: 'video/mp4', extension: 'mp4' }; }
|
|
122
|
+
catch { return ytDlpDownloadUrl(url, 'mp4', options); }
|
|
123
|
+
}
|
|
107
124
|
}
|
|
108
125
|
const data = await tikwmApi(url);
|
|
109
|
-
if (normalizedFormat === 'mp4')
|
|
126
|
+
if (normalizedFormat === 'mp4') {
|
|
127
|
+
try { return { buffer: await getMedia(data.hdplay || data.play || data.wmplay), mimetype: 'video/mp4', extension: 'mp4' }; }
|
|
128
|
+
catch { return ytDlpDownloadUrl(url, 'mp4', options); }
|
|
129
|
+
}
|
|
110
130
|
if (data.music) return { buffer: await getMedia(data.music), mimetype: 'audio/mpeg', extension: 'mp3' };
|
|
111
|
-
const video = await getMedia(data.hdplay || data.play);
|
|
131
|
+
const video = await getMedia(data.hdplay || data.play || data.wmplay);
|
|
112
132
|
const dir = join(process.cwd(), 'cache', 'temp');
|
|
113
133
|
const base = join(dir, `tt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
114
|
-
const input = `${base}.mp4`;
|
|
115
|
-
const output = `${base}.mp3`;
|
|
134
|
+
const input = `${base}.mp4`, output = `${base}.mp3`;
|
|
116
135
|
await mkdir(dir, { recursive: true });
|
|
117
136
|
try {
|
|
118
137
|
await writeFile(input, video);
|
|
@@ -120,16 +139,18 @@ export async function tiktokDownload(url, format = 'mp4') {
|
|
|
120
139
|
const buffer = await readFile(output);
|
|
121
140
|
if (!validMediaBuffer(buffer)) throw new Error('TIKTOK_AUDIO_TOO_SMALL');
|
|
122
141
|
return { buffer, mimetype: 'audio/mpeg', extension: 'mp3' };
|
|
123
|
-
} finally {
|
|
124
|
-
await Promise.allSettled([unlink(input), unlink(output)]);
|
|
125
|
-
}
|
|
142
|
+
} finally { await Promise.allSettled([unlink(input), unlink(output)]); }
|
|
126
143
|
}
|
|
127
144
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
export async function tiktokSave(url, dest = './tt.mp4', quality = 'best', options = {}) {
|
|
146
|
+
const result = await tiktokDl(url, options);
|
|
147
|
+
if (result.fallbackMethod === 'yt-dlp') {
|
|
148
|
+
const output = await ytDlpDownloadUrl(url, 'mp4', options);
|
|
149
|
+
await writeFile(dest, output.buffer);
|
|
150
|
+
return dest;
|
|
151
|
+
}
|
|
131
152
|
if (result.fallback) {
|
|
132
|
-
const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4');
|
|
153
|
+
const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4', options);
|
|
133
154
|
await writeFile(dest, output.buffer);
|
|
134
155
|
return dest;
|
|
135
156
|
}
|
package/src/platforms/twitter.js
CHANGED
|
@@ -5,12 +5,23 @@ import { NotFoundError, UnsupportedError } from '../errors.js';
|
|
|
5
5
|
import { CREATOR } from '../constants.js';
|
|
6
6
|
|
|
7
7
|
const idOf = url => new URL(url).pathname.match(/status\/(\d+)/)?.[1];
|
|
8
|
+
async function fxtwitter(id, originalUrl) {
|
|
9
|
+
const response = await fetchWithRetry(`https://api.fxtwitter.com/status/${id}`, { headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' } });
|
|
10
|
+
if (!response.ok) throw new Error(`FXTWITTER_HTTP_${response.status}`);
|
|
11
|
+
const json = await response.json();
|
|
12
|
+
const tweet = json.tweet || json;
|
|
13
|
+
const formats = sortFormats((tweet.media?.all || []).flatMap(item => item.videos || []).map(video => ({ tag: `${video.bitrate || 0}kbps`, quality: `${video.height || 720}p`, type: 'mp4', url: video.url, bitrate: video.bitrate })));
|
|
14
|
+
if (!formats.length) throw new NotFoundError('NO_X_VIDEO');
|
|
15
|
+
return { creator: CREATOR, platform: 'twitter', id, author: tweet.author?.screen_name ? `@${tweet.author.screen_name}` : undefined, title: tweet.text, originalUrl, formats, fallback: true };
|
|
16
|
+
}
|
|
8
17
|
|
|
9
18
|
/** Download metadata and direct MP4 variants for an X/Twitter post. */
|
|
10
19
|
export async function twitterDl(url) {
|
|
11
20
|
const id = idOf(url);
|
|
12
21
|
if (!id) throw new UnsupportedError('Invalid Twitter URL.');
|
|
13
|
-
|
|
22
|
+
let response;
|
|
23
|
+
try { response = await fetchWithRetry(`https://cdn.syndication.twimg.com/tweet-result?id=${id}&lang=en&features=tfw_timeline_list%3D%7B%7D`, { headers: { 'User-Agent': 'Mozilla/5.0' } }); }
|
|
24
|
+
catch { return fxtwitter(id, url); }
|
|
14
25
|
const json = response.ok ? await response.json() : {};
|
|
15
26
|
const media = json.mediaDetails?.flatMap(item => item.video_info?.variants || []) || [];
|
|
16
27
|
let formats = sortFormats(media.filter(v => v.content_type === 'video/mp4' && v.url).map(v => ({ tag: `${Math.round((v.bitrate || 0) / 1000) || 0}kbps`, quality: `${Math.round((v.bitrate || 0) / 1000) || 0}kbps`, type: 'mp4', url: v.url, bitrate: v.bitrate })));
|
|
@@ -19,7 +30,9 @@ export async function twitterDl(url) {
|
|
|
19
30
|
const urls = [...html.matchAll(/https:[^" ]*video\.twimg\.com[^" ]+\.mp4[^" ]*/g)].map(m => unescapeJson(m[0]));
|
|
20
31
|
formats = urls.map((item, index) => ({ tag: index ? '480p' : '720p', quality: index ? '480p' : '720p', type: 'mp4', url: item }));
|
|
21
32
|
}
|
|
22
|
-
if (!formats.length)
|
|
33
|
+
if (!formats.length) {
|
|
34
|
+
try { return await fxtwitter(id, url); } catch { throw new NotFoundError(); }
|
|
35
|
+
}
|
|
23
36
|
return { creator: CREATOR, platform: 'twitter', id, author: json.user?.screen_name ? `@${json.user.screen_name}` : undefined, title: json.text, originalUrl: url, formats };
|
|
24
37
|
}
|
|
25
38
|
|
package/src/platforms/youtube.js
CHANGED
|
@@ -33,8 +33,8 @@ function fromYtDlp(data, url, id) {
|
|
|
33
33
|
const format = direct ? [{ tag: data.height ? `${data.height}p` : 'best', quality: data.height ? `${data.height}p` : 'best', type: 'mp4', hasAudio: Boolean(video?.acodec && video.acodec !== 'none') || Boolean(data.acodec && data.acodec !== 'none'), url: direct, height: data.height, bitrate: data.tbr }] : [];
|
|
34
34
|
return { creator: CREATOR, platform: 'youtube', id, client: 'YT-DLP', title: data.title, author: data.uploader || data.channel, thumbnail: data.thumbnail, originalUrl: url, formats: format, ...(video?.url && audio?.url && video.url !== audio.url ? { needsMux: true, videoUrl: video.url, audioUrl: audio.url } : {}), fallback: true };
|
|
35
35
|
}
|
|
36
|
-
async function ytDlpFallback(url, id, cause) {
|
|
37
|
-
try { return fromYtDlp(await ytDlpInfo(id), url, id); }
|
|
36
|
+
async function ytDlpFallback(url, id, cause, options = {}) {
|
|
37
|
+
try { return fromYtDlp(await ytDlpInfo(id, options), url, id); }
|
|
38
38
|
catch (error) { throw new Error(`${fallbackInstallMessage()} (${cause?.message || error.message})`); }
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -75,16 +75,16 @@ export async function player(id) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/** Download metadata and direct formats for a YouTube video. */
|
|
78
|
-
export async function youtubeDl(url) {
|
|
78
|
+
export async function youtubeDl(url, options = {}) {
|
|
79
79
|
const id = videoId(url);
|
|
80
80
|
if (!id) throw new UnsupportedError('Invalid YouTube URL.');
|
|
81
81
|
let data;
|
|
82
82
|
try { data = await player(id); }
|
|
83
83
|
catch (error) {
|
|
84
84
|
if (!fallbackAllowed(error)) throw error;
|
|
85
|
-
return ytDlpFallback(url, id, error);
|
|
85
|
+
return ytDlpFallback(url, id, error, options);
|
|
86
86
|
}
|
|
87
|
-
if (!data.streamingData) return ytDlpFallback(url, id, new NotFoundError('PRIVATE_OR_AGE_RESTRICTED'));
|
|
87
|
+
if (!data.streamingData) return ytDlpFallback(url, id, new NotFoundError('PRIVATE_OR_AGE_RESTRICTED'), options);
|
|
88
88
|
const all = [...(data.streamingData.formats || []), ...(data.streamingData.adaptiveFormats || [])];
|
|
89
89
|
const formats = sortFormats(all.filter(item => item.url && /^video\/mp4/.test(item.mimeType || '')).map(item => ({ tag: qualityOf(item, 'video'), quality: qualityOf(item, 'video'), type: 'mp4', hasAudio: !item.mimeType?.includes('video-only'), url: item.url, bitrate: item.bitrate, height: item.height }))).sort((a, b) => Number(b.hasAudio) - Number(a.hasAudio));
|
|
90
90
|
if (!formats.length) throw new NotFoundError('NO_PLAYABLE_MP4');
|
|
@@ -95,11 +95,11 @@ export async function youtubeDl(url) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/** Save a YouTube video to disk. */
|
|
98
|
-
export async function youtubeSave(url, dest = './yt.mp4', quality = 'best') {
|
|
99
|
-
const result = await youtubeDl(url);
|
|
98
|
+
export async function youtubeSave(url, dest = './yt.mp4', quality = 'best', options = {}) {
|
|
99
|
+
const result = await youtubeDl(url, options);
|
|
100
100
|
if (result.fallback) {
|
|
101
101
|
const { writeFile } = await import('node:fs/promises');
|
|
102
|
-
const output = await ytDlpDownload(result.id, 'mp4');
|
|
102
|
+
const output = await ytDlpDownload(result.id, 'mp4', options);
|
|
103
103
|
await writeFile(dest, output.buffer);
|
|
104
104
|
return dest;
|
|
105
105
|
}
|
package/src/platforms/ytmp3.js
CHANGED
|
@@ -5,7 +5,7 @@ import { CREATOR } from '../constants.js';
|
|
|
5
5
|
import { fallbackInstallMessage, ytDlpDownload, ytDlpInfo } from '../utils/ytdlp.js';
|
|
6
6
|
|
|
7
7
|
/** Download audio-only metadata from YouTube (m4a/opus, not transcoded MP3). */
|
|
8
|
-
export async function youtubeMp3Dl(url) {
|
|
8
|
+
export async function youtubeMp3Dl(url, options = {}) {
|
|
9
9
|
const id = videoId(url);
|
|
10
10
|
if (!id) throw new UnsupportedError('Invalid YouTube URL.');
|
|
11
11
|
let data;
|
|
@@ -38,11 +38,11 @@ export async function youtubeMp3Dl(url) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** Save the best YouTube audio stream to disk. */
|
|
41
|
-
export async function youtubeMp3Save(url, dest = './audio.m4a', quality = 'best') {
|
|
42
|
-
const result = await youtubeMp3Dl(url);
|
|
41
|
+
export async function youtubeMp3Save(url, dest = './audio.m4a', quality = 'best', options = {}) {
|
|
42
|
+
const result = await youtubeMp3Dl(url, options);
|
|
43
43
|
if (result.fallback) {
|
|
44
44
|
const { writeFile } = await import('node:fs/promises');
|
|
45
|
-
const output = await ytDlpDownload(result.id, 'mp3');
|
|
45
|
+
const output = await ytDlpDownload(result.id, 'mp3', options);
|
|
46
46
|
await writeFile(dest, output.buffer);
|
|
47
47
|
return dest;
|
|
48
48
|
}
|
package/src/utils/ytdlp.js
CHANGED
|
@@ -2,9 +2,8 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { mkdir, readFile, readdir, stat, unlink } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
|
|
5
|
+
import { UA } from './constants.js';
|
|
6
6
|
const execFileAsync = promisify(execFile);
|
|
7
|
-
const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
|
|
8
7
|
const BASE_ARGS = [
|
|
9
8
|
'--no-playlist', '--no-warnings', '--newline', '--no-part', '--no-continue',
|
|
10
9
|
'--retries', '3', '--fragment-retries', '3', '--file-access-retries', '3',
|
|
@@ -40,10 +39,19 @@ export async function runYtDlp(args) {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
export function ytDlpUrl(id) { return `https://www.youtube.com/watch?v=${id}`; }
|
|
42
|
+
export function cookieArgs(options = {}) {
|
|
43
|
+
const args = [];
|
|
44
|
+
if (options.cookiesPath) args.push('--cookies', options.cookiesPath);
|
|
45
|
+
if (options.cookiesFromBrowser) args.push('--cookies-from-browser', options.cookiesFromBrowser);
|
|
46
|
+
return args;
|
|
47
|
+
}
|
|
43
48
|
|
|
44
49
|
/** Get yt-dlp JSON metadata for a video. */
|
|
45
|
-
export async function ytDlpInfo(id) {
|
|
46
|
-
|
|
50
|
+
export async function ytDlpInfo(id, options = {}) {
|
|
51
|
+
return ytDlpInfoUrl(ytDlpUrl(id), options);
|
|
52
|
+
}
|
|
53
|
+
export async function ytDlpInfoUrl(url, options = {}) {
|
|
54
|
+
const { stdout } = await runYtDlp(['--dump-json', '--no-download', '--no-playlist', '--skip-download', '--no-warnings', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--user-agent', UA, ...cookieArgs(options), url]);
|
|
47
55
|
const line = stdout.trim().split(/\r?\n/).find(Boolean);
|
|
48
56
|
if (!line) throw new Error('yt-dlp returned no video information.');
|
|
49
57
|
return JSON.parse(line);
|
|
@@ -63,15 +71,18 @@ async function cleanup(base, dir) {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/** Download a YouTube video or audio fallback into memory, then clean temp files. */
|
|
66
|
-
export async function ytDlpDownload(id, format) {
|
|
74
|
+
export async function ytDlpDownload(id, format, options = {}) {
|
|
75
|
+
return ytDlpDownloadUrl(ytDlpUrl(id), format, options);
|
|
76
|
+
}
|
|
77
|
+
export async function ytDlpDownloadUrl(url, format, options = {}) {
|
|
67
78
|
if (!['mp3', 'mp4'].includes(format)) throw new Error('Invalid yt-dlp format.');
|
|
68
79
|
const dir = join(process.cwd(), 'cache', 'temp');
|
|
69
80
|
await mkdir(dir, { recursive: true });
|
|
70
81
|
const base = `yt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
71
82
|
const output = join(dir, `${base}.%(ext)s`);
|
|
72
83
|
const args = format === 'mp3'
|
|
73
|
-
? [...BASE_ARGS, '-f', 'bestaudio[ext=m4a]/bestaudio', '-x', '--audio-format', 'mp3', '--audio-quality', '0', '-o', output,
|
|
74
|
-
: [...BASE_ARGS, '-f', 'bv*[ext=mp4][vcodec^=avc1][height<=720]+ba[ext=m4a][acodec^=mp4a]/b[ext=mp4][height<=720]', '--merge-output-format', 'mp4', '--remux-video', 'mp4', '-o', output,
|
|
84
|
+
? [...BASE_ARGS, ...cookieArgs(options), '-f', 'bestaudio[ext=m4a]/bestaudio', '-x', '--audio-format', 'mp3', '--audio-quality', '0', '-o', output, url]
|
|
85
|
+
: [...BASE_ARGS, ...cookieArgs(options), '-f', 'bv*[ext=mp4][vcodec^=avc1][height<=720]+ba[ext=m4a][acodec^=mp4a]/b[ext=mp4][height<=720]', '--merge-output-format', 'mp4', '--remux-video', 'mp4', '-o', output, url];
|
|
75
86
|
let file;
|
|
76
87
|
try {
|
|
77
88
|
await runYtDlp(args);
|
package/index.d.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
export interface Format { tag: string; quality: string; type: string; url: string; hasAudio?: boolean; isAudioOnly?: boolean; isNoWatermark?: boolean; }
|
|
2
|
-
export interface DlResult { creator?: string; platform: string; id: string; client?: string; title?: string; author?: string; thumbnail?: string; originalUrl: string; isAudioOnly?: boolean; formats: Format[]; needsMux?: boolean; videoUrl?: string; audioUrl?: string; }
|
|
3
|
-
export interface UniversalOptions { audioOnly?: boolean; quality?: string; noWatermark?: boolean; }
|
|
4
|
-
export interface StreamResult { stream: ReadableStream<Uint8Array>; filename: string; info: DlResult; contentType: string; contentLength?: string | null; }
|
|
5
|
-
export function detectPlatform(url: string): string;
|
|
6
|
-
export function universalDl(url: string, opts?: UniversalOptions): Promise<DlResult>;
|
|
7
|
-
export function universalSave(url: string, dest?: string, opts?: UniversalOptions): Promise<string>;
|
|
8
|
-
export function universalStream(url: string, opts?: UniversalOptions): Promise<StreamResult>;
|
|
9
|
-
export function doctor(options?: { log?: boolean }): Promise<any>;
|
|
10
|
-
export function facebookDl(url: string): Promise<DlResult>;
|
|
11
|
-
export function facebookSave(url: string, dest?: string, quality?: string): Promise<string>;
|
|
12
|
-
export function youtubeDl(url: string): Promise<DlResult>;
|
|
13
|
-
export function youtubeSave(url: string, dest?: string, quality?: string): Promise<string>;
|
|
14
|
-
export function youtubeMp3Dl(url: string): Promise<DlResult>;
|
|
15
|
-
export function youtubeMp3Save(url: string, dest?: string, quality?: string): Promise<string>;
|
|
16
|
-
export function instagramDl(url: string): Promise<DlResult>;
|
|
17
|
-
export function instagramSave(url: string, dest?: string, quality?: string): Promise<string>;
|
|
18
|
-
export function tiktokDl(url: string): Promise<DlResult>;
|
|
19
|
-
export function tiktokSave(url: string, dest?: string, quality?: string): Promise<string>;
|
|
20
|
-
export function tiktokDownload(url: string, format?: 'mp3' | 'mp4'): Promise<{ buffer: Buffer; mimetype: string; extension: string }>;
|
|
21
|
-
export function twitterDl(url: string): Promise<DlResult>;
|
|
22
|
-
export function twitterSave(url: string, dest?: string, quality?: string): Promise<string>;
|
|
23
|
-
export function pinterestDl(url: string): Promise<DlResult>;
|
|
24
|
-
export function pinterestSave(url: string, dest?: string, quality?: string): Promise<string>;
|
|
25
|
-
export function capcutDl(url: string): Promise<DlResult>;
|
|
26
|
-
export function capcutSave(url: string, dest?: string, quality?: string): Promise<string>;
|
|
27
|
-
export class SocialDlError extends Error { code: string; }
|
|
28
|
-
export class BotDetectedError extends SocialDlError {}
|
|
29
|
-
export class PrivateError extends SocialDlError {}
|
|
30
|
-
export class NotFoundError extends SocialDlError {}
|
|
31
|
-
export class UnsupportedError extends SocialDlError {}
|
|
32
|
-
export const CREATOR: string;
|