first commit

This commit is contained in:
NAME
2026-05-14 08:42:03 +00:00
commit 5f16ed135d
167 changed files with 29178 additions and 0 deletions
@@ -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(/&amp;/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`;
}
}