Как обходится ограничение скорости скачивания с YouTube

в 7:45, , рубрики: YouTube, загрузка, потоковое видео, скачивание файлов

Вы когда-нибудь пробовали скачивать видео с YouTube? Я имею в виду ручками, а не через такие софтины, как youtube-dl, yt-dlp или один из «этих» сайтов. Оказывается, это гораздо сложнее, чем можно было бы подумать.

Youtube зарабатывает на показе рекламы пользователям. Поэтому с точки зрения платформы логично внедрить специальные ограничения, которые не позволяли бы скачивать видеоролики или даже просматривать их через неофициальный клиент, например YouTube Vanced. В этой статье будут пояснены технические детали тех механизмов безопасности, что действуют в Youtube, и рассказано, как их обойти.

Гуглим качалку для YouTube
Гуглим качалку для 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

https://github.com/Theta-Dev/NewPipeExtractor/blob/dev/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeJavaScriptExtractor.java

node-ytdl-core

https://github.com/fent/node-ytdl-core/blob/master/lib/sig.js

Автор:
Sivchenko_translate

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js