Обвиваем YouTube змеем, или как смотреть и скачивать видео с YouTube без VPN на чистом Python-е. Часть 1

в 15:42, , рубрики: dpi, python, YouTube, обход блокировок, обход блокировок youtube, Роскомнадзор, скачивание видео

Современный мир пресыщен различной информацией, и в наше непростое время её важно уметь не только находить и сохранять. Многие наверняка заметили, что в на YouTube, кроме мусора, котиков и прочих бесполезных вещей (которые мы иногда не прочь посмотреть) есть масса полезного материала по самым различным темам. И иногда этот материал неплохо было бы сохранить себе на будущее, чтобы не зависеть от переменчивых настроений в мире.

В этой статье я хочу рассказать, как можно скачивать видео, аудио (1 часть статьи), плейлисты и целые каналы с YouTube (2 часть статьи) без использования VPN и на чистом Python-е. Сразу оговорка: VPN нам не понадобится, но мы сделаем собственное средство, которое будет решать "проблему с устаревшим и изношенным оборудованием Google Global Cache" (вы поняли, о чём я). Я думаю это средство будет особенно актуально сегодня, когда у многих россиян YouTube почти или совсем не работает.


Зачем изобретать велосипед и почему Python?

  1. Я программирую на Python, и о чём мне рассказывать как не о нём?

  2. У python есть широкие возможности для создания таких инструментов и готовые библиотеки с большим функционалом.

  3. Существуют такие замечательные утилиты, как yt-dlp и youtube-dl, но они подходят, когда нужно по-быстрому что-то скачать. Тонкая настройка и скачивание, например целого канала, вещь довольно геморройная (если, нет - поправьте меня), так как постоянно нужно что-то гуглить и знать многочисленные ключи и параметры.

  4. Существуют и другие десктопные программы, мобильные приложения и сайты, но как правило они или платные, или напичканы рекламой, или ограничены в функционале, или даже всё вместе.

  5. Ну и по-моему, собственный скрипт всегда удобнее любой навороченной утилиты, да и глаз радуется на красивые строчки в редакторе)

Этап 1. Обходим блок… ой!.. Решаем проблему с устаревшим и изношенным оборудованием.

Почему YouTube у нас работает не так, как надо? Конечно, все знают, что дело здесь не в оборудовании, а в том, что провайдеры используют такую зловредную (в данном случае) вещь, как DPI (англ. Deep Packet Inspection «глубокая инспекция пакетов»)

How it works

Большинство DPI работает так. Когда вы пытаетесь получить доступ к заблокированному веб-сайту, DPI отправляет вам HTTP 302 Redirect, и делает это быстрее, чем веб-сайт к которому вы обращаетесь. В отличие от брандмауэров, Deep Packet Inspection анализирует не только заголовки пакетов, но и содержимое запросов, что позволяет интернет-провайдерам и государственным органам ограничивать доступ к запрещенным ресурсам, выявлять вторжения в сеть и останавливать распространение компьютерных вирусов.

Это довольно упрощенная модель пассивного DPI, но так как целью этой статьи не является подробное ознакомление с этой темой, да и я, честно признаюсь, не большой специалист в этом, то думаю этого достаточно для понимания того, что происходит. Если вас интересует эта тема, то можете почитать здесь и здесь.

Что же делать

Но, на каждый болт найдется своя гайка, и есть способы обойти эту вещь (хотя они не всегда действуют). Многие наверняка слышали про GoodbyeDPI или zapret. Это замечательные инструменты, но раз уж мы говорим про Python, то давайте сделаем свой аналог на Python. Поискав немного, я нашел nodpi, который использует только python.

Как это работает

nodpi создает прокси-сервер (socket), через который мы гоним трафик. Все исходящие соединения он случайным образом фрагментирует, тем самым сбивая с толку DPI.

Плюсы

  1. Это написано на Python!

  2. Просто, дёшево, сердито и работает для большинства провайдеров

  3. Не требуются привилегии администратора, как, например у GoodbyeDPI

  4. Прокси-сервер можно настроить только в некоторых приложениях и не гнать через него весь трафик системы.

Минусы

  1. Простота - не всегда качество, поэтому гарантия не даётся

  2. Работает только для TCP

  3. Не поможет, если сайт забанен по IP

Пишем..., нет - переписываем код

Программа написана довольно небрежно, словно у разработчика слипались глаза и он залил код прямо так, не разбираясь, и поэтому я приведу причёсанный код и заодно немного расскажу, что он делает.

Для начала создадим текстовый файл blacklist.txt и добавим туда домены, блокировку которых хотим обходить. Для YouTube это:

youtube.com
youtu.be
yt.be
googlevideo.com
ytimg.com
ggpht.com
gvt1.com
youtube-nocookie.com
youtube-ui.l.google.com
youtubeembeddedplayer.googleapis.com
youtube.googleapis.com
youtubei.googleapis.com
yt-video-upload.l.google.com
wide-youtube.l.google.com

Теперь создадим файл nodpi.py и добавим в него следующий код

import random
import asyncio

BLOCKED = [line.rstrip().encode() for line in open('blacklist.txt', 'r', encoding='utf-8')]
TASKS = []

Что мы здесь делаем:

  1. Импортируем необходимые библиотеки

  2. Создаём список заблокированных доменов

  3. Создаём список, в котором будем хранить задачи передачи данных между клиентом и сервером

async def main(host, port):

    server = await asyncio.start_server(new_conn, host, port)
    await server.serve_forever()

Создаём входную точку - асинхронную функцию, которая запускает сокет-сервер на указанном хосте и порту и используем new_conn (ниже) для обработки новых соединений.

async def pipe(reader, writer):

    while not reader.at_eof() and not writer.is_closing():
        try:
            writer.write(await reader.read(1500))
            await writer.drain()
        except:
            break

    writer.close()

Создаём асинхронную функцию, которая читает данные из reader и записывает их в writer до тех пор, пока не достигнут конец или соединение не закрыто.

async def new_conn(local_reader, local_writer):

    http_data = await local_reader.read(1500)

    try:
        type, target = http_data.split(b"rn")[0].split(b" ")[0:2]
        host, port = target.split(b":")
    except:
        local_writer.close()
        return

    if type != b"CONNECT":
        local_writer.close()
        return

    local_writer.write(b'HTTP/1.1 200 OKnn')
    await local_writer.drain()

    try:
        remote_reader, remote_writer = await asyncio.open_connection(host, port)
    except:
        local_writer.close()
        return

    if port == b'443':
        await fragemtn_data(local_reader, remote_writer)

    TASKS.append(asyncio.create_task(pipe(local_reader, remote_writer)))
    TASKS.append(asyncio.create_task(pipe(remote_reader, local_writer)))
  1. Создаём асинхронную функцию, которая обрабатывает новое соединение.

  2. Читаем HTTP-заголовки и извлекаем тип запроса и целевой адрес.

  3. Если тип запроса не CONNECT, закрываем соединение.

  4. Отправляем ответ HTTP/1.1 200 OK клиенту.

  5. Открываем соединение с целевым сервером.

  6. Если порт равен 443 (порт HTTPS), вызываем функцию fragemtn_data (ниже)

  7. Создаем задачи для передачи данных между клиентом и сервером.

async def fragemtn_data(local_reader, remote_writer):

    head = await local_reader.read(5)
    data = await local_reader.read(1500)
    parts = []

    if all([data.find(site) == -1 for site in BLOCKED]):
        remote_writer.write(head + data)
        await remote_writer.drain()

        return

    while data:
        part_len = random.randint(1, len(data))
        parts.append(bytes.fromhex("1603") + bytes([random.randint(0, 255)]) + int(
            part_len).to_bytes(2, byteorder='big') + data[0:part_len])

        data = data[part_len:]

    remote_writer.write(b''.join(parts))
    await remote_writer.drain()
  1. Создаём асинхронную функцию, которая фрагментирует данные перед отправкой на сервер.

  2. Читаем заголовок и данные.

  3. Если данные не из заблокированных доменов, отправляем их без изменений.

  4. В противном случае, разбиваем данные на случайные части и добавляем заголовки перед отправкой. Это помогает обойти блокировку DPI, так как фрагментированные данные выглядят как случайные пакеты.

asyncio.run(main(host='127.0.0.1', port=8881))

Запускаем сервер на локальном 127.0.0.1 и слушаем порт 8881

Всё, можно пользоваться!

Давайте проверим. Если у вас Firefox, то заходим в Настройки → Настройки сети и прописываем IP и порт:

Настройка прокси в Firefox

Настройка прокси в Firefox

Открываем YouTube и убеждаемся, что все работает или не работает :)

Этап 2. Скачиваем!

Установка необходимого инструментария

Для начала установим библиотеку pytubefix командой:

pip install pytubefix
Немного о pytubefix

pytubefix - это мощная библиотека python для скачивания с YouTube видео, аудио, плейлистов и каналов со своим собственным cli. Сама по себе она фактически является форком pytube, но последние изменения в pytube были сделаны больше года назад, и в августе этого года, после изменений в api YouTube она перестала работать. Также в pytubefix исправлены многие недочеты предшественницы и добавлены новые функции. pytubefix, как и pytube, работает с api youtube, а кое-где и парсит его html/json.

Также нам понадобится ffmpeg, для объединения аудио и видео (если, конечно, вы хотите скачивать в высоком качестве). Почему? Дело в том, что YouTube дает видео с аудио в одном потоке только в разрешении 360p (раньше было 720p), и чтобы скачать видео, например в 1080p, нужно сначала скачать видео в 1080p, потом скачать аудио, и затем все это объединить конвертером. ffmpeg существует и для Windows. Проверенную сборку можно скачать с моего Google Диска, но вы можете найти ее и самостоятельно в интернете.

Простая загрузка видео

Давайте попробуем скачать какое-нибудь видео. Создайте файл yt_downloader.py и вставьте в него код:

from pytubefix import YouTube
from pytubefix.cli import on_progress

url = "https://www.youtube.com/watch?v=xxxxxxxxxxx"

video = YouTube(
    proxies={"http": "http://127.0.0.1:8881",
             "https": "http://127.0.0.1:8881"},
    url=url,
    on_progress_callback=on_progress,
)

print('Title:', yt.title)

stream = video.streams.get_highest_resolution()
stream.download()

Давайте разберём, что делает этот код. Сначала мы импортируем класс YouTube, с помощью которого мы скачиваем видео, и функцию on_progress. Она нужна для того, чтобы во время скачивания отображался прогрессбар загрузки. Согласитесь, что так гораздо удобнее? При желании можно написать свою функцию-callback. Она должна иметь аргументы (stream: Stream, chunk: bytes, bytes_remaining: int)

В переменную url вставьте свою ссылку для скачивания, так как я заменил id видео иксами. Далее мы создаем экземпляр класса YouTube и передаем ему словарь с адресом нашего прокси-сервера, url и callback. Все аргументы, кроме url являются необязательными. Также стоит отметить, что callback вызывается каждый раз, после скачивания нового чанка (pytubefix скачивает видео кусками - чанками).

Далее мы выводим на экран заголовок (название) видео. Также можно узнать дату публикации, длину видео (в секундах), описание, количество просмотров и автора. Размер файла можно узнать с помощью stream.filesize, так как он зависит от выбранного потока.

В строке stream = video.streams.get_highest_resolution() мы получаем список потоков и сортируем его по разрешению (качеству) видео. Тут я хочу остановиться и рассказать немного подробнее. У каждого видео на YouTube есть несколько десятков потоков, каждый из которых представляет собой одно видео, одно аудио или все сразу в разных форматах, кодировках и качестве. Так, для моего видео список потоков выглядит примерно так:

Осторожно, длинная портянка!
[
    {
        "itag": 18,
        "mime_type": "video/mp4",
        "resolution": "360p",
        "vcodec": "avc1.42001E",
        "acodec": "mp4a.40.2",
        "progressive": True,
        "type": "video",
    },
    {
        "itag": 315,
        "mime_type": "video/webm",
        "resolution": "2160p",
        "vcodec": "vp9",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 337,
        "mime_type": "video/webm",
        "resolution": "2160p",
        "vcodec": "vp9.2",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 701,
        "mime_type": "video/mp4",
        "resolution": "2160p",
        "vcodec": "av01.0.13M.10.0.110.09.18.09.0",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 308,
        "mime_type": "video/webm",
        "resolution": "1440p",
        "vcodec": "vp9",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 336,
        "mime_type": "video/webm",
        "resolution": "1440p",
        "vcodec": "vp9.2",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 700,
        "mime_type": "video/mp4",
        "resolution": "1440p",
        "vcodec": "av01.0.12M.10.0.110.09.18.09.0",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 299,
        "mime_type": "video/mp4",
        "resolution": "1080p",
        "vcodec": "avc1.64002a",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 303,
        "mime_type": "video/webm",
        "resolution": "1080p",
        "vcodec": "vp9",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 335,
        "mime_type": "video/webm",
        "resolution": "1080p",
        "vcodec": "vp9.2",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 699,
        "mime_type": "video/mp4",
        "resolution": "1080p",
        "vcodec": "av01.0.09M.10.0.110.09.18.09.0",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 298,
        "mime_type": "video/mp4",
        "resolution": "720p",
        "vcodec": "avc1.4d4020",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 302,
        "mime_type": "video/webm",
        "resolution": "720p",
        "vcodec": "vp9",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 334,
        "mime_type": "video/webm",
        "resolution": "720p",
        "vcodec": "vp9.2",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 698,
        "mime_type": "video/mp4",
        "resolution": "720p",
        "vcodec": "av01.0.08M.10.0.110.09.18.09.0",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 135,
        "mime_type": "video/mp4",
        "resolution": "480p",
        "vcodec": "avc1.4d401f",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 244,
        "mime_type": "video/webm",
        "resolution": "480p",
        "vcodec": "vp9",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 333,
        "mime_type": "video/webm",
        "resolution": "480p",
        "vcodec": "vp9.2",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 697,
        "mime_type": "video/mp4",
        "resolution": "480p",
        "vcodec": "av01.0.05M.10.0.110.09.18.09.0",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 134,
        "mime_type": "video/mp4",
        "resolution": "360p",
        "vcodec": "avc1.4d401e",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 243,
        "mime_type": "video/webm",
        "resolution": "360p",
        "vcodec": "vp9",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 332,
        "mime_type": "video/webm",
        "resolution": "360p",
        "vcodec": "vp9.2",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 696,
        "mime_type": "video/mp4",
        "resolution": "360p",
        "vcodec": "av01.0.04M.10.0.110.09.18.09.0",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 133,
        "mime_type": "video/mp4",
        "resolution": "240p",
        "vcodec": "avc1.4d4015",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 242,
        "mime_type": "video/webm",
        "resolution": "240p",
        "vcodec": "vp9",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 331,
        "mime_type": "video/webm",
        "resolution": "240p",
        "vcodec": "vp9.2",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 695,
        "mime_type": "video/mp4",
        "resolution": "240p",
        "vcodec": "av01.0.01M.10.0.110.09.18.09.0",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 160,
        "mime_type": "video/mp4",
        "resolution": "144p",
        "vcodec": "avc1.4d400c",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 278,
        "mime_type": "video/webm",
        "resolution": "144p",
        "vcodec": "vp9",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 330,
        "mime_type": "video/webm",
        "resolution": "144p",
        "vcodec": "vp9.2",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 694,
        "mime_type": "video/mp4",
        "resolution": "144p",
        "vcodec": "av01.0.00M.10.0.110.09.18.09.0",
        "acodec": None,
        "progressive": False,
        "type": "video",
    },
    {
        "itag": 139,
        "mime_type": "audio/mp4",
        "resolution": None,
        "vcodec": None,
        "acodec": "mp4a.40.5",
        "progressive": False,
        "type": "audio",
    },
    {
        "itag": 140,
        "mime_type": "audio/mp4",
        "resolution": None,
        "vcodec": None,
        "acodec": "mp4a.40.2",
        "progressive": False,
        "type": "audio",
    },
    {
        "itag": 249,
        "mime_type": "audio/webm",
        "resolution": None,
        "vcodec": None,
        "acodec": "opus",
        "progressive": False,
        "type": "audio",
    },
    {
        "itag": 250,
        "mime_type": "audio/webm",
        "resolution": None,
        "vcodec": None,
        "acodec": "opus",
        "progressive": False,
        "type": "audio",
    },
    {
        "itag": 251,
        "mime_type": "audio/webm",
        "resolution": None,
        "vcodec": None,
        "acodec": "opus",
        "progressive": False,
        "type": "audio",
    },
]

Вы можете посмотреть полный список потоков для своего видео, просто выведя на экран print(video.streams), но как правило это не требуется, так как библиотека предоставляет средства для их сортировки и фильтрации. В нашем случае это get_highest_resolution(). Эта функция возвращает поток с самым лучшим разрешением, который содержит и аудио, и видео. Как я уже объяснял, таким разрешением окажется 360p, потому что YouTube дает видео с аудио в одном потоке только в разрешении 360p (раньше было 720p), и чтобы скачать видео, например в 1080p, нужно сначала скачать видео в 1080p, потом скачать аудио, и затем все это объединить конвертером. Скрипт, который скачивает видео в более высоком качестве, мы напишем чуть позднее.

Ну и наконец, в строке stream.download() мы скачиваем видео. При скачивании будет отображаться прогрессбар, и вы увидите процесс загрузки.

Запустите скрипт и посмотрите результат. Не забудьте перед этим запустить нашу программу для обхода DPI!

Скачиваем видео в высоком разрешении

Видео в 360p, это, конечно, не очень. Давайте расширим нашу программу. Если вы еще не скачали ffmpeg, сделайте это. Без него скачивание в другом расширении невозможно. Предполагается, что ffmpeg лежит в той же папке, что и скрипт.

Создайте файл yt_downloader_2.py и добавьте в него следующий код:

import os

from pytubefix import YouTube
from pytubefix.cli import on_progress

url = "https://www.youtube.com/watch?v=xxxxxxxxxxx"


def combine(audio: str, video: str, output: str) -> None:

    if os.path.exists(output):
        os.remove(output)

    code = os.system(
        f'.\ffmpeg.exe -i "{video}" -i "{audio}" -c copy "{output}"')

    if code != 0:
        raise SystemError(code)


def download(url: str):

    yt = YouTube(
        proxies={"http": "http://127.0.0.1:8881",
                 "https": "http://127.0.0.1:8881"},
        url=url,
        on_progress_callback=on_progress,
    )

    video_stream = yt.streams.
        filter(type='video').
        order_by('resolution').
        desc().first()

    audio_stream = yt.streams.
        filter(mime_type='audio/mp4').
        order_by('filesize').
        desc().first()

    print('Information:')
    print("tTitle:", yt.title)
    print("tAuthor:", yt.author)
    print("tDate:", yt.publish_date)
    print("tResolution:", video_stream.resolution)
    print("tViews:", yt.views)
    print("tLength:", round(yt.length/60), "minutes")
    print("tFilename of the video:", video_stream.default_filename)
    print("tFilesize of the video:", round(
        video_stream.filesize / 1000000), "MB")

    print('Download video...')
    video_stream.download()
    print('nDownload audio...')
    audio_stream.download()

    combine(audio_stream.default_filename, video_stream.default_filename,
            f'{yt.title}.mp4')


download(url)

Что изменилось? Мы добавили функцию combine, которая отвечает за объединение видео и аудио. Мы делаем это командой .ffmpeg.exe -i "filename_video" -i "filename_audio" -c copy "output_filename"

В основном коде мы по отдельности вытаскиваем потоки видео и аудио. Здесь видео не содержит аудио, но зато оно в высоком качестве. Чтобы получить его, мы сначала фильтруем потоки по типу (видео) filter(type='video'),  затем сортируем по разрешению, затем сортируем по убыванию разрешения и берём первый поток. Аналогично с аудио. После этого мы выводим подробную информацию о скачиваемом видео и скачиваем аудио и видео по отдельности, после чего объединяем их с помощью ffmpeg. Ничего сложного!

Запускаем наш скрипт для обхода DPI и скрипт для скачивания (замените url на свой). Готово!

Как узнать список доступных разрешений

Чтобы узнать, в каких разрешениях можно скачать данное видео, можно написать такую функцию:

def resolutions(video: YouTube):

    res = []

    streams = self.video.streams.
                    filter(type='video').
                    order_by('resolution').
                    desc()

    for stream in streams:
        if stream.resolution not in res:
            res.append(stream.resolution)

    return res    
  

Поток с выбранным разрешением можно получить следующим образом:

res = '1080p' # например
stream = self.video.streams.
          filter(resolution=res, progressive=False).desc().first()

Если вы не используете ffmpeg, замените progressive=False на True

Скачивание аудио

Из предыдущего примера вы видели, как мы скачивали аудио:

...

audio_stream = yt.streams.
        filter(mime_type='audio/mp4').
        order_by('filesize').
        desc().first()

audio_stream.download()

Только учтите, что формат аудио будет m4a, поэтому, если вам нужен другой, придётся воспользоваться ffmpeg


На этом я хочу остановиться. В следующей части (если такая будет) я расскажу о том, как скачивать целые плейлисты, каналы и даже субтитры с YouTube.

Известные проблемы

Обход DPI не работает

Да, такое, конечно, возможно. Но у меня на провайдере ТТК всё работает отлично. Поэтому я могу лишь посоветовать использовать другие инструменты, например GoodbyeDPI.

Remote end closed connection

А вот это действительно проблема, с которой я столкнулся. Она появилась, когда начали тормозить YouTube и я перешёл на nodpi. То ли ютуб банит подозрительную активность, то ли РКН химичит, а может в nodpi что-то не так. Я так и не понял. Если тут есть знатоки, может подскажут, что не так и как пофиксить)

Предупреждение

Если статья вам интересна, не откладывайте её в долгий ящик, так как через некоторое время после публикации она может попасть под географические ограничения по запросу РКН

Автор: Lord_of_Rings

Источник

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


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