social-dl 0.9.5 → 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/package.json +1 -1
- package/src/platforms/instagram.js +69 -62
- package/src/platforms/tiktok.js +64 -54
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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",
|
|
@@ -9,72 +9,78 @@ 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
|
-
async function headersFor(options = {}) { return options.cookiesPath ? { ...IG_HEADERS, Cookie: await readFile(options.cookiesPath, 'utf8') } : IG_HEADERS; }
|
|
14
|
+
const codeOf = url => new URL(url).pathname.match(/\/(?:reel|reels|p|tv)\/([A-Za-z0-9_-]+)/i)?.[1];
|
|
15
15
|
|
|
16
16
|
export function isInstagramUrl(value) {
|
|
17
|
-
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; }
|
|
18
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('; ');
|
|
29
|
+
}
|
|
30
|
+
async function headersFor(options = {}) { const cookie = cookieHeader(await cookieText(options)); return cookie ? { ...IG_HEADERS, Cookie: cookie } : IG_HEADERS; }
|
|
20
31
|
function fromHtml(html) {
|
|
21
|
-
const find = key => { const
|
|
22
|
-
const urls = [...html.matchAll(/"url"
|
|
23
|
-
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 })));
|
|
24
35
|
}
|
|
25
36
|
function fallbackError(error) { return error instanceof PrivateError || error instanceof NotFoundError || /PRIVATE|NOT_FOUND|LOGIN|404|403|empty|accounts\/login/i.test(error?.message || ''); }
|
|
26
|
-
function outputFile(base) { return join(TMP_DIR, `${base}.mp4`); }
|
|
27
37
|
async function validFile(file) { try { return (await stat(file)).isFile() && (await stat(file)).size > 4096; } catch { return false; } }
|
|
28
|
-
|
|
29
|
-
async function
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
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(() => {}); }
|
|
38
58
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
if (!match) return null;
|
|
45
|
-
try { return decodeURIComponent(unescapeJson(match[1])); } catch { return unescapeJson(match[1]); }
|
|
46
|
-
};
|
|
47
|
-
for (const endpoint of [`https://www.ddinstagram.com/reel/${id}/`, ...(cookie ? [`https://www.instagram.com/reel/${id}/`] : [])]) {
|
|
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}/`]) {
|
|
48
64
|
try {
|
|
49
|
-
const response = await
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 */ }
|
|
53
76
|
}
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
async function instagramFallbackInfo(url, id, options = {}) {
|
|
57
|
-
try { return normalizeFallback(await ytdlpInfo(url, options), url, id); }
|
|
58
|
-
catch { return ddinstagramFallback(url, id, options); }
|
|
59
|
-
}
|
|
60
|
-
async function instagramFallbackDownload(url, dest, options = {}) {
|
|
61
|
-
await mkdir(TMP_DIR, { recursive: true });
|
|
62
|
-
const base = `ig-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
63
|
-
const temp = outputFile(base);
|
|
64
|
-
try {
|
|
65
|
-
await runYtDlp(['--no-playlist', '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--no-warnings', '--user-agent', UA, '--add-header', 'Referer:https://www.instagram.com/', '-f', 'best', '--merge-output-format', 'mp4', '-o', temp, ...cookieArgs(options), url]);
|
|
66
|
-
if (!await validFile(temp)) throw new Error('Instagram yt-dlp output was empty or too small.');
|
|
67
|
-
await rename(temp, dest);
|
|
68
|
-
return dest;
|
|
69
|
-
} catch (error) {
|
|
70
|
-
if (/ENOENT|not found|spawn/i.test(error.message)) throw new Error('Instagram blocked. Run npm i yt-dlp-exec or pip install yt-dlp');
|
|
71
|
-
throw error;
|
|
72
|
-
} finally { await cleanup(temp); }
|
|
77
|
+
return null;
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
async function instagramTier1(url, id, options = {}) {
|
|
76
81
|
const requestHeaders = await headersFor(options);
|
|
77
|
-
const
|
|
82
|
+
const type = url.match(/\/(reel|reels|p|tv)\//i)?.[1] || 'p';
|
|
83
|
+
const base = `https://www.instagram.com/${type}/${id}/`;
|
|
78
84
|
let response = await fetchWithRetry(`${base}?__a=1&__d=dis`, { headers: requestHeaders });
|
|
79
85
|
let formats = [];
|
|
80
86
|
let item;
|
|
@@ -84,7 +90,7 @@ async function instagramTier1(url, id, options = {}) {
|
|
|
84
90
|
item = json.items?.[0] || json.data?.xdt_shortcode_media;
|
|
85
91
|
const media = item?.carousel_media || [item];
|
|
86
92
|
formats = sortFormats(media.flatMap((entry, index) => [
|
|
87
|
-
...(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 })),
|
|
88
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 }
|
|
89
95
|
]));
|
|
90
96
|
} catch { /* use embed fallback */ }
|
|
@@ -100,23 +106,24 @@ async function instagramTier1(url, id, options = {}) {
|
|
|
100
106
|
return { creator: CREATOR, platform: 'instagram', id, username: item?.user?.username, author: item?.user?.username, caption: item?.caption?.text, originalUrl: url, formats };
|
|
101
107
|
}
|
|
102
108
|
|
|
103
|
-
/** Download metadata and direct formats for an Instagram post or reel. */
|
|
104
109
|
export async function instagramDl(url, options = {}) {
|
|
105
110
|
if (!isInstagramUrl(url)) throw new UnsupportedError('Invalid Instagram URL.');
|
|
106
111
|
const cleanUrl = cleanInstagramUrl(url);
|
|
107
112
|
const id = codeOf(cleanUrl);
|
|
108
113
|
try { return await instagramTier1(cleanUrl, id, options); }
|
|
109
|
-
catch (error) {
|
|
110
|
-
if (!fallbackError(error)) throw error;
|
|
111
|
-
try { return await instagramFallbackInfo(cleanUrl, id, options); }
|
|
112
|
-
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; }
|
|
113
|
-
}
|
|
114
|
+
catch (error) { if (!fallbackError(error)) throw error; return { creator: CREATOR, platform: 'instagram', id, originalUrl: cleanUrl, formats: [], fallback: true }; }
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
/** Save an Instagram video or image. */
|
|
117
117
|
export async function instagramSave(url, dest = './ig.mp4', quality = 'best', options = {}) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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'); }
|
|
122
129
|
}
|
package/src/platforms/tiktok.js
CHANGED
|
@@ -8,7 +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 { ytDlpDownloadUrl, ytDlpInfoUrl } from '../utils/ytdlp.js';
|
|
11
|
+
import { cookieArgs, runYtDlp, ytDlpDownloadUrl, ytDlpInfoUrl } from '../utils/ytdlp.js';
|
|
12
12
|
|
|
13
13
|
const execFileAsync = promisify(execFile);
|
|
14
14
|
const UA = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36';
|
|
@@ -16,68 +16,79 @@ export const TIKWM_API = 'https://www.tikwm.com/api/';
|
|
|
16
16
|
const clean = value => value && unescapeJson(value);
|
|
17
17
|
|
|
18
18
|
export function isTikTokUrl(value) {
|
|
19
|
-
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; }
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export function validMediaBuffer(buffer) { return Buffer.isBuffer(buffer) && buffer.length > 4096; }
|
|
23
27
|
|
|
24
|
-
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);
|
|
25
37
|
const response = await fetchWithRetry(TIKWM_API, {
|
|
26
38
|
method: 'POST',
|
|
27
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded
|
|
28
|
-
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()
|
|
29
41
|
});
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
}
|
|
32
52
|
if (json?.code !== 0 || !json.data) throw new NotFoundError('TIKTOK_PRIVATE_OR_NOT_FOUND');
|
|
33
53
|
return json.data;
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
async function getMedia(url) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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');
|
|
42
73
|
}
|
|
43
74
|
|
|
44
75
|
function normalizeTikwm(data, originalUrl, id) {
|
|
45
76
|
const author = data.author || {};
|
|
46
|
-
const video = data.hdplay || data.play;
|
|
77
|
+
const video = data.hdplay || data.play || data.wmplay;
|
|
47
78
|
const formats = sortFormats([video && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: video }]);
|
|
48
|
-
return {
|
|
49
|
-
creator: CREATOR,
|
|
50
|
-
platform: 'tiktok',
|
|
51
|
-
id: String(data.id || id),
|
|
52
|
-
username: author.unique_id ? `@${author.unique_id}` : undefined,
|
|
53
|
-
author: author.nickname,
|
|
54
|
-
title: data.title,
|
|
55
|
-
views: data.play_count,
|
|
56
|
-
likes: data.digg_count,
|
|
57
|
-
comments: data.comment_count,
|
|
58
|
-
shares: data.share_count,
|
|
59
|
-
downloads: data.download_count,
|
|
60
|
-
originalUrl: data.share_url || originalUrl,
|
|
61
|
-
formats,
|
|
62
|
-
music: data.music,
|
|
63
|
-
fallback: true
|
|
64
|
-
};
|
|
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 };
|
|
65
80
|
}
|
|
66
81
|
function normalizeYtDlp(data, originalUrl, id) {
|
|
67
82
|
const direct = data.url || data.requested_formats?.find(item => item.url)?.url;
|
|
68
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
|
-
async function tiktokFallback(url, id) {
|
|
72
|
-
const data = await tikwmApi(url);
|
|
73
|
-
return normalizeTikwm(data, url, id);
|
|
74
|
-
}
|
|
86
|
+
async function tiktokFallback(url, id) { return normalizeTikwm(await tikwmApi(url), url, id); }
|
|
75
87
|
|
|
76
|
-
/** Download metadata and direct CDN formats for a TikTok video. */
|
|
77
88
|
export async function tiktokDl(url, options = {}) {
|
|
78
89
|
if (!isTikTokUrl(url)) throw new UnsupportedError('Invalid TikTok URL.');
|
|
79
90
|
let resolved = url;
|
|
80
|
-
try { resolved = await resolveShortUrl(url); } catch {
|
|
91
|
+
try { resolved = await resolveShortUrl(url); } catch { resolved = await resolveTikTokUrl(url); }
|
|
81
92
|
const id = resolved.match(/\/video\/(\d+)/)?.[1] || resolved.match(/\/v\/(\d+)/)?.[1] || new URL(resolved).pathname.split('/').filter(Boolean).pop() || url;
|
|
82
93
|
if (!id) throw new UnsupportedError('Invalid TikTok URL.');
|
|
83
94
|
const pageUrl = resolved.match(/\/video\//) ? resolved : `https://www.tiktok.com/@a/video/${id}`;
|
|
@@ -89,36 +100,38 @@ export async function tiktokDl(url, options = {}) {
|
|
|
89
100
|
const item = getPath(json, '__DEFAULT_SCOPE__.webapp.video-detail.videoData.itemInfo.itemStruct');
|
|
90
101
|
const play = clean(video?.playAddr) || clean(html.match(/"playAddr":"([^"]+)"/)?.[1]);
|
|
91
102
|
const download = clean(video?.downloadAddr) || clean(html.match(/"downloadAddr":"([^"]+)"/)?.[1]);
|
|
92
|
-
const formats = sortFormats([
|
|
93
|
-
download && { tag: 'NOWM', quality: '720p', type: 'mp4', isNoWatermark: true, url: download },
|
|
94
|
-
play && { tag: 'WM', quality: '720p', type: 'mp4', isNoWatermark: false, url: play }
|
|
95
|
-
]);
|
|
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 }]);
|
|
96
104
|
if (!formats.length) throw new PrivateError('PRIVATE_OR_NOT_FOUND');
|
|
97
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 };
|
|
98
|
-
} catch
|
|
106
|
+
} catch {
|
|
99
107
|
try { return normalizeYtDlp(await ytDlpInfoUrl(url, options), url, id); }
|
|
100
108
|
catch { return tiktokFallback(url, id); }
|
|
101
109
|
}
|
|
102
110
|
}
|
|
103
111
|
|
|
104
|
-
|
|
105
|
-
export async function tiktokDownload(url, format = 'mp4') {
|
|
112
|
+
export async function tiktokDownload(url, format = 'mp4', options = {}) {
|
|
106
113
|
const normalizedFormat = String(format).toLowerCase();
|
|
107
114
|
if (!['mp3', 'mp4'].includes(normalizedFormat)) throw new UnsupportedError('Invalid TikTok format.');
|
|
108
|
-
const result = await tiktokDl(url);
|
|
115
|
+
const result = await tiktokDl(url, options);
|
|
116
|
+
if (result.fallbackMethod === 'yt-dlp') return ytDlpDownloadUrl(url, 'mp4', options);
|
|
109
117
|
if (!result.fallback) {
|
|
110
118
|
const wanted = result.formats[0];
|
|
111
119
|
if (!wanted) throw new NotFoundError('PRIVATE_OR_NOT_FOUND');
|
|
112
|
-
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
|
+
}
|
|
113
124
|
}
|
|
114
125
|
const data = await tikwmApi(url);
|
|
115
|
-
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
|
+
}
|
|
116
130
|
if (data.music) return { buffer: await getMedia(data.music), mimetype: 'audio/mpeg', extension: 'mp3' };
|
|
117
|
-
const video = await getMedia(data.hdplay || data.play);
|
|
131
|
+
const video = await getMedia(data.hdplay || data.play || data.wmplay);
|
|
118
132
|
const dir = join(process.cwd(), 'cache', 'temp');
|
|
119
133
|
const base = join(dir, `tt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
120
|
-
const input = `${base}.mp4`;
|
|
121
|
-
const output = `${base}.mp3`;
|
|
134
|
+
const input = `${base}.mp4`, output = `${base}.mp3`;
|
|
122
135
|
await mkdir(dir, { recursive: true });
|
|
123
136
|
try {
|
|
124
137
|
await writeFile(input, video);
|
|
@@ -126,12 +139,9 @@ export async function tiktokDownload(url, format = 'mp4') {
|
|
|
126
139
|
const buffer = await readFile(output);
|
|
127
140
|
if (!validMediaBuffer(buffer)) throw new Error('TIKTOK_AUDIO_TOO_SMALL');
|
|
128
141
|
return { buffer, mimetype: 'audio/mpeg', extension: 'mp3' };
|
|
129
|
-
} finally {
|
|
130
|
-
await Promise.allSettled([unlink(input), unlink(output)]);
|
|
131
|
-
}
|
|
142
|
+
} finally { await Promise.allSettled([unlink(input), unlink(output)]); }
|
|
132
143
|
}
|
|
133
144
|
|
|
134
|
-
/** Save a TikTok video to disk. */
|
|
135
145
|
export async function tiktokSave(url, dest = './tt.mp4', quality = 'best', options = {}) {
|
|
136
146
|
const result = await tiktokDl(url, options);
|
|
137
147
|
if (result.fallbackMethod === 'yt-dlp') {
|
|
@@ -140,7 +150,7 @@ export async function tiktokSave(url, dest = './tt.mp4', quality = 'best', optio
|
|
|
140
150
|
return dest;
|
|
141
151
|
}
|
|
142
152
|
if (result.fallback) {
|
|
143
|
-
const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4');
|
|
153
|
+
const output = await tiktokDownload(url, dest.toLowerCase().endsWith('.mp3') ? 'mp3' : 'mp4', options);
|
|
144
154
|
await writeFile(dest, output.buffer);
|
|
145
155
|
return dest;
|
|
146
156
|
}
|