diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ea0e32a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Attach by Process ID", + "processId": "${command:PickProcess}", + "request": "attach", + "skipFiles": [ + "/**" + ], + "type": "node" + }, + { + "type": "node-terminal", + "name": "Run Script: dev (Next-Downloader-Bot)", + "request": "launch", + "command": "npm run dev", + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/apps/bot/locales/en.ftl b/apps/bot/locales/en.ftl index 1c31271..c97e12e 100644 --- a/apps/bot/locales/en.ftl +++ b/apps/bot/locales/en.ftl @@ -5,11 +5,12 @@ description = 🟢 **Currently supported:** - TikTok - Instagram + - YouTube ⚡ Simply send the video link, and the bot will download it for you. short-description = - Download TikTok, Instagram videos and images. + Download TikTok, Instagram and YouTube videos and images. For any questions: @v_dev_support @@ -18,10 +19,10 @@ start = .description = Start the bot -err-invalid-url = ❌ Invalid URL! Please send a valid TikTok or Instagram link (e.g., https://vt.tiktok.com/ or https://www.instagram.com/p/) +err-invalid-url = ❌ Invalid URL! Please send a valid TikTok, Instagram or YouTube link (e.g., https://vt.tiktok.com/ or https://www.instagram.com/p/ or https://www.youtube.com/) err-invalid-download-urls = 🔍 Download links not found. The video might be deleted or unavailable err-generic = ⚠️ Something went wrong. Please try again in a few seconds err-limit-exceeded = 🚫 Too many requests! Please wait -msg-welcome = Welcome! I can download TikTok or Instagram videos and images for you without watermark. Just send me the link (for example: https://vt.tiktok.com/ or https://www.instagram.com/p/) \ No newline at end of file +msg-welcome = Welcome! I can download TikTok, Instagram or YouTube videos and images for you without watermark. Just send me the link (for example: https://vt.tiktok.com/ or https://www.instagram.com/p/ or https://www.youtube.com/) \ No newline at end of file diff --git a/apps/bot/locales/ru.ftl b/apps/bot/locales/ru.ftl index 82fb860..a8b9233 100644 --- a/apps/bot/locales/ru.ftl +++ b/apps/bot/locales/ru.ftl @@ -5,11 +5,12 @@ description = 🟢 **Текущая поддержка:** - TikTok - Instagram + - YouTube ⚡ Просто отправьте ссылку на видео и изображения, и бот сразу скачает его для вас! short-description = - Скачивай видео и изображения из TikTok и Instagram. + Скачивай видео и изображения из TikTok, Instagram и YouTube. По всем вопросам: @v_dev_support @@ -18,10 +19,10 @@ start = .description = Запуск бота -err-invalid-url = ❌ Неверная ссылка! Отправьте корректную ссылку TikTok или Instagram (например: https://vt.tiktok.com/ или https://www.instagram.com/p/) +err-invalid-url = ❌ Неверная ссылка! Отправьте корректную ссылку TikTok, Instagram или YouTube (например: https://vt.tiktok.com/, https://www.instagram.com/p/ или https://www.youtube.com/watch?v=) err-invalid-download-urls = 🔍 Не удалось найти ссылки для скачивания. Возможно, видео и изображения удалено или недоступно err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного -msg-welcome = Добро пожаловать! Я могу скачать для вас видео и изображения из TikTok или Instagram без водяного знака. Для этого просто отправьте мне ссылку (например: https://vt.tiktok.com/ или https://www.instagram.com/p/) \ No newline at end of file +msg-welcome = Добро пожаловать! Я могу скачать для вас видео и изображения из TikTok, Instagram или YouTube без водяного знака. Для этого просто отправьте мне ссылку (например: https://vt.tiktok.com/, https://www.instagram.com/p/ или https://www.youtube.com/watch?v=) \ No newline at end of file diff --git a/apps/bot/package.json b/apps/bot/package.json index e7cf4ba..09855b2 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -31,11 +31,13 @@ "@repo/typescript-config": "workspace:*", "@types/node": "catalog:", "axios": "^1.12.0", + "axios-cookiejar-support": "^6.0.5", "grammy": "^1.37.0", "ioredis": "^5.7.0", "pino": "^9.9.0", "pino-pretty": "^13.1.1", "radashi": "^12.6.2", + "tough-cookie": "^6.0.0", "tsup": "^8.5.0", "typescript": "catalog:", "zod": "catalog:" diff --git a/apps/bot/src/bot/features/download.ts b/apps/bot/src/bot/features/download.ts index a9dd65b..67df91a 100644 --- a/apps/bot/src/bot/features/download.ts +++ b/apps/bot/src/bot/features/download.ts @@ -5,9 +5,10 @@ import { TTL_URLS } from '@/config/redis'; import { getRedisInstance } from '@/utils/redis'; import { getTiktokDownloadUrl } from '@/utils/tiktok'; import { getInstagramDownloadUrl } from '@/utils/instagram'; -import { validateTikTokUrl, validateInstagramUrl } from '@/utils/urls'; +import { validateTikTokUrl, validateInstagramUrl, validateYoutubeUrl } from '@/utils/urls'; import { Composer, InputFile } from 'grammy'; import { cluster } from 'radashi'; +import { getYoutubeDownloadUrl } from '@/utils/youtube'; const composer = new Composer(); const feature = composer.chatType('private'); @@ -19,8 +20,9 @@ feature.on('message:text', logHandle('download-message'), async (context) => { const isTikTok = validateTikTokUrl(url); const isInstagram = validateInstagramUrl(url); + const isYoutube = validateYoutubeUrl(url); - if (!isTikTok && !isInstagram) { + if (!isTikTok && !isInstagram && !isYoutube) { return context.reply(context.t('err-invalid-url')); } @@ -40,6 +42,9 @@ feature.on('message:text', logHandle('download-message'), async (context) => { const result = await getInstagramDownloadUrl(url); imagesUrls = result.images; videoUrl = result.play; + } else if (isYoutube) { + const result = await getYoutubeDownloadUrl(url); + videoUrl = result.play; } if (!videoUrl && !imagesUrls?.length) { diff --git a/apps/bot/src/constants/regex.ts b/apps/bot/src/constants/regex.ts index adc5271..63d7f42 100644 --- a/apps/bot/src/constants/regex.ts +++ b/apps/bot/src/constants/regex.ts @@ -4,3 +4,5 @@ export const TIKTOK_URL_REGEX = export const INSTAGRAM_URL_REGEX = /https?:\/\/(www\.)?instagram\.com\/(p|reel|tv|stories)\/([a-zA-Z0-9_-]+)(\/)?(\?utm_source=ig_web_copy_link&igshid=[a-z0-9]+)?/u; + +export const YOUTUBE_URL_REGEX = /(youtu.*be.*)\/(watch\?v=|embed\/|v|shorts|)(.*?((?=[&#?])|$))/u; diff --git a/apps/bot/src/utils/urls.ts b/apps/bot/src/utils/urls.ts index 5c8b0b5..c9457f0 100644 --- a/apps/bot/src/utils/urls.ts +++ b/apps/bot/src/utils/urls.ts @@ -1,4 +1,4 @@ -import { INSTAGRAM_URL_REGEX, TIKTOK_URL_REGEX } from '@/constants/regex'; +import { INSTAGRAM_URL_REGEX, TIKTOK_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants/regex'; export function validateTikTokUrl(url: string) { return TIKTOK_URL_REGEX.test(url); @@ -7,3 +7,7 @@ export function validateTikTokUrl(url: string) { export function validateInstagramUrl(url: string) { return INSTAGRAM_URL_REGEX.test(url); } + +export function validateYoutubeUrl(url: string) { + return YOUTUBE_URL_REGEX.test(url); +} diff --git a/apps/bot/src/utils/youtube.ts b/apps/bot/src/utils/youtube.ts new file mode 100644 index 0000000..787da78 --- /dev/null +++ b/apps/bot/src/utils/youtube.ts @@ -0,0 +1,98 @@ +import axios from 'axios'; +import { wrapper } from 'axios-cookiejar-support'; +import * as tough from 'tough-cookie'; + +const jar = new tough.CookieJar(); + +const headers = { + accept: '*/*', + 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7', + 'content-type': 'application/json', + dnt: '1', + priority: 'u=1, i', + 'sec-ch-ua': '"Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-origin', + 'sec-gpc': '1', + 'user-agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36', +}; + +const client = wrapper( + axios.create({ + jar, + withCredentials: true, + headers, + }), +); + +export interface Media { + type: string; + quality: string; + extension: string; + fileSize: number; +} + +export interface InfoRoot { + title: string; + thumbnail: string; + duration: number; + medias: Media[]; +} + +export interface DownloadRoot { + status: string; + url: string; + filename: string; + duration: number; +} + +const qualityOrder = ['144p', '240p', '360p', '480p', '1080p', '720p'].reverse(); + +export async function getYoutubeDownloadUrl(url: string) { + // get session cookie + await client.get('https://downr.org/.netlify/functions/analytics'); + + // fetch video info + const { data: infoData } = await client.post( + 'https://downr.org/.netlify/functions/video-info', + { + url, + }, + ); + + if (!infoData?.medias.length) throw new Error('Invalid YouTube response'); + if (infoData.duration > 120) throw new Error('Video duration exceeds limit'); + + let quality: string | undefined; + + for (const q of qualityOrder) { + const hasQuality = infoData.medias.find( + (media) => media.type === 'video' && media.quality === q && media.extension === 'mp4', + ); + + if (hasQuality) { + quality = q; + break; + } + } + + if (!quality) throw new Error('No suitable quality found'); + + // fetch download link + const { data: downloadData } = await client.post( + 'https://downr.org/.netlify/functions/youtube-download', + { + downloadMode: 'video', + url, + videoQuality: quality, + }, + ); + + return { + play: downloadData.url, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78b5f26..5e0390c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: axios: specifier: ^1.12.0 version: 1.12.0 + axios-cookiejar-support: + specifier: ^6.0.5 + version: 6.0.5(axios@1.12.0)(tough-cookie@6.0.0) grammy: specifier: ^1.37.0 version: 1.37.1 @@ -105,6 +108,9 @@ importers: radashi: specifier: ^12.6.2 version: 12.6.2 + tough-cookie: + specifier: ^6.0.0 + version: 6.0.0 tsup: specifier: ^8.5.0 version: 8.5.0(jiti@2.5.1)(postcss@8.5.6)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1) @@ -1084,6 +1090,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1193,6 +1203,13 @@ packages: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} + axios-cookiejar-support@6.0.5: + resolution: {integrity: sha512-ldPOQCJWB0ipugkTNVB8QRl/5L2UgfmVNVQtS9en1JQJ1wW588PqAmymnwmmgc12HLDzDtsJ28xE2ppj4rD4ng==} + engines: {node: '>=20.0.0'} + peerDependencies: + axios: '>=0.20.0' + tough-cookie: '>=4.0.0' + axios@1.12.0: resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} @@ -2203,6 +2220,16 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + http-cookie-agent@7.0.3: + resolution: {integrity: sha512-EeZo7CGhfqPW6R006rJa4QtZZUpBygDa2HZH3DJqsTzTjyRE6foDBVQIv/pjVsxHC8z2GIdbB1Hvn9SRorP3WQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + tough-cookie: ^4.0.0 || ^5.0.0 || ^6.0.0 + undici: ^7.0.0 + peerDependenciesMeta: + undici: + optional: true + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -3352,10 +3379,21 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -4628,6 +4666,8 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -4746,6 +4786,14 @@ snapshots: axe-core@4.10.3: {} + axios-cookiejar-support@6.0.5(axios@1.12.0)(tough-cookie@6.0.0): + dependencies: + axios: 1.12.0 + http-cookie-agent: 7.0.3(tough-cookie@6.0.0) + tough-cookie: 6.0.0 + transitivePeerDependencies: + - undici + axios@1.12.0: dependencies: follow-redirects: 1.15.11 @@ -5995,6 +6043,11 @@ snapshots: dependencies: lru-cache: 10.4.3 + http-cookie-agent@7.0.3(tough-cookie@6.0.0): + dependencies: + agent-base: 7.1.4 + tough-cookie: 6.0.0 + human-signals@5.0.0: {} husky@9.1.7: {} @@ -7192,10 +7245,20 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + tr46@0.0.3: {} tr46@1.0.1: