feat: add YouTube download support and validation

This commit is contained in:
vchikalkin 2025-12-26 15:28:52 +03:00
parent fae8dc1b5e
commit c4339f67cb
6 changed files with 182 additions and 3 deletions

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

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

@ -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,103 @@
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) {
try {
// 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('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<DownloadRoot>(
'https://downr.org/.netlify/functions/youtube-download',
{
downloadMode: 'video',
url,
videoQuality: quality,
},
);
return {
play: downloadData.url,
};
} catch (error) {
console.error('Error fetching YouTube download URL:', error);
return { play: undefined };
}
}

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: