social-dl 0.9.7 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-dl",
3
- "version": "0.9.7",
3
+ "version": "1.3.0",
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",
@@ -1,100 +1,136 @@
1
- import { mkdir, rename, stat, unlink } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { fetchWithRetry } from '../utils/fetcher.js';
4
- import { sortFormats, saveResult } from '../utils/media.js';
5
- import { cookieArgs, runYtDlp } from '../utils/ytdlp.js';
6
- import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
7
- import { CREATOR } from '../constants.js';
8
-
9
- export const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
10
- export const TMP_DIR = 'cache/temp';
11
- const FB_HEADERS = { 'User-Agent': UA, Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', Referer: 'https://www.facebook.com/', Origin: 'https://www.facebook.com/' };
12
- const unescape = value => { try { return JSON.parse(`"${value}"`); } catch { return value.replace(/\\u0025/g, '%').replace(/\\u0026/g, '&').replace(/\\\//g, '/'); } };
1
+ import { execFile } from 'child_process'
2
+ import { promisify } from 'util'
3
+ import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises'
4
+ import { join } from 'path'
5
+ const execFileAsync = promisify(execFile)
6
+ const TMP_DIR = 'cache/temp'
7
+ const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36'
8
+ const FB_HEADERS = {
9
+ 'User-Agent': UA,
10
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
11
+ 'Accept-Language': 'en-US,en;q=0.9',
12
+ 'Referer': 'https://www.facebook.com/',
13
+ 'Sec-Fetch-Mode': 'navigate'
14
+ }
13
15
 
14
- export function isFacebookUrl(value) {
16
+ export const isUrl = (value) => {
15
17
  try {
16
- const parsed = new URL(value);
17
- return /facebook\.com\/share\/r\//i.test(parsed.href)
18
- || /fb\.watch/i.test(parsed.hostname)
19
- || /facebook\.com\/reel\//i.test(parsed.href)
20
- || /web\.facebook\.com\/reel\//i.test(parsed.href)
21
- || /facebook\.com\/watch/i.test(parsed.href);
22
- } catch { return false; }
18
+ const u = new URL(String(value).trim())
19
+ const h = u.hostname.toLowerCase().replace(/^www\./,'')
20
+ return (h==='facebook.com' || h.endsWith('.facebook.com') || h==='fb.watch' || h==='fb.com') && /reel|watch|video|share|story/i.test(u.pathname + u.search)
21
+ } catch { return false }
23
22
  }
24
23
 
25
- function extract(html) {
26
- const find = name => { const match = html.match(new RegExp(`"${name}":"([^"]+)"`)); return match && unescape(match[1]); };
27
- const hd = find('browser_native_hd_url') || find('playable_url_quality_hd');
28
- const sd = find('browser_native_sd_url') || find('playable_url');
29
- return sortFormats([
30
- hd && { tag: 'HD', quality: '720p', type: 'mp4', url: hd },
31
- sd && { tag: 'SD', quality: '360p', type: 'mp4', url: sd }
32
- ]);
24
+ export function cleanFbUrl(value){
25
+ try{
26
+ const u = new URL(String(value).trim())
27
+ for(const k of [...u.searchParams.keys()]){
28
+ if(k.startsWith('utm_') || k==='fbclid' || k==='mibextid') u.searchParams.delete(k)
29
+ }
30
+ return u.toString()
31
+ }catch{ return String(value).split('?')[0] }
33
32
  }
34
33
 
35
- function idOf(url) { return url.match(/reel\/(\d+)|videos\/(\d+)|[?&]v=(\d+)|\/(\d+)\/?/)?.slice(1).find(Boolean) || url; }
36
- function fallbackError(error) { return error instanceof PrivateError || error instanceof NotFoundError || /PRIVATE|NOT_FOUND|empty|404|403|login|checkpoint/i.test(error?.message || ''); }
37
- function outputFile(base) { return join(TMP_DIR, `${base}.mp4`); }
38
- async function validFile(file) { try { return (await stat(file)).isFile() && (await stat(file)).size > 4096; } catch { return false; } }
39
- async function cleanup(file) { await unlink(file).catch(() => {}); }
40
-
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
- const line = stdout.trim().split(/\r?\n/).find(Boolean);
44
- if (!line) throw new Error('yt-dlp returned no Facebook video information.');
45
- return JSON.parse(line);
34
+ async function validFile(file){
35
+ try{ const s = await stat(file); return s.isFile() && s.size > 4096 }catch{ return false }
46
36
  }
47
- function normalizeFallback(data, url) {
48
- const direct = data.url || data.requested_formats?.find(item => item.url)?.url;
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 };
37
+
38
+ async function runFbYtDlp(url){
39
+ const dir = join(process.cwd(), TMP_DIR); await mkdir(dir, {recursive:true})
40
+ const tmp = join(dir, `fb-${Date.now()}-${Math.random().toString(36).slice(2,8)}.mp4`)
41
+ const attempts = [
42
+ ['--remote-components','ejs:github','--js-runtimes','node','--no-playlist','--no-warnings','-o',tmp, url],
43
+ ['--remote-components','ejs:github','--js-runtimes','node','--no-playlist','-f','bv*+ba/best','--merge-output-format','mp4','-o',tmp, url],
44
+ ['--remote-components','ejs:github','--js-runtimes','node','--no-playlist','--user-agent',UA,'-o',tmp, url]
45
+ ]
46
+ try{
47
+ for(const args of attempts){
48
+ try{ await execFileAsync('yt-dlp', args, {maxBuffer:64*1024*1024}) }catch(e){}
49
+ if(await validFile(tmp)) return await readFile(tmp)
50
+ await unlink(tmp).catch(()=>{})
51
+ }
52
+ return null
53
+ }finally{ await unlink(tmp).catch(()=>{}) }
50
54
  }
51
- async function facebookFallbackInfo(url, options) { return normalizeFallback(await ytdlpInfo(url, options), url); }
52
55
 
53
- async function facebookFallbackDownload(url, dest, options = {}) {
54
- await mkdir(TMP_DIR, { recursive: true });
55
- const base = `fb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
56
- const temp = outputFile(base);
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<=1080]+ba[ext=m4a]/b[ext=mp4]/best', '--merge-output-format', 'mp4', '-o', temp, ...cookieArgs(options), url]);
59
- if (!await validFile(temp)) throw new Error('Facebook yt-dlp output was empty or too small.');
60
- await rename(temp, dest);
61
- return dest;
62
- } catch (error) {
63
- if (/ENOENT|not found|spawn/i.test(error.message)) throw new Error('Facebook blocked. Run npm i yt-dlp-exec or pip install yt-dlp');
64
- throw error;
65
- } finally { await cleanup(temp); }
56
+ function extractFbVideoUrl(html){
57
+ // Facebook stores urls escaped
58
+ const patterns = [
59
+ /"browser_native_hd_url":"([^"]+)"/,
60
+ /"browser_native_sd_url":"([^"]+)"/,
61
+ /"playable_url_quality_hd":"([^"]+)"/,
62
+ /"playable_url":"([^"]+)"/,
63
+ /"playable_url_low":"([^"]+)"/,
64
+ /hd_src":"([^"]+)"/,
65
+ /sd_src":"([^"]+)"/
66
+ ]
67
+ for(const re of patterns){
68
+ const m = html.match(re)
69
+ if(m){
70
+ let url = m[1]
71
+ .replace(/\\u0025/g,'%').replace(/\\u0026/g,'&').replace(/\\\//g,'/').replace(/\\/g,'')
72
+ try{ url = JSON.parse(`"${url}"`) }catch{}
73
+ try{ url = decodeURIComponent(url) }catch{}
74
+ if(url.startsWith('https://') && url.includes('.mp4')) return url
75
+ }
76
+ }
77
+ return null
66
78
  }
67
79
 
68
- async function facebookTier1(url) {
69
- const resolved = await fetchWithRetry(url, { headers: FB_HEADERS });
70
- const originalUrl = resolved.url || url;
71
- const id = idOf(originalUrl);
72
- let html = await (await fetchWithRetry(originalUrl, { headers: FB_HEADERS })).text();
73
- let formats = extract(html);
74
- if (html.length < 5000 || formats.length === 0) {
75
- await new Promise(resolve => setTimeout(resolve, 1000));
76
- const basicUrl = originalUrl.replace(/www\.facebook\.com/i, 'mbasic.facebook.com');
77
- html = await (await fetchWithRetry(basicUrl, { headers: FB_HEADERS })).text();
78
- formats = extract(html);
80
+ async function getDirectFb(url){
81
+ // Try mobile and desktop UAs
82
+ const urlsToTry = [url, url.replace('www.facebook.com','m.facebook.com')]
83
+ for(const tryUrl of urlsToTry){
84
+ try{
85
+ const res = await fetch(tryUrl, { headers: FB_HEADERS, redirect:'follow' })
86
+ if(!res.ok) continue
87
+ const html = await res.text()
88
+ const videoUrl = extractFbVideoUrl(html)
89
+ if(!videoUrl) continue
90
+ const vRes = await fetch(videoUrl, { headers: { 'User-Agent': UA, 'Referer':'https://www.facebook.com/' }, redirect:'follow' })
91
+ if(!vRes.ok) continue
92
+ const buf = Buffer.from(await vRes.arrayBuffer())
93
+ if(buf.length > 4096) return buf
94
+ }catch{}
79
95
  }
80
- if (!formats.length) { if (/login|checkpoint|not found/i.test(html)) throw new PrivateError(); throw new NotFoundError('PRIVATE_OR_NOT_FOUND'); }
81
- return { creator: CREATOR, platform: 'facebook', id, originalUrl, formats };
96
+ return null
97
+ }
98
+
99
+ export async function info(url){
100
+ if(!isUrl(url)) throw new Error('Invalid Facebook URL')
101
+ const clean = cleanFbUrl(url)
102
+ return { url: clean, title: 'Facebook video', platform: 'facebook', originalUrl: clean }
82
103
  }
83
104
 
84
- /** Download metadata for a Facebook video or reel. */
85
- export async function facebookDl(url, options = {}) {
86
- if (!isFacebookUrl(url)) throw new UnsupportedError('Invalid Facebook URL.');
87
- try { return await facebookTier1(url); }
88
- catch (error) {
89
- if (!fallbackError(error)) throw error;
90
- try { return await facebookFallbackInfo(url, options); }
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; }
105
+ export async function download(url, format='mp4'){
106
+ if(!isUrl(url)) throw new Error('Invalid Facebook URL')
107
+ const clean = cleanFbUrl(url)
108
+ // Tier1 yt-dlp (currently broken upstream but keep)
109
+ let buffer = await runFbYtDlp(clean)
110
+ // Tier2 direct scrape fallback - works even when yt-dlp parser broken
111
+ if(!buffer) buffer = await getDirectFb(clean)
112
+ if(!buffer || buffer.length <= 4096) throw new Error('NOT_FOUND: Facebook empty media - FB changed html. This is upstream yt-dlp bug, try different public reel or wait for yt-dlp update.')
113
+ format = String(format||'mp4').toLowerCase()
114
+ if(format === 'mp4') return { buffer, mimetype:'video/mp4', extension:'mp4' }
115
+ const dir = join(process.cwd(), TMP_DIR); await mkdir(dir,{recursive:true})
116
+ const base = `fb-${Date.now()}-${Math.random().toString(36).slice(2,8)}`
117
+ const input = join(dir, `${base}.mp4`); const output = join(dir, `${base}.mp3`)
118
+ try{
119
+ await writeFile(input, buffer)
120
+ await execFileAsync('ffmpeg',['-y','-i',input,'-vn','-c:a','libmp3lame','-q:a','2',output])
121
+ const audio = await readFile(output)
122
+ if(!audio || audio.length <= 4096) throw new Error('EMPTY_AUDIO')
123
+ return { buffer: audio, mimetype:'audio/mpeg', extension:'mp3' }
124
+ }finally{
125
+ await unlink(input).catch(()=>{})
126
+ await unlink(output).catch(()=>{})
92
127
  }
93
128
  }
94
129
 
95
- /** Save a Facebook video to disk. */
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
- return saveResult(result, dest, quality, FB_HEADERS);
130
+ export const isFacebookUrl = isUrl
131
+ export const facebookDl = info
132
+ export const facebookSave = async (url, dest='./fb.mp4') => {
133
+ const { buffer } = await download(url, 'mp4')
134
+ await writeFile(dest, buffer)
135
+ return dest
100
136
  }
@@ -0,0 +1,100 @@
1
+ import { mkdir, rename, stat, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { fetchWithRetry } from '../utils/fetcher.js';
4
+ import { sortFormats, saveResult } from '../utils/media.js';
5
+ import { cookieArgs, runYtDlp } from '../utils/ytdlp.js';
6
+ import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
7
+ import { CREATOR } from '../constants.js';
8
+
9
+ export const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
10
+ export const TMP_DIR = 'cache/temp';
11
+ const FB_HEADERS = { 'User-Agent': UA, Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', Referer: 'https://www.facebook.com/', Origin: 'https://www.facebook.com/' };
12
+ const unescape = value => { try { return JSON.parse(`"${value}"`); } catch { return value.replace(/\\u0025/g, '%').replace(/\\u0026/g, '&').replace(/\\\//g, '/'); } };
13
+
14
+ export function isFacebookUrl(value) {
15
+ try {
16
+ const parsed = new URL(value);
17
+ return /facebook\.com\/share\/r\//i.test(parsed.href)
18
+ || /fb\.watch/i.test(parsed.hostname)
19
+ || /facebook\.com\/reel\//i.test(parsed.href)
20
+ || /web\.facebook\.com\/reel\//i.test(parsed.href)
21
+ || /facebook\.com\/watch/i.test(parsed.href);
22
+ } catch { return false; }
23
+ }
24
+
25
+ function extract(html) {
26
+ const find = name => { const match = html.match(new RegExp(`"${name}":"([^"]+)"`)); return match && unescape(match[1]); };
27
+ const hd = find('browser_native_hd_url') || find('playable_url_quality_hd');
28
+ const sd = find('browser_native_sd_url') || find('playable_url');
29
+ return sortFormats([
30
+ hd && { tag: 'HD', quality: '720p', type: 'mp4', url: hd },
31
+ sd && { tag: 'SD', quality: '360p', type: 'mp4', url: sd }
32
+ ]);
33
+ }
34
+
35
+ function idOf(url) { return url.match(/reel\/(\d+)|videos\/(\d+)|[?&]v=(\d+)|\/(\d+)\/?/)?.slice(1).find(Boolean) || url; }
36
+ function fallbackError(error) { return error instanceof PrivateError || error instanceof NotFoundError || /PRIVATE|NOT_FOUND|empty|404|403|login|checkpoint/i.test(error?.message || ''); }
37
+ function outputFile(base) { return join(TMP_DIR, `${base}.mp4`); }
38
+ async function validFile(file) { try { return (await stat(file)).isFile() && (await stat(file)).size > 4096; } catch { return false; } }
39
+ async function cleanup(file) { await unlink(file).catch(() => {}); }
40
+
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
+ const line = stdout.trim().split(/\r?\n/).find(Boolean);
44
+ if (!line) throw new Error('yt-dlp returned no Facebook video information.');
45
+ return JSON.parse(line);
46
+ }
47
+ function normalizeFallback(data, url) {
48
+ const direct = data.url || data.requested_formats?.find(item => item.url)?.url;
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
+ }
51
+ async function facebookFallbackInfo(url, options) { return normalizeFallback(await ytdlpInfo(url, options), url); }
52
+
53
+ async function facebookFallbackDownload(url, dest, options = {}) {
54
+ await mkdir(TMP_DIR, { recursive: true });
55
+ const base = `fb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
56
+ const temp = outputFile(base);
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<=1080]+ba[ext=m4a]/b[ext=mp4]/best', '--merge-output-format', 'mp4', '-o', temp, ...cookieArgs(options), url]);
59
+ if (!await validFile(temp)) throw new Error('Facebook yt-dlp output was empty or too small.');
60
+ await rename(temp, dest);
61
+ return dest;
62
+ } catch (error) {
63
+ if (/ENOENT|not found|spawn/i.test(error.message)) throw new Error('Facebook blocked. Run npm i yt-dlp-exec or pip install yt-dlp');
64
+ throw error;
65
+ } finally { await cleanup(temp); }
66
+ }
67
+
68
+ async function facebookTier1(url) {
69
+ const resolved = await fetchWithRetry(url, { headers: FB_HEADERS });
70
+ const originalUrl = resolved.url || url;
71
+ const id = idOf(originalUrl);
72
+ let html = await (await fetchWithRetry(originalUrl, { headers: FB_HEADERS })).text();
73
+ let formats = extract(html);
74
+ if (html.length < 5000 || formats.length === 0) {
75
+ await new Promise(resolve => setTimeout(resolve, 1000));
76
+ const basicUrl = originalUrl.replace(/www\.facebook\.com/i, 'mbasic.facebook.com');
77
+ html = await (await fetchWithRetry(basicUrl, { headers: FB_HEADERS })).text();
78
+ formats = extract(html);
79
+ }
80
+ if (!formats.length) { if (/login|checkpoint|not found/i.test(html)) throw new PrivateError(); throw new NotFoundError('PRIVATE_OR_NOT_FOUND'); }
81
+ return { creator: CREATOR, platform: 'facebook', id, originalUrl, formats };
82
+ }
83
+
84
+ /** Download metadata for a Facebook video or reel. */
85
+ export async function facebookDl(url, options = {}) {
86
+ if (!isFacebookUrl(url)) throw new UnsupportedError('Invalid Facebook URL.');
87
+ try { return await facebookTier1(url); }
88
+ catch (error) {
89
+ if (!fallbackError(error)) throw error;
90
+ try { return await facebookFallbackInfo(url, options); }
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
+ }
93
+ }
94
+
95
+ /** Save a Facebook video to disk. */
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
+ return saveResult(result, dest, quality, FB_HEADERS);
100
+ }
@@ -1,129 +1,107 @@
1
- import { mkdir, readFile, rename, stat, unlink } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { fetchWithRetry, downloadTo } from '../utils/fetcher.js';
4
- import { saveResult, sortFormats } from '../utils/media.js';
5
- import { cookieArgs, runYtDlp } from '../utils/ytdlp.js';
6
- import { unescapeJson } from '../utils/parser.js';
7
- import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
8
- import { CREATOR } from '../constants.js';
1
+ import { execFile } from 'child_process'
2
+ import { promisify } from 'util'
3
+ import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises'
4
+ import { join } from 'path'
5
+ const execFileAsync = promisify(execFile)
6
+ const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36'
7
+ const TMP_DIR = 'cache/temp'
8
+ const IG_APP_ID = '936619743392459'
9
+ 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/' }
9
10
 
10
- export const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
11
- export const TMP_DIR = 'cache/temp';
12
- export const IG_APP_ID = '936619743392459';
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/' };
14
- const codeOf = url => new URL(url).pathname.match(/\/(?:reel|reels|p|tv)\/([A-Za-z0-9_-]+)/i)?.[1];
11
+ const codeOf = url => new URL(url).pathname.match(/\/(?:reel|reels|p|tv)\/([A-Za-z0-9_-]+)/i)?.[1]
15
12
 
16
- export function isInstagramUrl(value) {
13
+ export const isUrl = (value) => {
17
14
  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; }
15
+ const parsed = new URL(String(value).trim())
16
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '')
17
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && host.endsWith('instagram.com') && /\/(?:reel|reels|p|tv)\//i.test(parsed.pathname)
18
+ } catch { return false }
22
19
  }
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('; ');
20
+
21
+ export function cleanIgUrl(value){
22
+ try{
23
+ const u = new URL(String(value).trim())
24
+ for(const k of [...u.searchParams.keys()]){
25
+ if(k.startsWith('utm_') || k==='igsh' || k==='igshid' || k.toLowerCase().includes('stkn') || k==='fbclid') u.searchParams.delete(k)
26
+ }
27
+ return `${u.origin}${u.pathname.endsWith('/')? u.pathname : `${u.pathname}/`}`
28
+ }catch{ return String(value).split('?')[0] }
29
29
  }
30
- async function headersFor(options = {}) { const cookie = cookieHeader(await cookieText(options)); return cookie ? { ...IG_HEADERS, Cookie: cookie } : IG_HEADERS; }
31
- function fromHtml(html) {
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 })));
30
+ export const cleanInstagramUrl = cleanIgUrl
31
+
32
+ async function validFile(file){
33
+ try{ const s = await stat(file); return s.isFile() && s.size > 4096 }catch{ return false }
35
34
  }
36
- function fallbackError(error) { return error instanceof PrivateError || error instanceof NotFoundError || /PRIVATE|NOT_FOUND|LOGIN|404|403|empty|accounts\/login/i.test(error?.message || ''); }
37
- async function validFile(file) { try { return (await stat(file)).isFile() && (await stat(file)).size > 4096; } catch { return false; } }
38
35
 
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
- ];
50
- try {
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(() => {});
36
+ async function runInstagramYtDlp(url){
37
+ const dir = join(process.cwd(), TMP_DIR); await mkdir(dir, {recursive:true})
38
+ const tmp = join(dir, `ig-${Date.now()}-${Math.random().toString(36).slice(2,8)}.mp4`)
39
+ const baseArgs = ['--remote-components','ejs:github','--js-runtimes','node','--no-playlist','-o',tmp, url]
40
+ const fallbackArgs = ['--remote-components','ejs:github','--js-runtimes','node','--no-playlist','-f','bv*+ba/best','--merge-output-format','mp4','-o',tmp, url]
41
+ try{
42
+ for(const args of [baseArgs, fallbackArgs]){
43
+ try{ await execFileAsync('yt-dlp', args, {maxBuffer:64*1024*1024}) }catch(e){ console.error('yt-dlp failed:', e?.stderr || e?.message) }
44
+ if(await validFile(tmp)) return await readFile(tmp)
45
+ await unlink(tmp).catch(()=>{})
55
46
  }
56
- return null;
57
- } finally { await unlink(tmp).catch(() => {}); }
47
+ return null
48
+ }finally{ await unlink(tmp).catch(()=>{}) }
58
49
  }
59
50
 
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 */ }
51
+ async function getDirect(id){
52
+ for(const pageUrl of [`https://www.instagram.com/reel/${id}/`, `https://www.instagram.com/p/${id}/`, `https://www.instagram.com/tv/${id}/`]){
53
+ try{
54
+ const res = await fetch(pageUrl, { headers: IG_HEADERS, redirect:'follow' })
55
+ if(!res.ok) continue
56
+ const html = await res.text()
57
+ const m = html.match(/"video_url"\s*:\s*"(https:[^"]+)"/) || html.match(/"video_versions"\s*:\s*\[.*?\{"url"\s*:\s*"(https:[^"]+)"/s)
58
+ if(!m) continue
59
+ let mediaUrl = m[1].replace(/\\u0026/g,'&').replace(/\\/g,'').replace(/\u0026/g,'&')
60
+ try{ mediaUrl = JSON.parse(`"${mediaUrl}"`) }catch{}
61
+ const media = await fetch(mediaUrl, { headers:{'User-Agent':UA, Referer:'https://www.instagram.com/'}, redirect:'follow' })
62
+ if(!media.ok) continue
63
+ const buf = Buffer.from(await media.arrayBuffer())
64
+ if(buf.length > 4096) return buf
65
+ }catch{}
76
66
  }
77
- return null;
67
+ return null
78
68
  }
79
69
 
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 });
85
- let formats = [];
86
- let item;
87
- if (response.ok) {
88
- try {
89
- const json = await response.json();
90
- item = json.items?.[0] || json.data?.xdt_shortcode_media;
91
- const media = item?.carousel_media || [item];
92
- formats = sortFormats(media.flatMap((entry, index) => [
93
- ...(entry?.video_versions || []).map(video => ({ tag: index ? `${index + 1}` : 'HD', quality: `${video.height || 720}p`, type: 'mp4', url: video.url })),
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 }
95
- ]));
96
- } catch { /* use embed fallback */ }
97
- }
98
- if (!formats.length) {
99
- response = await fetchWithRetry(`${base}embed/`, { headers: requestHeaders });
100
- const html = await response.text();
101
- if (response.url.includes('/accounts/login')) throw new PrivateError();
102
- formats = fromHtml(html);
103
- if (!formats.length) { const image = html.match(/"display_url":"([^"]+)"/); if (image) formats.push({ tag: 'IMG', quality: '1080x1080', type: 'jpg', url: unescapeJson(image[1]) }); }
104
- }
105
- if (!formats.length) throw new NotFoundError('NOT_FOUND');
106
- return { creator: CREATOR, platform: 'instagram', id, username: item?.user?.username, author: item?.user?.username, caption: item?.caption?.text, originalUrl: url, formats };
70
+ export async function info(url){
71
+ if(!isUrl(url)) throw new Error('Invalid Instagram URL')
72
+ const clean = cleanIgUrl(url)
73
+ const id = codeOf(clean)
74
+ return { id, url: clean, title: 'IG Reel', originalUrl: clean, thumbnail: null, platform: 'instagram' }
107
75
  }
108
76
 
109
- export async function instagramDl(url, options = {}) {
110
- if (!isInstagramUrl(url)) throw new UnsupportedError('Invalid Instagram URL.');
111
- const cleanUrl = cleanInstagramUrl(url);
112
- const id = codeOf(cleanUrl);
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 }; }
77
+ export async function download(url, format='mp4'){
78
+ if(!isUrl(url)) throw new Error('Invalid Instagram URL')
79
+ const clean = cleanIgUrl(url)
80
+ const id = codeOf(clean)
81
+ let buffer = await runInstagramYtDlp(clean)
82
+ if(!buffer) buffer = await getDirect(id)
83
+ if(!buffer || buffer.length <= 4096) throw new Error('NOT_FOUND: Instagram empty media - IG blocks datacenter. Test in Termux, not Cody.')
84
+ format = String(format||'mp4').toLowerCase()
85
+ if(format === 'mp4') return { buffer, mimetype:'video/mp4', extension:'mp4' }
86
+ const dir = join(process.cwd(), TMP_DIR); await mkdir(dir,{recursive:true})
87
+ const base = `ig-${Date.now()}-${Math.random().toString(36).slice(2,8)}`
88
+ const input = join(dir, `${base}.mp4`); const output = join(dir, `${base}.mp3`)
89
+ try{
90
+ await writeFile(input, buffer)
91
+ await execFileAsync('ffmpeg',['-y','-i',input,'-vn','-c:a','libmp3lame','-q:a','2',output])
92
+ const audio = await readFile(output)
93
+ if(!audio || audio.length <= 4096) throw new Error('EMPTY_AUDIO')
94
+ return { buffer: audio, mimetype:'audio/mpeg', extension:'mp3' }
95
+ }finally{
96
+ await unlink(input).catch(()=>{})
97
+ await unlink(output).catch(()=>{})
98
+ }
115
99
  }
116
100
 
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'); }
101
+ export const isInstagramUrl = isUrl
102
+ export const instagramDl = info
103
+ export const instagramSave = async (url, dest='./ig.mp4', quality='best') => {
104
+ const { buffer } = await download(url, 'mp4')
105
+ await writeFile(dest, buffer)
106
+ return dest
129
107
  }
@@ -0,0 +1,129 @@
1
+ import { mkdir, readFile, rename, stat, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { fetchWithRetry, downloadTo } from '../utils/fetcher.js';
4
+ import { saveResult, sortFormats } from '../utils/media.js';
5
+ import { cookieArgs, runYtDlp } from '../utils/ytdlp.js';
6
+ import { unescapeJson } from '../utils/parser.js';
7
+ import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
8
+ import { CREATOR } from '../constants.js';
9
+
10
+ export const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
11
+ export const TMP_DIR = 'cache/temp';
12
+ export const IG_APP_ID = '936619743392459';
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/' };
14
+ const codeOf = url => new URL(url).pathname.match(/\/(?:reel|reels|p|tv)\/([A-Za-z0-9_-]+)/i)?.[1];
15
+
16
+ export function isInstagramUrl(value) {
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; }
22
+ }
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; }
31
+ function fromHtml(html) {
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 })));
35
+ }
36
+ function fallbackError(error) { return error instanceof PrivateError || error instanceof NotFoundError || /PRIVATE|NOT_FOUND|LOGIN|404|403|empty|accounts\/login/i.test(error?.message || ''); }
37
+ async function validFile(file) { try { return (await stat(file)).isFile() && (await stat(file)).size > 4096; } catch { return false; } }
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
+ ];
50
+ try {
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;
78
+ }
79
+
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 });
85
+ let formats = [];
86
+ let item;
87
+ if (response.ok) {
88
+ try {
89
+ const json = await response.json();
90
+ item = json.items?.[0] || json.data?.xdt_shortcode_media;
91
+ const media = item?.carousel_media || [item];
92
+ formats = sortFormats(media.flatMap((entry, index) => [
93
+ ...(entry?.video_versions || []).map(video => ({ tag: index ? `${index + 1}` : 'HD', quality: `${video.height || 720}p`, type: 'mp4', url: video.url })),
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 }
95
+ ]));
96
+ } catch { /* use embed fallback */ }
97
+ }
98
+ if (!formats.length) {
99
+ response = await fetchWithRetry(`${base}embed/`, { headers: requestHeaders });
100
+ const html = await response.text();
101
+ if (response.url.includes('/accounts/login')) throw new PrivateError();
102
+ formats = fromHtml(html);
103
+ if (!formats.length) { const image = html.match(/"display_url":"([^"]+)"/); if (image) formats.push({ tag: 'IMG', quality: '1080x1080', type: 'jpg', url: unescapeJson(image[1]) }); }
104
+ }
105
+ if (!formats.length) throw new NotFoundError('NOT_FOUND');
106
+ return { creator: CREATOR, platform: 'instagram', id, username: item?.user?.username, author: item?.user?.username, caption: item?.caption?.text, originalUrl: url, formats };
107
+ }
108
+
109
+ export async function instagramDl(url, options = {}) {
110
+ if (!isInstagramUrl(url)) throw new UnsupportedError('Invalid Instagram URL.');
111
+ const cleanUrl = cleanInstagramUrl(url);
112
+ const id = codeOf(cleanUrl);
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 }; }
115
+ }
116
+
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'); }
129
+ }
@@ -0,0 +1,110 @@
1
+ import { execFile } from 'child_process'
2
+ import { promisify } from 'util'
3
+ import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises'
4
+ import { join } from 'path'
5
+ const execFileAsync = promisify(execFile)
6
+ const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36'
7
+ const TMP_DIR = 'cache/temp'
8
+ const IG_APP_ID = '936619743392459'
9
+ 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/' }
10
+
11
+ const codeOf = url => new URL(url).pathname.match(/\/(?:reel|reels|p|tv)\/([A-Za-z0-9_-]+)/i)?.[1]
12
+
13
+ export const isUrl = (value) => {
14
+ try {
15
+ const parsed = new URL(String(value).trim())
16
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '')
17
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && host.endsWith('instagram.com') && /\/(?:reel|reels|p|tv)\//i.test(parsed.pathname)
18
+ } catch { return false }
19
+ }
20
+
21
+ export function cleanIgUrl(value){
22
+ try{
23
+ const u = new URL(String(value).trim())
24
+ for(const k of [...u.searchParams.keys()]){
25
+ if(k.startsWith('utm_') || k==='igsh' || k==='igshid' || k.toLowerCase().includes('stkn') || k==='fbclid') u.searchParams.delete(k)
26
+ }
27
+ return `${u.origin}${u.pathname.endsWith('/')? u.pathname : `${u.pathname}/`}`
28
+ }catch{ return String(value).split('?')[0] }
29
+ }
30
+ export const cleanInstagramUrl = cleanIgUrl
31
+
32
+ async function validFile(file){
33
+ try{ const s = await stat(file); return s.isFile() && s.size > 4096 }catch{ return false }
34
+ }
35
+
36
+ async function runInstagramYtDlp(url){
37
+ const dir = join(process.cwd(), TMP_DIR); await mkdir(dir, {recursive:true})
38
+ const tmp = join(dir, `ig-${Date.now()}-${Math.random().toString(36).slice(2,8)}.mp4`)
39
+ const common = ['--no-playlist','--remote-components','ejs:github','--js-runtimes','node','--no-warnings','--user-agent',UA,'--add-header','Referer:https://www.instagram.com/']
40
+ const attempts = [
41
+ [...common,'-f','best','-o',tmp, url],
42
+ [...common,'-f','bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best','--merge-output-format','mp4','-o',tmp, url]
43
+ ]
44
+ try{
45
+ for(const args of attempts){
46
+ try{ await execFileAsync('yt-dlp', args, {maxBuffer:64*1024*1024}) }catch{}
47
+ if(await validFile(tmp)) return await readFile(tmp)
48
+ await unlink(tmp).catch(()=>{})
49
+ }
50
+ return null
51
+ }finally{ await unlink(tmp).catch(()=>{}) }
52
+ }
53
+
54
+ async function getDirect(id){
55
+ for(const pageUrl of [`https://www.instagram.com/reel/${id}/`, `https://www.instagram.com/p/${id}/`, `https://www.instagram.com/tv/${id}/`]){
56
+ try{
57
+ const res = await fetch(pageUrl, { headers: IG_HEADERS, redirect:'follow' })
58
+ if(!res.ok) continue
59
+ const html = await res.text()
60
+ const m = html.match(/"video_url"\s*:\s*"(https:[^"]+)"/) || html.match(/"video_versions"\s*:\s*\[.*?\{"url"\s*:\s*"(https:[^"]+)"/s)
61
+ if(!m) continue
62
+ let mediaUrl = m[1].replace(/\\u0026/g,'&').replace(/\\/g,'').replace(/\u0026/g,'&')
63
+ try{ mediaUrl = JSON.parse(`"${mediaUrl}"`) }catch{}
64
+ const media = await fetch(mediaUrl, { headers:{'User-Agent':UA, Referer:'https://www.instagram.com/'}, redirect:'follow' })
65
+ if(!media.ok) continue
66
+ const buf = Buffer.from(await media.arrayBuffer())
67
+ if(buf.length > 4096) return buf
68
+ }catch{}
69
+ }
70
+ return null
71
+ }
72
+
73
+ export async function info(url){
74
+ if(!isUrl(url)) throw new Error('Invalid Instagram URL')
75
+ const clean = cleanIgUrl(url)
76
+ const id = codeOf(clean)
77
+ return { id, url: clean, title: 'IG Reel', originalUrl: clean, thumbnail: null, platform: 'instagram' }
78
+ }
79
+
80
+ export async function download(url, format='mp4'){
81
+ if(!isUrl(url)) throw new Error('Invalid Instagram URL')
82
+ const clean = cleanIgUrl(url)
83
+ const id = codeOf(clean)
84
+ let buffer = await runInstagramYtDlp(clean)
85
+ if(!buffer) buffer = await getDirect(id)
86
+ if(!buffer || buffer.length <= 4096) throw new Error('NOT_FOUND: Instagram empty media - IG blocks datacenter. Test in Termux, not Cody.')
87
+ format = String(format||'mp4').toLowerCase()
88
+ if(format === 'mp4') return { buffer, mimetype:'video/mp4', extension:'mp4' }
89
+ const dir = join(process.cwd(), TMP_DIR); await mkdir(dir,{recursive:true})
90
+ const base = `ig-${Date.now()}-${Math.random().toString(36).slice(2,8)}`
91
+ const input = join(dir, `${base}.mp4`); const output = join(dir, `${base}.mp3`)
92
+ try{
93
+ await writeFile(input, buffer)
94
+ await execFileAsync('ffmpeg',['-y','-i',input,'-vn','-c:a','libmp3lame','-q:a','2',output])
95
+ const audio = await readFile(output)
96
+ if(!audio || audio.length <= 4096) throw new Error('EMPTY_AUDIO')
97
+ return { buffer: audio, mimetype:'audio/mpeg', extension:'mp3' }
98
+ }finally{
99
+ await unlink(input).catch(()=>{})
100
+ await unlink(output).catch(()=>{})
101
+ }
102
+ }
103
+
104
+ export const isInstagramUrl = isUrl
105
+ export const instagramDl = info
106
+ export const instagramSave = async (url, dest='./ig.mp4', quality='best') => {
107
+ const { buffer } = await download(url, 'mp4')
108
+ await writeFile(dest, buffer)
109
+ return dest
110
+ }
@@ -1,163 +1,62 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
4
- import { join } from 'node:path';
5
- import { fetchWithRetry } from '../utils/fetcher.js';
6
- import { resolveShortUrl } from '../utils/resolver.js';
7
- import { saveResult, sortFormats } from '../utils/media.js';
8
- import { extractJson, getPath, unescapeJson } from '../utils/parser.js';
9
- import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
10
- import { CREATOR } from '../constants.js';
11
- import { cookieArgs, runYtDlp, ytDlpDownloadUrl, ytDlpInfoUrl } from '../utils/ytdlp.js';
12
-
13
- const execFileAsync = promisify(execFile);
14
- const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
15
- export const TIKWM_API = 'https://www.tikwm.com/api/';
16
- const clean = value => value && unescapeJson(value);
17
-
18
- export function isTikTokUrl(value) {
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; }
24
- }
25
-
26
- export function validMediaBuffer(buffer) { return Buffer.isBuffer(buffer) && buffer.length > 4096; }
27
-
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);
37
- const response = await fetchWithRetry(TIKWM_API, {
38
- method: 'POST',
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()
41
- });
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
- }
52
- if (json?.code !== 0 || !json.data) throw new NotFoundError('TIKTOK_PRIVATE_OR_NOT_FOUND');
53
- return json.data;
54
- }
55
-
56
- async function getMedia(url) {
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');
73
- }
74
-
75
- function normalizeTikwm(data, originalUrl, id) {
76
- const author = data.author || {};
77
- const video = data.hdplay || data.play || data.wmplay;
78
- const formats = sortFormats([video && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: video }]);
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 };
80
- }
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' };
84
- }
85
-
86
- async function tiktokFallback(url, id) { return normalizeTikwm(await tikwmApi(url), url, id); }
87
-
88
- export async function tiktokDl(url, options = {}) {
89
- if (!isTikTokUrl(url)) throw new UnsupportedError('Invalid TikTok URL.');
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;
93
- if (!id) throw new UnsupportedError('Invalid TikTok URL.');
94
- const pageUrl = resolved.match(/\/video\//) ? resolved : `https://www.tiktok.com/@a/video/${id}`;
95
- try {
96
- const response = await fetchWithRetry(pageUrl, { headers: { 'User-Agent': UA, Accept: 'text/html' } });
97
- const html = await response.text();
98
- const json = extractJson(html, '__UNIVERSAL_DATA_FOR_REHYDRATION__');
99
- const video = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct.video');
100
- const item = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct');
101
- const play = clean(video?.playAddr) || clean(html.match(/"playAddr":"([^"]+)"/)?.[1]);
102
- const download = clean(video?.downloadAddr) || clean(html.match(/"downloadAddr":"([^"]+)"/)?.[1]);
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 }]);
104
- if (!formats.length) throw new PrivateError('PRIVATE_OR_NOT_FOUND');
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 };
106
- } catch {
107
- try { return normalizeYtDlp(await ytDlpInfoUrl(url, options), url, id); }
108
- catch { return tiktokFallback(url, id); }
109
- }
110
- }
111
-
112
- export async function tiktokDownload(url, format = 'mp4', options = {}) {
113
- const normalizedFormat = String(format).toLowerCase();
114
- if (!['mp3', 'mp4'].includes(normalizedFormat)) throw new UnsupportedError('Invalid TikTok format.');
115
- const result = await tiktokDl(url, options);
116
- if (result.fallbackMethod === 'yt-dlp') return ytDlpDownloadUrl(url, 'mp4', options);
117
- if (!result.fallback) {
118
- const wanted = result.formats[0];
119
- if (!wanted) throw new NotFoundError('PRIVATE_OR_NOT_FOUND');
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
- }
124
- }
125
- const data = await tikwmApi(url);
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
- }
130
- if (data.music) return { buffer: await getMedia(data.music), mimetype: 'audio/mpeg', extension: 'mp3' };
131
- const video = await getMedia(data.hdplay || data.play || data.wmplay);
132
- const dir = join(process.cwd(), 'cache', 'temp');
133
- const base = join(dir, `tt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
134
- const input = `${base}.mp4`, output = `${base}.mp3`;
135
- await mkdir(dir, { recursive: true });
136
- try {
137
- await writeFile(input, video);
138
- await execFileAsync('ffmpeg', ['-y', '-i', input, '-vn', '-c:a', 'libmp3lame', '-q:a', '2', output], { maxBuffer: 32 * 1024 * 1024 });
139
- const buffer = await readFile(output);
140
- if (!validMediaBuffer(buffer)) throw new Error('TIKTOK_AUDIO_TOO_SMALL');
141
- return { buffer, mimetype: 'audio/mpeg', extension: 'mp3' };
142
- } finally { await Promise.allSettled([unlink(input), unlink(output)]); }
143
- }
144
-
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
- }
152
- if (result.fallback) {
153
- const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4', options);
154
- await writeFile(dest, output.buffer);
155
- return dest;
156
- }
157
- try { return await saveResult(result, dest, quality, { Referer: 'https://www.tiktok.com/' }); }
158
- catch {
159
- const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4', options);
160
- await writeFile(dest, output.buffer);
161
- return dest;
162
- }
1
+ import{execFile}from'child_process'
2
+ import{promisify}from'util'
3
+ import{mkdir,readFile,unlink,writeFile}from'fs/promises'
4
+ import{join}from'path'
5
+ const execFileAsync=promisify(execFile)
6
+ const API='https://www.tikwm.com/api/'
7
+ const UA='Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36'
8
+ const run=async args=>{
9
+ try{
10
+ const{stdout,stderr}=await execFileAsync('ffmpeg',args,{maxBuffer:32*1024*1024})
11
+ return{stdout:String(stdout||''),stderr:String(stderr||'')}
12
+ }catch(e){
13
+ throw new Error(String(e?.stderr||e?.stdout||e?.message||'FFmpeg falló').trim())
14
+ }
15
+ }
16
+ const isUrl=x=>{
17
+ try{
18
+ const u=new URL(String(x||'').trim()),h=u.hostname.toLowerCase().replace(/^www\./,'')
19
+ return(u.protocol==='http:'||u.protocol==='https:')&&(h==='tiktok.com'||h.endsWith('.tiktok.com'))
20
+ }catch{return false}
21
+ }
22
+ const api=async url=>{
23
+ const r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':UA,Referer:'https://www.tikwm.com/'},body:new URLSearchParams({url,hd:'1'})})
24
+ if(!r.ok)throw new Error(`TikWM respondió ${r.status}.`)
25
+ const x=await r.json()
26
+ if(x?.code!==0||!x?.data)throw new Error(x?.msg||'TikWM no pudo procesar el enlace.')
27
+ return x.data
28
+ }
29
+ const get=async url=>{
30
+ const r=await fetch(url,{headers:{'User-Agent':UA,Referer:'https://www.tikwm.com/'}})
31
+ if(!r.ok)throw new Error(`No se pudo descargar el archivo (${r.status}).`)
32
+ const b=Buffer.from(await r.arrayBuffer())
33
+ if(b.length<=4096)throw new Error('El archivo descargado está vacío.')
34
+ return b
35
+ }
36
+ export async function info(url){
37
+ if(!isUrl(url))throw new Error('Enlace de TikTok inválido.')
38
+ const x=await api(url)
39
+ return{id:x.id||null,url:x.share_url||url,title:x.title||'Sin título',author:x.author?.unique_id||x.author?.nickname||'',nickname:x.author?.nickname||'',views:Number(x.play_count)||null,likes:Number(x.digg_count)||null,comments:Number(x.comment_count)||null,shares:Number(x.share_count)||null,duration:Number(x.duration)||null,thumbnail:x.origin_cover||x.cover||null,video:x.hdplay||x.play||null,music:x.music||null}
40
+ }
41
+ export async function download(url,format){
42
+ if(!isUrl(url))throw new Error('Enlace de TikTok inválido.')
43
+ format=String(format||'').toLowerCase().trim()
44
+ if(!['mp3','mp4'].includes(format))throw new Error('Formato inválido.')
45
+ const x=await api(url)
46
+ if(format==='mp3'&&x.music){const buffer=await get(x.music);return{buffer,mimetype:'audio/mpeg',extension:'mp3'}}
47
+ if(!x.play&&!x.hdplay)throw new Error('TikWM no proporcionó el vídeo.')
48
+ const buffer=await get(x.hdplay||x.play)
49
+ if(format==='mp4')return{buffer,mimetype:'video/mp4',extension:'mp4'}
50
+ const dir=join(process.cwd(),'cache','temp');await mkdir(dir,{recursive:true})
51
+ const base=`tt-${Date.now()}-${Math.random().toString(36).slice(2,8)}`;const input=join(dir,`${base}.mp4`);const output=join(dir,`${base}.mp3`)
52
+ try{await writeFile(input,buffer);await run(['-y','-i',input,'-vn','-c:a','libmp3lame','-q:a','2',output]);const audio=await readFile(output);if(audio.length<=4096)throw new Error('No se pudo generar el audio.');return{buffer:audio,mimetype:'audio/mpeg',extension:'mp3'}}finally{await unlink(input).catch(()=>{});await unlink(output).catch(()=>{})}
53
+ }
54
+ export {isUrl}
55
+ export const isTiktokUrl = isUrl
56
+ export const tiktokDl = info
57
+ export const tiktokSave = async (url, dest='./tt.mp4') => {
58
+ const { buffer } = await download(url, 'mp4')
59
+ const { writeFile } = await import('fs/promises')
60
+ await writeFile(dest, buffer)
61
+ return dest
163
62
  }
@@ -0,0 +1,163 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { fetchWithRetry } from '../utils/fetcher.js';
6
+ import { resolveShortUrl } from '../utils/resolver.js';
7
+ import { saveResult, sortFormats } from '../utils/media.js';
8
+ import { extractJson, getPath, unescapeJson } from '../utils/parser.js';
9
+ import { NotFoundError, PrivateError, UnsupportedError } from '../errors.js';
10
+ import { CREATOR } from '../constants.js';
11
+ import { cookieArgs, runYtDlp, ytDlpDownloadUrl, ytDlpInfoUrl } from '../utils/ytdlp.js';
12
+
13
+ const execFileAsync = promisify(execFile);
14
+ const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
15
+ export const TIKWM_API = 'https://www.tikwm.com/api/';
16
+ const clean = value => value && unescapeJson(value);
17
+
18
+ export function isTikTokUrl(value) {
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; }
24
+ }
25
+
26
+ export function validMediaBuffer(buffer) { return Buffer.isBuffer(buffer) && buffer.length > 4096; }
27
+
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);
37
+ const response = await fetchWithRetry(TIKWM_API, {
38
+ method: 'POST',
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()
41
+ });
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
+ }
52
+ if (json?.code !== 0 || !json.data) throw new NotFoundError('TIKTOK_PRIVATE_OR_NOT_FOUND');
53
+ return json.data;
54
+ }
55
+
56
+ async function getMedia(url) {
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');
73
+ }
74
+
75
+ function normalizeTikwm(data, originalUrl, id) {
76
+ const author = data.author || {};
77
+ const video = data.hdplay || data.play || data.wmplay;
78
+ const formats = sortFormats([video && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: video }]);
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 };
80
+ }
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' };
84
+ }
85
+
86
+ async function tiktokFallback(url, id) { return normalizeTikwm(await tikwmApi(url), url, id); }
87
+
88
+ export async function tiktokDl(url, options = {}) {
89
+ if (!isTikTokUrl(url)) throw new UnsupportedError('Invalid TikTok URL.');
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;
93
+ if (!id) throw new UnsupportedError('Invalid TikTok URL.');
94
+ const pageUrl = resolved.match(/\/video\//) ? resolved : `https://www.tiktok.com/@a/video/${id}`;
95
+ try {
96
+ const response = await fetchWithRetry(pageUrl, { headers: { 'User-Agent': UA, Accept: 'text/html' } });
97
+ const html = await response.text();
98
+ const json = extractJson(html, '__UNIVERSAL_DATA_FOR_REHYDRATION__');
99
+ const video = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct.video');
100
+ const item = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct');
101
+ const play = clean(video?.playAddr) || clean(html.match(/"playAddr":"([^"]+)"/)?.[1]);
102
+ const download = clean(video?.downloadAddr) || clean(html.match(/"downloadAddr":"([^"]+)"/)?.[1]);
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 }]);
104
+ if (!formats.length) throw new PrivateError('PRIVATE_OR_NOT_FOUND');
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 };
106
+ } catch {
107
+ try { return normalizeYtDlp(await ytDlpInfoUrl(url, options), url, id); }
108
+ catch { return tiktokFallback(url, id); }
109
+ }
110
+ }
111
+
112
+ export async function tiktokDownload(url, format = 'mp4', options = {}) {
113
+ const normalizedFormat = String(format).toLowerCase();
114
+ if (!['mp3', 'mp4'].includes(normalizedFormat)) throw new UnsupportedError('Invalid TikTok format.');
115
+ const result = await tiktokDl(url, options);
116
+ if (result.fallbackMethod === 'yt-dlp') return ytDlpDownloadUrl(url, 'mp4', options);
117
+ if (!result.fallback) {
118
+ const wanted = result.formats[0];
119
+ if (!wanted) throw new NotFoundError('PRIVATE_OR_NOT_FOUND');
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
+ }
124
+ }
125
+ const data = await tikwmApi(url);
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
+ }
130
+ if (data.music) return { buffer: await getMedia(data.music), mimetype: 'audio/mpeg', extension: 'mp3' };
131
+ const video = await getMedia(data.hdplay || data.play || data.wmplay);
132
+ const dir = join(process.cwd(), 'cache', 'temp');
133
+ const base = join(dir, `tt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
134
+ const input = `${base}.mp4`, output = `${base}.mp3`;
135
+ await mkdir(dir, { recursive: true });
136
+ try {
137
+ await writeFile(input, video);
138
+ await execFileAsync('ffmpeg', ['-y', '-i', input, '-vn', '-c:a', 'libmp3lame', '-q:a', '2', output], { maxBuffer: 32 * 1024 * 1024 });
139
+ const buffer = await readFile(output);
140
+ if (!validMediaBuffer(buffer)) throw new Error('TIKTOK_AUDIO_TOO_SMALL');
141
+ return { buffer, mimetype: 'audio/mpeg', extension: 'mp3' };
142
+ } finally { await Promise.allSettled([unlink(input), unlink(output)]); }
143
+ }
144
+
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
+ }
152
+ if (result.fallback) {
153
+ const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4', options);
154
+ await writeFile(dest, output.buffer);
155
+ return dest;
156
+ }
157
+ try { return await saveResult(result, dest, quality, { Referer: 'https://www.tiktok.com/' }); }
158
+ catch {
159
+ const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4', options);
160
+ await writeFile(dest, output.buffer);
161
+ return dest;
162
+ }
163
+ }