Вы когда-нибудь пробовали скачивать видео с YouTube? Я имею в виду ручками, а не через такие софтины, как youtube-dl, yt-dlp или один из «этих» сайтов. Оказывается, это гораздо сложнее, чем можно было бы подумать.
Youtube зарабатывает на показе рекламы пользователям. Поэтому с точки зрения платформы логично внедрить специальные ограничения, которые не позволяли бы скачивать видеоролики или даже просматривать их через неофициальный клиент, например YouTube Vanced. В этой статье будут пояснены технические детали тех механизмов безопасности, что действуют в Youtube, и рассказано, как их обойти.
Извлечение URL
Первым делом нужно найти тот URL, по которому содержится файл. Для этого можно обратиться к API YouTube. В частности, через конечную точку /youtubei/v1/player
можно извлечь всю метаинформацию о видео, а именно: заголовок, описание, эскизы и, что самое важное — форматы. Именно из этих форматов можно выцепить URL файла, отталкиваясь от желаемого качества (SD, HD, UHD, т.д.).
Возьмём, к примеру, видео с ID aqz-KE-bpKQ
, где получаем URL для одного из форматов. Обратите внимание: другие объекты, содержащиеся в объекте context
– это предусловия, проверяемые API. Принимаемые значения удалось найти, наблюдая, какие именно запросы посылает браузер.
echo -n '{"videoId":"aqz-KE-bpKQ","context":{"client":{"clientName":"WEB","clientVersion":"2.20230810.05.00"}}}' |
http post 'https://www.youtube.com/youtubei/v1/player' |
jq -r '.streamingData.adaptiveFormats[0].url'
https://rr1---sn-8qu-t0aee.googlevideo.com/videoplayback?expire=1691828966&ei=hu7WZOCJHI7T8wTSrr_QBg [TRUNCATED]
Правда, если попытаться скачать файл по этому URL, загрузка пойдёт очень медленно:
http --print=b --download 'https://rr1---sn-8qu-t0aee.googlevideo.com/videoplayback?expire=1691828966&ei=hu7WZOCJHI7T8wTSrr_QBg [TRUNCATED]'
Downloading to videoplayback.webm
[ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ] 0% ( 0.0/1.5 GB ) 6:23:45 66.7 kB/s
Скорость загрузки всегда ограничена примерно 40-70 кБ/с. К сожалению, на скачивание этого десятиминутного видео уходит примерно шесть с половиной часов. А через браузер, очевидно, получается гораздо быстрее. Так в чём же разница?
Давайте разберём этот URL по частям. Он довольно сложный, но в нём есть конкретный параметр, который нас и интересует.
Protocol: https
Hostname: rr1---sn-8qu-t0aee.googlevideo.com
Path name: /videoplayback
Query Parameters:
expire: 1691829310
ei: 3u_WZJT7Cbag_9EPn7mi0A8
ip: 203.0.113.30
id: o-ABGboQn9qMKsUdClvQHd6cHm6l1dWkRw4WNj3V7wBgY1
itag: 315
aitags: 133,134,135,136,160,242,243,244,247,278,298,299,302,303,308,315,394,395,396,397,398,399,400,401
source: youtube
requiressl: yes
mh: aP
mm: 31,29
mn: sn-8qu-t0aee,sn-t0a7ln7d
ms: au,rdu
mv: m
mvi: 1
pcm2cms: yes
pl: 18
initcwndbps: 1422500
spc: UWF9fzkQbIbHWdKe8-ahg0uWbE_UrbUM0U6LbQfFxg
vprv: 1
svpuc: 1
mime: video/webm
ns: dn5MLRkBtM4BWwzNNOhVxHIP
gir: yes
clen: 1536155487
dur: 634.566
lmt: 1662347928284893
mt: 1691807356
fvip: 3
keepalive: yes
fexp: 24007246,24363392
c: WEB
txp: 553C434
n: mAq3ayrWqdeV_7wbIgP
sparams: expire,ei,ip,id,aitags,source,requiressl,spc,vprv,svpuc,mime,ns,gir,clen,dur,lmt
sig: AOq0QJ8wRgIhAOx29gNeoiOLRe1GhEfE52PAiXW64ZEWX7nNdAiJE6ezAiEA0Plw6Yn0kmSFFZHO2JZPZyMGd0O-gEblUXPRrexQgrY=
lsparams: mh,mm,mn,ms,mv,mvi,pcm2cms,pl,initcwndbps
lsig: AG3C_xAwRQIgZVOkDl4rGPGnlK6IGCAXpzxk-cB5RRFmXDesEqOWTRoCIQCzIdPKE6C6_JQVpH6OKMF3woIJ2yVYaztT9mXIVtE6xw==
С середины 2021 года YouTube включает в большинство URL параметр запроса n. Этот параметр требуется преобразовывать при помощи алгоритма JavaScript, расположенного в файле base.js, который распространяется вместе с веб-страницей. YouTube использует этот параметр как клеймо, удостоверяющее, что загрузка выполнена через «официальный» клиент. Если клеймо не подтверждено, и n преобразовано неправильно, YouTube тихонько ограничит скорость при скачивании этого видео.
Алгоритм JavaScript обфусцирован и часто меняется, поэтому пытаться взломать его через реверс-инжиниринг — дохлый номер. Лучше просто скачать файл JavaScript, извлечь из него код алгоритма и выполнить, передав в этот код параметр n. (Кстати, вот урезанная версия интерпретатора JavaScript для работы с youtube-dl
- прим. пер).
Именно это и сделано в следующем листинге.
import axios from 'axios';
import vm from 'vm'
const videoId = 'aqz-KE-bpKQ';
/**
* Извлекаем через API Youtube метаданные о видео (заголовок, формат видео и формат аудио)
*/
async function retrieveMetadata(videoId) {
const response = await axios.post('https://www.youtube.com/youtubei/v1/player', {
"videoId": videoId,
"context": {
"client": { "clientName": "WEB", "clientVersion": "2.20230810.05.00" }
}
});
const formats = response.data.streamingData.adaptiveFormats;
return [
response.data.videoDetails.title,
formats.filter(w => w.mimeType.startsWith("video/webm"))[0],
formats.filter(w => w.mimeType.startsWith("audio/webm"))[0],
];
}
/**
* С веб-страницы Youtube извлекаем алгоритм, позволяющий проверить параметр n данного запроса
*/
async function retrieveChallenge(video_id){
/**
* Находим URL файла javascript для актуальной версии плеера
*/
async function retrieve_player_url(video_id) {
let response = await axios.get('https://www.youtube.com/embed/' + video_id);
let player_hash = /\/s\/player\/(\w+)\/player_ias.vflset\/\w+\/base.js/.exec(response.data)[1]
return `https://www.youtube.com/s/player/${player_hash}/player_ias.vflset/en_US/base.js`
}
const player_url = await retrieve_player_url(video_id);
const response = await axios.get(player_url);
let challenge_name = /\.get\("n"\)\)&&\(b=([a-zA-Z0-9$]+)(?:\[(\d+)\])?\([a-zA-Z0-9]\)/.exec(response.data)[1];
challenge_name = new RegExp(`var ${challenge_name}\\s*=\\s*\\[(.+?)\\]\\s*[,;]`).exec(response.data)[1];
const challenge = new RegExp(`${challenge_name}\\s*=\\s*function\\s*\\(([\\w$]+)\\)\\s*{(.+?}\\s*return\\ [\\w$]+.join\\(""\\))};`, "s").exec(response.data)[2];
return challenge;
}
/**
* Проходим проверку и меняем параметр запроса n из url
*/
function solveChallenge(challenge, formatUrl) {
const url = new URL(formatUrl);
const n = url.searchParams.get("n");
const n_transformed = vm.runInNewContext(`((a) => {${challenge}})('${n}')`);
url.searchParams.set("n", n_transformed);
return url.toString();
}
const [title, video, audio] = await retrieveMetadata(videoId);
const challenge = await retrieveChallenge(videoId);
video.url = solveChallenge(challenge, video.url);
audio.url = solveChallenge(challenge, audio.url);
console.log(video.url);
Скачиваем медиа-файлы
Теперь у нас есть новый URL с корректно преобразованным параметром n. Дальше нужно скачать видео. Правда, YouTube всё равно обязывает ограничивать скорость загрузки. Речь идёт о лимите на скорость закачки, который варьируется в зависимости от размера и длительности видео. Цель — добиться, чтобы длительность скачивания была примерно вдвое меньше длительности самого видео. Это логично с точки зрения потоковой природы видеороликов. Для YouTube было бы крайне расточительно расходовать полосу передачи данных так, чтобы файл всегда скачивался в кратчайший срок.
http --print=b --download 'https://rr1---sn-8qu-t0aee.googlevideo.com/videoplayback?expire=1691888702&ei=3tfXZIXSI72c_9EP1NGHqA8 [TRUNCATED]'
Downloading to videoplayback.webm
[ ━╸━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ] 4% ( 0.1/1.5 GB ) 0:06:07 4.0 MB/s
Чтобы обойти это ограничение, можно разбить загрузку на несколько более мелких фрагментов; это делается при помощи HTTP-заголовка Range
. В этом заголовке можно указать, какую часть файла вы хотите скачать при каждом из запросов (например: Range bytes=2000-3000
). Эта логика реализована в следующем коде.
/**
* Скачать медиа-файл, разбив его на несколько сегментов по 10 МБ
*/
async function download(url, length, file){
const MEGABYTE = 1024 * 1024;
await fs.promises.rm(file, { force: true });
let downloadedBytes = 0;
while (downloadedBytes < length) {
let nextSegment = downloadedBytes + 10 * MEGABYTE;
if (nextSegment > length) nextSegment = length;
// Скачать сегмент
const start = Date.now();
let response = await axios.get(url, { headers: { "Range": `bytes=${downloadedBytes}-${nextSegment}` }, responseType: 'stream' });
// Записать сегмент
await fs.promises.writeFile(file, response.data, {flag: 'a'});
const end = Date.now();
// Вывести статистику загрузки
const progress = (nextSegment / length * 100).toFixed(2);
const total = (length / MEGABYTE).toFixed(2);
const speed = ((nextSegment - downloadedBytes) / (end - start) * 1000 / MEGABYTE).toFixed(2);
console.log(`${progress}% of ${total}MB at ${speed}MB/s`);
downloadedBytes = nextSegment + 1;
}
}
Такой подход работает, поскольку на применение ограничения уходит некоторое время, а мелкие сегменты загружаются очень быстро, и для каждого сегмента открывается новое соединение.
node index.js
0.68% of 1464.99MB at 46.73MB/s
1.37% of 1464.99MB at 60.98MB/s
2.05% of 1464.99MB at 71.94MB/s
2.73% of 1464.99MB at 70.42MB/s
3.41% of 1464.99MB at 68.49MB/s
4.10% of 1464.99MB at 68.97MB/s
4.78% of 1464.99MB at 74.07MB/s
5.46% of 1464.99MB at 81.97MB/s
6.14% of 1464.99MB at 104.17MB/s
Теперь мы можем скачивать видео гораздо быстрее. Когда я тестировал код, при некоторых закачках соединение в 1 Гб/с удавалось использовать почти целиком. Но средняя скорость обычно варьируется в пределах 50-70 МБ/с или 400-560 Mб/с, что всё равно достаточно быстро.
Постобработка
YouTube раздаёт каналы видео и аудио в двух отдельных файлах. Так экономится место, поскольку видео в качестве HD и UHD могут использовать один и тот же аудио-файл. Кроме того, в некоторых видео теперь предлагаются и другие аудиоканалы (с привязкой к языку текста). Следовательно, нам остаётся только лишь совместить два этих канала в один файл, и для этого можно воспользоваться ffmpeg
.
/**
* Использование ffmpeg, комбинируем воедино аудио и видео
*/
async function combineChannels(destinationFile, videoFile, audioFile)
{
await fs.promises.rm(destinationFile, { force: true });
child_process.spawnSync('ffmpeg', [
"-y",
"-i", videoFile,
"-i", audioFile,
"-c", "copy",
"-map", "0:v:0",
"-map", "1:a:0",
destinationFile
]);
await fs.promises.rm(videoFile, { force: true });
await fs.promises.rm(audioFile, { force: true });
}
Наконец, если кому-то интересно, полный код к этому посту можно скачать здесь.
Заключение
Такие приёмы помогают многим проектам обходить ограничения YouTube по скачиванию видео. Самые популярные из них - yt-dlp (форк youtube-dl), написанный на Python, но в нём встроен собственный интерпретатор JavaScript, при помощи которого преобразуется параметр n.
yt-dlp
https://github.com/ytdl-org/youtube-dl/blob/master/youtube_dl/extractor/youtube.py
Медиаплеер VLC
https://github.com/videolan/vlc/blob/master/share/lua/playlist/youtube.lua
NewPipe
node-ytdl-core
https://github.com/fent/node-ytdl-core/blob/master/lib/sig.js