Вы когда-нибудь пробовали скачивать видео с 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
