first commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import {Module} from "@nestjs/common";
|
||||
import {XTiktokDownloadService} from "./x-tiktok.download.service";
|
||||
import {BullModule} from "@nestjs/bullmq";
|
||||
import {VideoDownloadProcessor} from "./video.download.processor";
|
||||
|
||||
@Module({
|
||||
imports:[
|
||||
BullModule.registerQueue(
|
||||
{name: 'download_video_queue'},// Hàng đợi cho AI-C
|
||||
),
|
||||
],
|
||||
providers: [XTiktokDownloadService, VideoDownloadProcessor],
|
||||
exports: [XTiktokDownloadService],
|
||||
})
|
||||
export class TiktokDownloadModule {}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import {OnWorkerEvent, Processor, WorkerHost} from "@nestjs/bullmq";
|
||||
import {Job} from "bullmq";
|
||||
import {Logger} from "@nestjs/common";
|
||||
import {XTiktokDownloadService} from "./x-tiktok.download.service";
|
||||
import {InjectBot} from "nestjs-telegraf";
|
||||
import {Context, Telegraf} from "telegraf";
|
||||
import {map} from "lodash";
|
||||
import {_sleep} from "../../shared/helper";
|
||||
import fs from "fs";
|
||||
|
||||
|
||||
@Processor('download_video_queue')
|
||||
export class VideoDownloadProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger("VideoDownloadProcessor");
|
||||
private adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
|
||||
|
||||
constructor(
|
||||
private downloadTool: XTiktokDownloadService,
|
||||
@InjectBot() private readonly bot: Telegraf<Context>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job, token: string | undefined): Promise<any> {
|
||||
|
||||
console.log(`VideoDownloadProcessor ==> process => ${job.name}`);
|
||||
console.log('job_data', job.data);
|
||||
|
||||
let {telegramChatId, url, sourceType, urls} = job.data;
|
||||
const jobName = job.name;
|
||||
|
||||
switch (jobName) {
|
||||
case 'download': {
|
||||
const result = await this.downloadTool.download(url);
|
||||
console.log('telegramChatId:', telegramChatId);
|
||||
if (result.status === 0) {
|
||||
//error
|
||||
await this.bot.telegram.sendMessage(telegramChatId > 0 ? telegramChatId : this.adminChatId,
|
||||
`Có lỗi.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.bot.telegram.sendMessage(telegramChatId > 0 ? telegramChatId : this.adminChatId,
|
||||
`đã download xong: ${result.title}`,
|
||||
);
|
||||
|
||||
return {
|
||||
status: 1,
|
||||
filePath: result.filePath,
|
||||
title: result.title,
|
||||
telegramChatId,
|
||||
jobName,
|
||||
originalUrl: url,
|
||||
};
|
||||
}
|
||||
case 'download_multi': {
|
||||
const totalLink = urls.length;
|
||||
let done = 0;
|
||||
for (const url of urls) {
|
||||
await _sleep(1000);
|
||||
console.log(`Downloading ${url}`);
|
||||
const result = await this.downloadTool.download(url).catch((err) => {
|
||||
console.log(err);
|
||||
return {
|
||||
status: 0,
|
||||
error: err.message,
|
||||
filePath: '',
|
||||
title: '',
|
||||
}
|
||||
});
|
||||
if (result.status === 0) {
|
||||
//error
|
||||
await this.bot.telegram.sendMessage(telegramChatId > 0 ? telegramChatId : this.adminChatId,
|
||||
`Có lỗi. ${result.error}`,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
done++;
|
||||
await this.bot.telegram.sendMessage(telegramChatId > 0 ? telegramChatId : this.adminChatId,
|
||||
`đã download xong ${done}/${totalLink}: ${result.title}`,
|
||||
);
|
||||
await this.sendVideoToTelegram(telegramChatId || this.adminChatId, result.filePath, result.title);
|
||||
}
|
||||
// // Dừng lại đợi ở đây
|
||||
// results.push(data);
|
||||
}
|
||||
// map(urls, async (url) => {
|
||||
//
|
||||
//
|
||||
//
|
||||
// return {
|
||||
// status: 1,
|
||||
// filePath: result.filePath,
|
||||
// title: result.title,
|
||||
// telegramChatId,
|
||||
// jobName,
|
||||
// originalUrl: url,
|
||||
// };
|
||||
// })
|
||||
|
||||
}
|
||||
case 'download_tiktok': {
|
||||
const result = await this.downloadTool.downloadTikTok(url);
|
||||
console.log({result});
|
||||
console.log('telegramChatId:', telegramChatId);
|
||||
await this.bot.telegram.sendMessage(telegramChatId > 0 ? telegramChatId : this.adminChatId,
|
||||
`đã download xong: ${result.title}`,
|
||||
);
|
||||
|
||||
return {
|
||||
status: 1,
|
||||
filePath: result.filePath,
|
||||
title: result.title,
|
||||
telegramChatId,
|
||||
jobName,
|
||||
originalUrl: url,
|
||||
};
|
||||
}
|
||||
case 'youtube': {
|
||||
break;
|
||||
}
|
||||
case 'facebook_reels': {
|
||||
const result = await this.downloadTool.downloadFacebookReels(url);
|
||||
console.log({result});
|
||||
console.log('telegramChatId:', telegramChatId);
|
||||
await this.bot.telegram.sendMessage(telegramChatId > 0 ? telegramChatId : this.adminChatId,
|
||||
`đã download xong: ${result.title}`,
|
||||
);
|
||||
|
||||
return {
|
||||
status: 1,
|
||||
filePath: result.filePath,
|
||||
title: result.title,
|
||||
telegramChatId,
|
||||
jobName,
|
||||
originalUrl: url,
|
||||
};
|
||||
}
|
||||
case 'mp4url': {
|
||||
const result = await this.downloadTool.downloadUrlMp4(url, sourceType);
|
||||
console.log({result});
|
||||
console.log('telegramChatId:', telegramChatId);
|
||||
await this.bot.telegram.sendMessage(telegramChatId > 0 ? telegramChatId : this.adminChatId,
|
||||
`đã download xong: ${result.title}`,
|
||||
);
|
||||
|
||||
return {
|
||||
status: 1,
|
||||
filePath: result.filePath,
|
||||
title: result.title,
|
||||
telegramChatId,
|
||||
jobName,
|
||||
originalUrl: url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
status: 0,
|
||||
telegramChatId,
|
||||
filePath: '',
|
||||
title: '',
|
||||
jobName,
|
||||
originalUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
async onCompleted(job: Job<any>) {
|
||||
console.log('VideoDownloadProcessor_completed');
|
||||
const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
|
||||
const status = job.returnvalue.status;
|
||||
const telegramChatId = job.returnvalue.telegramChatId;
|
||||
const originalUrl = job.returnvalue.originalUrl;
|
||||
if (status === 0) {
|
||||
//const topic = job.returnvalue.topic;
|
||||
await this.bot.telegram.sendMessage(telegramChatId || adminChatId, `Lỗi download: ${originalUrl}`);
|
||||
} else {
|
||||
const filePath = job.returnvalue.filePath;
|
||||
const title = job.returnvalue.title;
|
||||
await this.sendVideoToTelegram(telegramChatId || adminChatId, filePath, title);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async sendVideoToTelegram(chatId, filePath, caption = '') {
|
||||
await this.bot.telegram.sendMessage(chatId, 'chờ gửi lên telegram ...')
|
||||
await this.bot.telegram.sendVideo(chatId, {source: filePath}, {caption});
|
||||
// Xoá file sau khi gửi
|
||||
return fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import {exec} from 'child_process';
|
||||
import {promisify} from 'util';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import {BadRequestException, Injectable, Logger} from "@nestjs/common";
|
||||
import axios from "axios";
|
||||
import {pipeline} from 'stream/promises';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@Injectable()
|
||||
export class XTiktokDownloadService {
|
||||
private readonly logger = new Logger(XTiktokDownloadService.name);
|
||||
private downloadDir = './downloads/'
|
||||
|
||||
constructor() {
|
||||
fs.mkdirSync(this.downloadDir, {recursive: true});
|
||||
// fs.mkdirSync(this.downloadDir + '/fb', {recursive: true});
|
||||
// fs.mkdirSync(this.downloadDir + '/tiktok', {recursive: true});
|
||||
// fs.mkdirSync(this.downloadDir + '/x', {recursive: true});
|
||||
// fs.mkdirSync(this.downloadDir + '/yt', {recursive: true});
|
||||
|
||||
}
|
||||
|
||||
async download(url: string) {
|
||||
const urlMp4Regex = /(https?:\/\/[^\s]+\.mp4)/i;
|
||||
if (urlMp4Regex.test(url)) {
|
||||
return this.downloadUrlMp4(url, '');
|
||||
}
|
||||
if (url.indexOf('tiktok')) {
|
||||
return this.downloadTikTok(url);
|
||||
}
|
||||
const regexFb = /https?:\/\/(www\.)?(facebook\.com\/reel\/\S+|fb\.watch\/\S+)/i
|
||||
if (regexFb.test(url)) {
|
||||
return this.downloadFacebookReels(url);
|
||||
}
|
||||
return {
|
||||
status: 0,
|
||||
filePath: '',
|
||||
title: '',
|
||||
error: ''
|
||||
}
|
||||
}
|
||||
|
||||
async downloadTikTok(url) {
|
||||
const timestamp = Date.now();
|
||||
const outputDir = path.resolve('./downloads/tiktok');
|
||||
const outputTemplate = path.join(outputDir, `tt_${timestamp}.%(ext)s`);
|
||||
|
||||
// yt-dlp: tải TikTok không watermark, output mp4
|
||||
const cmd = `yt-dlp -f "best[ext=mp4]/best" -o "${outputTemplate}" --no-warnings "${url}"`;
|
||||
|
||||
try {
|
||||
const {stdout} = await execAsync(cmd, {maxBuffer: 1024 * 1024 * 50});
|
||||
|
||||
// Tìm file vừa download
|
||||
const files = fs.readdirSync(outputDir)
|
||||
.filter(f => f.startsWith(`tt_${timestamp}`))
|
||||
.map(f => path.join(outputDir, f));
|
||||
|
||||
if (!files.length) throw new Error('Không tìm thấy file đã tải');
|
||||
console.log({files});
|
||||
return {
|
||||
filePath: files[0],
|
||||
title: `TikTok_${timestamp}`,
|
||||
status: 1,
|
||||
error: ''
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(`Lỗi download TikTok: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// async downloadVideoQueue(url) {}
|
||||
async downloadUrlMp4(mp4url: string, sourceType = '') {
|
||||
const dir = this.downloadDir + `/${sourceType}`;
|
||||
fs.mkdirSync(dir, {recursive: true});
|
||||
console.log('downloadUrlMp4:', mp4url);
|
||||
// const filename = customName || this.extractFilename(mp4url);
|
||||
|
||||
this.logger.log(`⬇️ Đang tải: ${mp4url}`);
|
||||
const timestamp = Date.now();
|
||||
const filename = `${sourceType}_${timestamp}.mp4`;
|
||||
const filePath = path.join(dir, filename);
|
||||
|
||||
|
||||
const response = await axios({
|
||||
url: mp4url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
timeout: 80000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept: 'video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5',
|
||||
},
|
||||
});
|
||||
|
||||
const writer = fs.createWriteStream(filePath);
|
||||
await pipeline(response.data, writer);
|
||||
|
||||
// await new Promise((resolve, reject) => {
|
||||
// const writer = fs.createWriteStream(filePath);
|
||||
// response.data.pipe(writer);
|
||||
// writer.on('finish', ()=>{
|
||||
// console.log(`download ${mp4url} done`);
|
||||
// resolve(filePath);
|
||||
// });
|
||||
// writer.on('error', reject);
|
||||
// });
|
||||
console.log('✅ Tải xong:', mp4url);
|
||||
return {
|
||||
filePath,
|
||||
title: filename,
|
||||
status: 1,
|
||||
error: ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download Facebook Reels
|
||||
*/
|
||||
async downloadFacebookReels(fbUrl: string) {
|
||||
this.logger.log(`🔍 Đang phân tích Facebook Reels: ${fbUrl}`);
|
||||
|
||||
// Chuẩn hóa URL
|
||||
let targetUrl = fbUrl.trim();
|
||||
if (!targetUrl.startsWith('http')) targetUrl = `https://${targetUrl}`;
|
||||
|
||||
// 1. Lấy HTML trang reels
|
||||
const html = await this.fetchFacebookPage(targetUrl);
|
||||
|
||||
// 2. Trích xuất direct video URL từ HTML
|
||||
const videoUrl = this.extractFacebookVideoUrl(html);
|
||||
if (!videoUrl) {
|
||||
throw new BadRequestException('Không tìm thấy video URL trong trang Facebook. Có thể reels bị giới hạn hoặc Facebook đã đổi cấu trúc.');
|
||||
}
|
||||
|
||||
this.logger.log(`📹 Tìm thấy video source: ${videoUrl.substring(0, 100)}...`);
|
||||
|
||||
// 3. Tải về bằng axios stream
|
||||
return this.downloadUrlMp4(videoUrl, 'fb');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lấy HTML của trang Facebook (giả lập browser desktop)
|
||||
*/
|
||||
private async fetchFacebookPage(url: string): Promise<string> {
|
||||
try {
|
||||
const {data} = await axios.get(url, {
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Cache-Control': 'max-age=0',
|
||||
'sec-fetch-dest': 'document',
|
||||
'sec-fetch-mode': 'navigate',
|
||||
'sec-fetch-site': 'none',
|
||||
'sec-fetch-user': '?1',
|
||||
'upgrade-insecure-requests': '1',
|
||||
},
|
||||
});
|
||||
return data as string;
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Lỗi fetch Facebook page: ${err.message}`);
|
||||
throw new BadRequestException(`Không thể truy cập link Facebook: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTML Facebook để tìm video URL trực tiếp
|
||||
*/
|
||||
private extractFacebookVideoUrl(html: string): string | null {
|
||||
// Cách 1: Meta tag og:video hoặc og:video:url
|
||||
const ogVideoMatch = html.match(/<meta[^>]+property=["']og:video(:(url|secure_url))?["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (ogVideoMatch) {
|
||||
const url = ogVideoMatch[3];
|
||||
// Loại bỏ HTML entities
|
||||
return url.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
// Cách 2: Tìm trong script JSON chứa "video_url" hoặc "browser_native_sd_url"
|
||||
const patterns = [
|
||||
/"video_url"\s*:\s*"([^"]+)"/,
|
||||
/"browser_native_sd_url"\s*:\s*"([^"]+)"/,
|
||||
/"browser_native_hd_url"\s*:\s*"([^"]+)"/,
|
||||
/"playable_url"\s*:\s*"([^"]+)"/,
|
||||
/"playable_url_quality_hd"\s*:\s*"([^"]+)"/,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match) {
|
||||
return match[1].replace(/\\u0025/g, '%').replace(/\\/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Cách 3: Tìm trong data-store hoặc các cấu trúc deferred data
|
||||
const deferredMatch = html.match(/video_url\\":\\"([^"]+)\\"/);
|
||||
if (deferredMatch) {
|
||||
return deferredMatch[1].replace(/\\/g, '');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractFilename(url: string): string {
|
||||
try {
|
||||
const name = path.basename(new URL(url).pathname);
|
||||
if (name && name.includes('.')) return name;
|
||||
} catch { /* ignore */
|
||||
}
|
||||
return `video_${Date.now()}.mp4`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user