Compare commits

...

8 Commits

Author SHA1 Message Date
vchikalkin
4e5631487f feat: add support for donation link in bot descriptions and update short descriptions 2026-02-17 23:50:28 +03:00
Vlad Chikalkin
7cc225ca47
feat(youtube): refactor download logic and integrate new API (#7)
- Added optional YTDLP_PATH environment variable to configure yt-dlp path.
- Removed old youtube download utility and replaced it with a new API-based approach.
- Implemented new functions to fetch YouTube video download URLs using an external service.
- Introduced yt-dlp integration for fallback download method.
- Created a structured response for video information retrieval.
- Enhanced error handling for video duration and response validation.
2026-02-17 23:32:35 +03:00
vchikalkin
88aa8eb34a fix: instagram reels download 2026-01-20 20:16:29 +03:00
Vlad Chikalkin
9a504c10a3
fix: use downr.org to download from instagram source (#6) 2026-01-20 18:25:53 +03:00
vchikalkin
ce11004de9 ci: add 'sudo' to deploy commands 2026-01-19 16:47:00 +03:00
vchikalkin
8a8a21b155 refactor: extract download logic and clean up caption handling 2026-01-15 12:16:47 +03:00
vchikalkin
1f5b4073e0 refactor: remove unnecessary comments and improve code clarity in download feature 2026-01-15 12:04:57 +03:00
Vlad Chikalkin
8919fbb65f
Feature/send post description (#5)
* feat: enhance download feature with caption support for Instagram

* feat: improve message handling with enhanced caching and error management

* feat: add hashtag removal utility and integrate it into caption formatting

* fix: bot sent caption without content from cache

* feat: refactor Instagram download URL handling to simplify video response structure
2026-01-14 18:26:24 +03:00
18 changed files with 2098 additions and 169 deletions

View File

@ -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 && \
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
docker compose pull && \
docker compose down && \
docker compose up -d
sudo docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
sudo docker compose pull && \
sudo docker compose down && \
sudo docker compose up -d
"

View File

@ -64,7 +64,8 @@ RUN apk add --no-cache \
pixman \
pangomm \
libjpeg-turbo \
freetype
freetype \
yt-dlp
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 botuser

View File

@ -10,9 +10,9 @@ description =
⚡ Simply send the video link, and the bot will download it for you.
short-description =
Download TikTok, Instagram and YouTube videos and images.
For any questions: @v_dev_support
Bot downloads videos and pictures
Support: @v_dev_support
Donate: { $donateLink }
start =

View File

@ -10,9 +10,9 @@ description =
⚡ Просто отправьте ссылку на видео и изображения, и бот сразу скачает его для вас!
short-description =
Скачивай видео и изображения из TikTok, Instagram и YouTube.
По всем вопросам: @v_dev_support
Бот скачивает видео и изображения
Поддержка: @v_dev_support
Поблагодарить: { $donateLink }
start =

View File

@ -4,17 +4,157 @@ 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 { Composer, InputFile } from 'grammy';
import { cluster } from 'radashi';
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();
@ -22,36 +162,31 @@ feature.on('message:text', logHandle('download-message'), async (context) => {
const isInstagram = validateInstagramUrl(url);
const isYoutube = validateYoutubeUrl(url);
const isSupportedService = isTikTok || isInstagram || isYoutube;
if (!isSupportedService) {
if (!isTikTok && !isInstagram && !isYoutube) {
return context.reply(context.t('err-invalid-url'));
}
const cachedFileId = await redis.get(url);
if (cachedFileId) {
return context.replyWithVideo(cachedFileId);
}
const cacheResult = await checkCacheAndReply(context, url);
if (cacheResult.captionSent) return;
let contentMessageId = cacheResult.contentMessageId;
let imagesUrls: string[] | undefined;
let videoUrl: string | undefined;
let caption: string | undefined;
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 (error_: unknown) {
const error = error_ as Error;
const message = error?.message ?? String(error);
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));
}
@ -63,21 +198,9 @@ feature.on('message:text', logHandle('download-message'), async (context) => {
return context.reply(context.t('err-invalid-download-urls'));
}
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);
}
contentMessageId = await sendImages(context, imagesUrls ?? [], contentMessageId);
contentMessageId = await sendVideoAndCache(context, videoUrl, url, contentMessageId);
await sendCaptionAndCache(context, caption, url, contentMessageId);
});
export { composer as download };

View File

@ -1,10 +1,13 @@
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'));
await api.setMyShortDescription(
i18n.t(locale, 'short-description', { donateLink: env.DONATE_LINK }),
);
}
}

View File

@ -3,6 +3,7 @@ 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))
@ -18,6 +19,7 @@ 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);

View File

@ -7,3 +7,5 @@ export const INSTAGRAM_URL_REGEX =
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;

View File

@ -0,0 +1,37 @@
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;
};

View File

@ -1,45 +1,76 @@
import axios from 'axios';
import { getClient } from './client';
export type AuthorInfo = {
avatar: string;
id: number;
nickname: string;
username: string;
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;
type: string;
url: string;
view_count: unknown;
};
export type CarouselItem = {
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 Root = {
authorInfo: AuthorInfo;
carouselItems: CarouselItem[];
id: number;
mediaUrls: 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;
};
export async function getInstagramDownloadUrl(url: string) {
const { data } = await axios.post<Root>('https://thesocialcat.com/api/instagram-download', {
const client = await getClient();
// fetch video info
const { data } = await client.post<InfoRoot>('https://downr.org/.netlify/functions/nyt', {
url,
});
if (!data) throw new Error('err-invalid-instagram-response');
const isVideo = data.type === 'video' || !data.carouselItems.length;
const video = data.medias.find((media) => media.type === 'video');
if (isVideo) {
if (video) {
return {
caption: data.title,
images: [],
play: data.mediaUrls.at(0),
play: video.url,
};
}
return {
images: data.mediaUrls,
caption: data.title,
images: data.medias.map((media) => media.url),
play: undefined,
};
}

View File

@ -0,0 +1,3 @@
export function removeHashtags(caption: string): string {
return caption.replaceAll(/#[\p{L}\p{N}_-]+/gu, '').trim();
}

View File

@ -1,100 +0,0 @@
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({
headers,
jar,
withCredentials: true,
}),
);
export type DownloadRoot = {
duration: number;
filename: string;
status: string;
url: string;
};
export type InfoRoot = {
duration: number;
medias: Media[];
thumbnail: string;
title: string;
};
export type Media = {
extension: string;
fileSize: number;
quality: string;
type: string;
};
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,
};
}

View File

@ -0,0 +1,40 @@
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;
}

View File

@ -0,0 +1,21 @@
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 };
}

View File

@ -0,0 +1,22 @@
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

View File

@ -0,0 +1,2 @@
export { ytDlpGetUrl } from './get-download-url';
export { getInfo } from './get-info';

View File

@ -0,0 +1,32 @@
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';
}