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..e81f158 --- /dev/null +++ b/apps/bot/src/utils/youtube.ts @@ -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( + '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, + }; + } catch (error) { + console.error('Error fetching YouTube download URL:', error); + return { play: undefined }; + } +} 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: