Compare commits
No commits in common. "master" and "feature/youtube-download" have entirely different histories.
master
...
feature/yo
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@ -98,8 +98,8 @@ jobs:
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
cd /home/${{ secrets.VPS_USER }}/next-downloader-bot && \
|
||||
sudo docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
|
||||
sudo docker compose pull && \
|
||||
sudo docker compose down && \
|
||||
sudo docker compose up -d
|
||||
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
|
||||
docker compose pull && \
|
||||
docker compose down && \
|
||||
docker compose up -d
|
||||
"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Next-Downloader-Bot
|
||||
|
||||
A Telegram bot for downloading TikTok, Instagram, YouTube, and YouTube Shorts videos and images without watermarks. Built with TypeScript, Grammy.js, and Redis for caching.
|
||||
A Telegram bot for downloading TikTok and Instagram videos and images without watermarks. Built with TypeScript, Grammy.js, and Redis for caching.
|
||||
|
||||
## 🤖 Try the Bot
|
||||
|
||||
@ -8,14 +8,12 @@ A Telegram bot for downloading TikTok, Instagram, YouTube, and YouTube Shorts vi
|
||||
|
||||
👉 **[Start Next Downloader Bot](https://t.me/next_downloader_bot)**
|
||||
|
||||
Simply send a TikTok, Instagram, YouTube, or YouTube Shorts video or image URL to the bot and it will download the content without watermarks for you.
|
||||
Simply send a TikTok or Instagram video or image URL to the bot and it will download the content without watermarks for you.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **TikTok Video Download**: Download TikTok videos without watermarks
|
||||
- **Instagram Video Download**: Download Instagram Reels, posts, and IGTV videos without watermarks
|
||||
- **YouTube Video Download**: Download YouTube videos in various qualities
|
||||
- **YouTube Shorts Download**: Download YouTube Shorts with ease
|
||||
- **Image/Photo Support**: Download TikTok and Instagram image collections
|
||||
- **Caching**: Redis-based caching to avoid re-downloading the same content
|
||||
- **Rate Limiting**: Built-in rate limiting to prevent abuse
|
||||
@ -254,5 +252,6 @@ If you encounter any issues:
|
||||
|
||||
- [Grammy.js Documentation](https://grammy.dev/)
|
||||
- [Telegram Bot API](https://core.telegram.org/bots/api)
|
||||
- [TikTok API DL](https://github.com/tobyg74/tiktok-api-dl)
|
||||
- [Redis Documentation](https://redis.io/documentation)
|
||||
- [Instagram Platform Documentation](https://developers.facebook.com/docs/instagram)
|
||||
|
||||
@ -64,8 +64,7 @@ RUN apk add --no-cache \
|
||||
pixman \
|
||||
pangomm \
|
||||
libjpeg-turbo \
|
||||
freetype \
|
||||
yt-dlp
|
||||
freetype
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 botuser
|
||||
|
||||
@ -11,7 +11,6 @@ export default [
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'unicorn/prevent-abbreviations': 'off',
|
||||
'canonical/id-match': 'off',
|
||||
'id-length': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@ -10,9 +10,9 @@ description =
|
||||
⚡ Simply send the video link, and the bot will download it for you.
|
||||
|
||||
short-description =
|
||||
Bot downloads videos and pictures
|
||||
Support: @v_dev_support
|
||||
Donate: { $donateLink }
|
||||
Download TikTok, Instagram and YouTube videos and images.
|
||||
|
||||
For any questions: @v_dev_support
|
||||
|
||||
|
||||
start =
|
||||
@ -24,11 +24,5 @@ err-invalid-download-urls = 🔍 Download links not found. The video might be de
|
||||
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, 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/)
|
||||
@ -10,9 +10,9 @@ description =
|
||||
⚡ Просто отправьте ссылку на видео и изображения, и бот сразу скачает его для вас!
|
||||
|
||||
short-description =
|
||||
Бот скачивает видео и изображения
|
||||
Поддержка: @v_dev_support
|
||||
Поблагодарить: { $donateLink }
|
||||
Скачивай видео и изображения из TikTok, Instagram и YouTube.
|
||||
|
||||
По всем вопросам: @v_dev_support
|
||||
|
||||
|
||||
start =
|
||||
@ -24,11 +24,5 @@ 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 или YouTube без водяного знака. Для этого просто отправьте мне ссылку (например: https://vt.tiktok.com/, https://www.instagram.com/p/ или https://www.youtube.com/watch?v=)
|
||||
@ -2,159 +2,19 @@
|
||||
import { type Context } from '../context';
|
||||
import { logHandle } from '../helpers/logging';
|
||||
import { TTL_URLS } from '@/config/redis';
|
||||
import { getInstagramDownloadUrl } from '@/utils/instagram';
|
||||
import { getRedisInstance } from '@/utils/redis';
|
||||
import { removeHashtags } from '@/utils/text';
|
||||
import { getTiktokDownloadUrl } from '@/utils/tiktok';
|
||||
import { validateInstagramUrl, validateTikTokUrl, validateYoutubeUrl } from '@/utils/urls';
|
||||
import { getYoutubeDownloadUrl } from '@/utils/youtube';
|
||||
import { code, expandableBlockquote, fmt } from '@grammyjs/parse-mode';
|
||||
import { getInstagramDownloadUrl } from '@/utils/instagram';
|
||||
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');
|
||||
|
||||
const redis = getRedisInstance();
|
||||
|
||||
type DownloadResult = {
|
||||
caption?: string;
|
||||
imagesUrls?: string[];
|
||||
videoUrl?: string;
|
||||
};
|
||||
|
||||
async function checkCacheAndReply(context: Context, url: string) {
|
||||
let contentMessageId: number | undefined;
|
||||
|
||||
const cachedVideoId = await redis.get(url);
|
||||
if (cachedVideoId) {
|
||||
const cachedMessage = await context.replyWithVideo(cachedVideoId);
|
||||
contentMessageId = cachedMessage.message_id;
|
||||
}
|
||||
|
||||
if (contentMessageId) {
|
||||
const cachedCaption = await redis.get(`caption:${url}`);
|
||||
if (cachedCaption) {
|
||||
const { entities, text } = formatCaption(cachedCaption);
|
||||
|
||||
if (text.trim().length) {
|
||||
await context.reply(text, {
|
||||
entities,
|
||||
reply_parameters: { message_id: contentMessageId },
|
||||
});
|
||||
}
|
||||
|
||||
return { captionSent: true, contentMessageId };
|
||||
}
|
||||
}
|
||||
|
||||
return { contentMessageId };
|
||||
}
|
||||
|
||||
function formatCaption(caption: string) {
|
||||
const cleanCaption = removeHashtags(caption);
|
||||
return fmt`${expandableBlockquote} ${code} ${cleanCaption} ${code} ${expandableBlockquote}`;
|
||||
}
|
||||
|
||||
async function getDownloadData(
|
||||
url: string,
|
||||
opts: {
|
||||
isInstagram: boolean;
|
||||
isTikTok: boolean;
|
||||
isYoutube: boolean;
|
||||
},
|
||||
): Promise<DownloadResult> {
|
||||
const { isInstagram, isTikTok, isYoutube } = opts;
|
||||
|
||||
if (isTikTok) {
|
||||
const result = await getTiktokDownloadUrl(url);
|
||||
return {
|
||||
caption: result.title,
|
||||
imagesUrls: result.images,
|
||||
videoUrl: result.play,
|
||||
};
|
||||
}
|
||||
|
||||
if (isInstagram) {
|
||||
const result = await getInstagramDownloadUrl(url.replace('/reel/', '/p/'));
|
||||
return {
|
||||
caption: result.caption,
|
||||
imagesUrls: result.images,
|
||||
videoUrl: result.play,
|
||||
};
|
||||
}
|
||||
|
||||
if (isYoutube) {
|
||||
const result = await getYoutubeDownloadUrl(url);
|
||||
return {
|
||||
videoUrl: result.play,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async function sendCaptionAndCache(
|
||||
context: Context,
|
||||
caption: string | undefined,
|
||||
url: string,
|
||||
contentMessageId?: number,
|
||||
) {
|
||||
if (!caption) return;
|
||||
|
||||
const { entities, text } = formatCaption(caption);
|
||||
await redis.set(`caption:${url}`, caption, 'EX', TTL_URLS);
|
||||
|
||||
if (text.trim().length) {
|
||||
await context.reply(text, {
|
||||
entities,
|
||||
reply_parameters: contentMessageId ? { message_id: contentMessageId } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendImages(
|
||||
context: Context,
|
||||
imagesUrls: string[],
|
||||
existingContentMessageId?: number,
|
||||
) {
|
||||
if (!imagesUrls.length) return existingContentMessageId;
|
||||
|
||||
const chunks = cluster(imagesUrls, 10);
|
||||
let contentMessageId = existingContentMessageId;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const imageMessages = await context.replyWithMediaGroup(
|
||||
chunk.map((imageUrl) => ({ media: imageUrl, type: 'photo' })),
|
||||
);
|
||||
|
||||
if (!contentMessageId && imageMessages.length) {
|
||||
contentMessageId = imageMessages.at(0)?.message_id;
|
||||
}
|
||||
}
|
||||
|
||||
return contentMessageId;
|
||||
}
|
||||
|
||||
async function sendVideoAndCache(
|
||||
context: Context,
|
||||
videoUrl: string | undefined,
|
||||
url: string,
|
||||
existingContentMessageId?: number,
|
||||
) {
|
||||
let contentMessageId = existingContentMessageId;
|
||||
|
||||
if (videoUrl && !contentMessageId) {
|
||||
const { video, ...videoMessage } = await context.replyWithVideo(
|
||||
new InputFile({ url: videoUrl }),
|
||||
);
|
||||
|
||||
contentMessageId = videoMessage.message_id;
|
||||
await redis.set(url, video.file_id, 'EX', TTL_URLS);
|
||||
}
|
||||
|
||||
return contentMessageId;
|
||||
}
|
||||
|
||||
feature.on('message:text', logHandle('download-message'), async (context) => {
|
||||
const url = context.message.text.trim();
|
||||
|
||||
@ -166,41 +26,46 @@ feature.on('message:text', logHandle('download-message'), async (context) => {
|
||||
return context.reply(context.t('err-invalid-url'));
|
||||
}
|
||||
|
||||
const cacheResult = await checkCacheAndReply(context, url);
|
||||
if (cacheResult.captionSent) return;
|
||||
|
||||
let contentMessageId = cacheResult.contentMessageId;
|
||||
const cachedFileId = await redis.get(url);
|
||||
if (cachedFileId) {
|
||||
return context.replyWithVideo(cachedFileId);
|
||||
}
|
||||
|
||||
let imagesUrls: string[] | undefined;
|
||||
let videoUrl: string | undefined;
|
||||
let caption: string | undefined;
|
||||
|
||||
try {
|
||||
const result = await getDownloadData(url, {
|
||||
isInstagram,
|
||||
isTikTok,
|
||||
isYoutube,
|
||||
});
|
||||
|
||||
imagesUrls = result.imagesUrls;
|
||||
videoUrl = result.videoUrl;
|
||||
caption = result.caption;
|
||||
} catch (error: unknown) {
|
||||
const message = (error as Error)?.message ?? String(error);
|
||||
if (typeof message === 'string' && message.startsWith('err-')) {
|
||||
return context.reply(context.t(message));
|
||||
}
|
||||
|
||||
return context.reply(context.t('err-generic'));
|
||||
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;
|
||||
}
|
||||
|
||||
if (!videoUrl && !imagesUrls?.length) {
|
||||
return context.reply(context.t('err-invalid-download-urls'));
|
||||
}
|
||||
|
||||
contentMessageId = await sendImages(context, imagesUrls ?? [], contentMessageId);
|
||||
contentMessageId = await sendVideoAndCache(context, videoUrl, url, contentMessageId);
|
||||
await sendCaptionAndCache(context, caption, url, contentMessageId);
|
||||
if (imagesUrls?.length) {
|
||||
const chunks = cluster(imagesUrls, 10);
|
||||
for (const chunk of chunks) {
|
||||
await context.replyWithMediaGroup(
|
||||
chunk.map((imageUrl) => ({ media: imageUrl, type: 'photo' })),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoUrl) {
|
||||
const { video } = await context.replyWithVideo(new InputFile({ url: videoUrl }));
|
||||
await redis.set(url, video.file_id, 'EX', TTL_URLS);
|
||||
}
|
||||
});
|
||||
|
||||
export { composer as download };
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import { type Context } from '../context';
|
||||
import { i18n } from '../i18n';
|
||||
import { env } from '@/config/env';
|
||||
import { type Api, type Bot, type RawApi } from 'grammy';
|
||||
|
||||
export async function setInfo({ api }: Bot<Context, Api<RawApi>>) {
|
||||
for (const locale of i18n.locales) {
|
||||
await api.setMyDescription(i18n.t(locale, 'description'));
|
||||
await api.setMyShortDescription(
|
||||
i18n.t(locale, 'short-description', { donateLink: env.DONATE_LINK }),
|
||||
);
|
||||
await api.setMyShortDescription(i18n.t(locale, 'short-description'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ import { z } from 'zod';
|
||||
|
||||
export const envSchema = z.object({
|
||||
BOT_TOKEN: z.string(),
|
||||
DONATE_LINK: z.string(),
|
||||
RATE_LIMIT: z
|
||||
.string()
|
||||
.transform((value) => Number.parseInt(value, 10))
|
||||
@ -19,7 +18,6 @@ export const envSchema = z.object({
|
||||
.transform((value) => Number.parseInt(value, 10))
|
||||
.default('6379'),
|
||||
TELEGRAM_API_ROOT: z.string(),
|
||||
YTDLP_PATH: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export const MAX_VIDEO_DURATION_SECONDS = 180; // 3 minutes
|
||||
@ -3,9 +3,6 @@ export const TIKTOK_URL_REGEX =
|
||||
/https:\/\/(?:m|t|www|vm|vt|lite)?\.?tiktok\.com\/(.*\b(?:(?:usr|v|embed|user|video|photo)\/|\?shareId=|&item_id=)(\d+)|\w+)/u;
|
||||
|
||||
export const INSTAGRAM_URL_REGEX =
|
||||
/https?:\/\/(www\.)?instagram\.com\/(p|reel|tv|stories)\/([\w-]+)(\/)?(\?utm_source=ig_web_copy_link&igshid=[a-z0-9]+)?/u;
|
||||
/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 =
|
||||
/(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([\w-]{11})(?:[?&]\S*)?/u;
|
||||
|
||||
export const TAGS = /#[\p{L}\p{N}_]+/gu;
|
||||
export const YOUTUBE_URL_REGEX = /(youtu.*be.*)\/(watch\?v=|embed\/|v|shorts|)(.*?((?=[&#?])|$))/u;
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
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',
|
||||
};
|
||||
|
||||
export const getClient = async () => {
|
||||
const client = wrapper(
|
||||
axios.create({
|
||||
headers,
|
||||
jar,
|
||||
withCredentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// get session cookie
|
||||
await client.get('https://downr.org/.netlify/functions/analytics');
|
||||
|
||||
return client;
|
||||
};
|
||||
@ -1,76 +1,45 @@
|
||||
import { getClient } from './client';
|
||||
import axios from 'axios';
|
||||
|
||||
export type InfoRoot = {
|
||||
author: string;
|
||||
error: boolean;
|
||||
like_count: number;
|
||||
medias: Media[];
|
||||
music_attribution_info: unknown;
|
||||
owner: Owner;
|
||||
shortcode: string;
|
||||
source: string;
|
||||
thumbnail: string;
|
||||
time_end: number;
|
||||
title: string;
|
||||
export interface Root {
|
||||
type: string;
|
||||
id: number;
|
||||
url: string;
|
||||
view_count: unknown;
|
||||
};
|
||||
|
||||
export type Media = {
|
||||
bandwidth?: number;
|
||||
codec?: string;
|
||||
extension: string;
|
||||
frameRate: unknown;
|
||||
id: string;
|
||||
is_audio: boolean;
|
||||
mimeType?: string;
|
||||
quality: string;
|
||||
resolution?: string;
|
||||
thumbnail?: string;
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type Owner = {
|
||||
__typename: string;
|
||||
ai_agent_owner_username: unknown;
|
||||
friendship_status: unknown;
|
||||
id: string;
|
||||
is_private: boolean;
|
||||
is_unpublished: boolean;
|
||||
is_verified: boolean;
|
||||
pk: string;
|
||||
profile_pic_url: string;
|
||||
show_account_transparency_details: boolean;
|
||||
transparency_label: unknown;
|
||||
transparency_product: unknown;
|
||||
transparency_product_enabled: boolean;
|
||||
username: string;
|
||||
};
|
||||
mediaUrls: string[];
|
||||
carouselItems: CarouselItem[];
|
||||
authorInfo: AuthorInfo;
|
||||
}
|
||||
|
||||
export interface CarouselItem {
|
||||
url: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface AuthorInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export async function getInstagramDownloadUrl(url: string) {
|
||||
const client = await getClient();
|
||||
// fetch video info
|
||||
const { data } = await client.post<InfoRoot>('https://downr.org/.netlify/functions/nyt', {
|
||||
const { data } = await axios.post<Root>('https://thesocialcat.com/api/instagram-download', {
|
||||
url,
|
||||
});
|
||||
|
||||
if (!data) throw new Error('err-invalid-instagram-response');
|
||||
if (!data) throw new Error('Invalid Instagram response');
|
||||
|
||||
const video = data.medias.find((media) => media.type === 'video');
|
||||
const isVideo = data.type === 'video' || !data.carouselItems.length;
|
||||
|
||||
if (video) {
|
||||
if (isVideo) {
|
||||
return {
|
||||
caption: data.title,
|
||||
play: data.mediaUrls.at(0),
|
||||
images: [],
|
||||
play: video.url,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
caption: data.title,
|
||||
images: data.medias.map((media) => media.url),
|
||||
images: data.mediaUrls,
|
||||
play: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
export function removeHashtags(caption: string): string {
|
||||
return caption.replaceAll(/#[\p{L}\p{N}_-]+/gu, '').trim();
|
||||
}
|
||||
@ -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('err-invalid-tiktok-response');
|
||||
if (!data) throw new Error('Invalid TikTok response');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { INSTAGRAM_URL_REGEX, TIKTOK_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants/regex';
|
||||
|
||||
export function validateInstagramUrl(url: string) {
|
||||
return INSTAGRAM_URL_REGEX.test(url);
|
||||
}
|
||||
|
||||
export function validateTikTokUrl(url: string) {
|
||||
return TIKTOK_URL_REGEX.test(url);
|
||||
}
|
||||
|
||||
export function validateInstagramUrl(url: string) {
|
||||
return INSTAGRAM_URL_REGEX.test(url);
|
||||
}
|
||||
|
||||
export function validateYoutubeUrl(url: string) {
|
||||
return YOUTUBE_URL_REGEX.test(url);
|
||||
}
|
||||
|
||||
98
apps/bot/src/utils/youtube.ts
Normal file
98
apps/bot/src/utils/youtube.ts
Normal file
@ -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<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,
|
||||
};
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
type DlsrvResponse = {
|
||||
duration: number;
|
||||
filename: string;
|
||||
status: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function getYoutubeDownload(url: string) {
|
||||
const videoId = getYouTubeVideoId(url);
|
||||
|
||||
const { data } = await axios.post<DlsrvResponse>('https://embed.dlsrv.online/api/download/mp4', {
|
||||
format: 'mp4',
|
||||
quality: '720',
|
||||
videoId,
|
||||
});
|
||||
|
||||
return data.url;
|
||||
}
|
||||
|
||||
export function getYouTubeVideoId(link: string) {
|
||||
const url = new URL(link);
|
||||
|
||||
// 1. shorts
|
||||
if (url.pathname.startsWith('/shorts/')) {
|
||||
return url.pathname.split('/')[2] || null;
|
||||
}
|
||||
|
||||
// 2. обычное видео: watch?v=
|
||||
const vParam = url.searchParams.get('v');
|
||||
if (vParam) return vParam;
|
||||
|
||||
// 3. короткая ссылка youtu.be
|
||||
if (url.hostname.includes('youtu.be')) {
|
||||
return url.pathname.slice(1) || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
import { getInfo, ytDlpGetUrl } from '../yt-dlp';
|
||||
import { getYoutubeDownload } from './api';
|
||||
import { MAX_VIDEO_DURATION_SECONDS } from '@/constants/limits';
|
||||
|
||||
export async function getYoutubeDownloadUrl(url: string) {
|
||||
const infoData = await getInfo(url);
|
||||
|
||||
if (!infoData) throw new Error('err-invalid-youtube-response');
|
||||
|
||||
if (infoData.duration > MAX_VIDEO_DURATION_SECONDS)
|
||||
throw new Error('err-youtube-duration-exceeded');
|
||||
|
||||
let play: string | undefined;
|
||||
try {
|
||||
play = await getYoutubeDownload(url);
|
||||
} catch {
|
||||
play = await ytDlpGetUrl(url);
|
||||
}
|
||||
|
||||
return { play, title: infoData.title };
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { runYtDlp } from './yt-dlp';
|
||||
|
||||
export async function ytDlpGetUrl(url: string): Promise<string> {
|
||||
const output = await runYtDlp([
|
||||
'-f',
|
||||
// '298+ba/136+ba/22+ba/247+251/best[height<=720]+ba/bv+ba',
|
||||
'best[height<=720]+ba/bv+ba',
|
||||
'-g',
|
||||
'--no-playlist',
|
||||
'--no-warnings',
|
||||
url,
|
||||
]);
|
||||
|
||||
// yt-dlp может вернуть несколько строк — берём первую
|
||||
const directUrl = output.split('\n')[0];
|
||||
|
||||
if (!directUrl?.startsWith('http')) {
|
||||
throw new Error('Failed to get direct video URL');
|
||||
}
|
||||
|
||||
return directUrl;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
||||
export { ytDlpGetUrl } from './get-download-url';
|
||||
export { getInfo } from './get-info';
|
||||
@ -1,32 +0,0 @@
|
||||
import { env } from '@/config/env';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export function runYtDlp(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ytDlpPath = getYtDlpPath();
|
||||
const process = spawn(ytDlpPath, args);
|
||||
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(output.trim());
|
||||
} else {
|
||||
reject(new Error(`yt-dlp error: ${error}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getYtDlpPath(): string {
|
||||
return env.YTDLP_PATH || 'yt-dlp';
|
||||
}
|
||||
1143
pnpm-lock.yaml
generated
1143
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,7 @@ packages:
|
||||
- packages/*
|
||||
catalog:
|
||||
"@types/node": ^20
|
||||
"@vchikalkin/eslint-config-awesome": ^2.2.3
|
||||
"@vchikalkin/eslint-config-awesome": ^2.2.2
|
||||
dotenv-cli: ^7.4.4
|
||||
eslint: ^9.17.0
|
||||
husky: ^9.1.7
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user