Compare commits

..

6 Commits

Author SHA1 Message Date
vchikalkin
e79b0216b2 feat: enhance error handling and add limits for video downloads 2025-12-26 16:44:12 +03:00
Vlad Chikalkin
e40b8cd704
Merge pull request #2 from vchikalkin/feature/youtube-download
Feature/youtube download
2025-12-26 15:44:00 +03:00
vchikalkin
4015b6c872 feat: add YouTube support to download messages in English and Russian locales 2025-12-26 15:39:36 +03:00
vchikalkin
8eaa27e8b2 fix: remove try/catch from getYoutubeDownloadUrl 2025-12-26 15:36:58 +03:00
vchikalkin
c4339f67cb feat: add YouTube download support and validation 2025-12-26 15:28:52 +03:00
vchikalkin
fae8dc1b5e feat: add launch configuration for debugging in VSCode 2025-12-26 13:46:16 +03:00
12 changed files with 242 additions and 19 deletions

24
.vscode/launch.json vendored Normal file
View File

@ -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": [
"<node_internals>/**"
],
"type": "node"
},
{
"type": "node-terminal",
"name": "Run Script: dev (Next-Downloader-Bot)",
"request": "launch",
"command": "npm run dev",
"cwd": "${workspaceFolder}"
}
]
}

View File

@ -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,16 @@ 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
err-invalid-tiktok-response = 🔍 Invalid TikTok response. The video might be deleted or unavailable
err-invalid-instagram-response = 🔍 Invalid Instagram response. The post might be deleted or unavailable
err-invalid-youtube-response = 🔍 Invalid YouTube response. The video might be deleted or unavailable
err-youtube-duration-exceeded = 🚫 Video duration exceeds limit
err-youtube-no-quality = 🔍 No suitable quality found for this video
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/)
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/)

View File

@ -5,11 +5,12 @@ description =
🟢 **Текущая поддержка:**
- TikTok
- Instagram
- YouTube
⚡ Просто отправьте ссылку на видео и изображения, и бот сразу скачает его для вас!
short-description =
Скачивай видео и изображения из TikTok и Instagram.
Скачивай видео и изображения из TikTok, Instagram и YouTube.
По всем вопросам: @v_dev_support
@ -18,10 +19,16 @@ 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 = 🚫 Слишком много запросов! Подождите немного
err-invalid-tiktok-response = 🔍 Некорректный ответ от TikTok. Видео может быть удалено или недоступно
err-invalid-instagram-response = 🔍 Некорректный ответ от Instagram. Запись может быть удалена или недоступна
err-invalid-youtube-response = 🔍 Некорректный ответ от YouTube. Видео может быть удалено или недоступно
err-youtube-duration-exceeded = 🚫 Длительность видео превышает лимит
err-youtube-no-quality = 🔍 Не найдено подходящее качество для этого видео
msg-welcome = Добро пожаловать! Я могу скачать для вас видео и изображения из TikTok или Instagram без водяного знака. Для этого просто отправьте мне ссылку (например: https://vt.tiktok.com/ или https://www.instagram.com/p/)
msg-welcome = Добро пожаловать! Я могу скачать для вас видео и изображения из TikTok, Instagram или YouTube без водяного знака. Для этого просто отправьте мне ссылку (например: https://vt.tiktok.com/, https://www.instagram.com/p/ или https://www.youtube.com/watch?v=)

View File

@ -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:"

View File

@ -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<Context>();
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'));
}
@ -32,14 +34,26 @@ feature.on('message:text', logHandle('download-message'), async (context) => {
let imagesUrls: string[] | undefined;
let videoUrl: string | undefined;
if (isTikTok) {
const result = await getTiktokDownloadUrl(url);
imagesUrls = result.images;
videoUrl = result.play;
} else if (isInstagram) {
const result = await getInstagramDownloadUrl(url);
imagesUrls = result.images;
videoUrl = result.play;
try {
if (isTikTok) {
const result = await getTiktokDownloadUrl(url);
imagesUrls = result.images;
videoUrl = result.play;
} else if (isInstagram) {
const result = await getInstagramDownloadUrl(url);
imagesUrls = result.images;
videoUrl = result.play;
} else if (isYoutube) {
const result = await getYoutubeDownloadUrl(url);
videoUrl = result.play;
}
} catch (err: any) {
const message = err?.message ?? String(err);
if (typeof message === 'string' && message.startsWith('err-')) {
return context.reply(context.t(message));
}
return context.reply(context.t('err-generic'));
}
if (!videoUrl && !imagesUrls?.length) {

View File

@ -0,0 +1 @@
export const MAX_VIDEO_DURATION_SECONDS = 180; // 3 minutes

View File

@ -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;

View File

@ -27,7 +27,7 @@ export async function getInstagramDownloadUrl(url: string) {
url,
});
if (!data) throw new Error('Invalid Instagram response');
if (!data) throw new Error('err-invalid-instagram-response');
const isVideo = data.type === 'video' || !data.carouselItems.length;

View File

@ -39,7 +39,7 @@ export async function getTiktokDownloadUrl(url: string) {
const res = await axios.get(`https://tikwm.com/api/?url=${encodeURIComponent(url)}`);
const { data } = res.data as Root;
if (!data) throw new Error('Invalid TikTok response');
if (!data) throw new Error('err-invalid-tiktok-response');
return data;
}

View File

@ -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);
}

View File

@ -0,0 +1,99 @@
import { MAX_VIDEO_DURATION_SECONDS } from '@/constants/limits';
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<InfoRoot>(
'https://downr.org/.netlify/functions/video-info',
{
url,
},
);
if (!infoData?.medias.length) throw new Error('err-invalid-youtube-response');
if (infoData.duration > MAX_VIDEO_DURATION_SECONDS) throw new Error('err-youtube-duration-exceeded');
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('err-youtube-no-quality');
// fetch download link
const { data: downloadData } = await client.post<DownloadRoot>(
'https://downr.org/.netlify/functions/youtube-download',
{
downloadMode: 'video',
url,
videoQuality: quality,
},
);
return {
play: downloadData.url,
};
}

63
pnpm-lock.yaml generated
View File

@ -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: