- PVSM.RU - https://www.pvsm.ru -

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

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

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


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

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

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

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

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

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

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

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

How it works

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

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

Что же делать

Но, на каждый болт найдется своя гайка, и есть способы обойти эту вещь (хотя они не всегда действуют). Многие наверняка слышали про GoodbyeDPI [4] или zapret [5]. Это замечательные инструменты, но раз уж мы говорим про Python, то давайте сделаем свой аналог на Python. Поискав немного, я нашел nodpi [6], который использует только 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 [7] - это мощная библиотека python для скачивания с YouTube видео, аудио, плейлистов и каналов со своим собственным cli. Сама по себе она фактически является форком pytube [8], но последние изменения в pytube были сделаны больше года назад, и в августе этого года, после изменений в api YouTube она перестала работать. Также в pytubefix исправлены многие недочеты предшественницы и добавлены новые функции. pytubefix, как и pytube, работает с api youtube, а кое-где и парсит его html/json.

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

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

Давайте попробуем скачать какое-нибудь видео. Создайте файл 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

Источник [10]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/python/406262

Ссылки в тексте:

[1] Google Global Cache: https://ru.wikipedia.org/wiki/Google_Global_Cache

[2] DPI: https://ru.wikipedia.org/wiki/Deep_packet_inspection

[3] здесь: https://habr.com/ru/articles/111054/

[4] GoodbyeDPI: https://github.com/ValdikSS/GoodbyeDPI

[5] zapret: https://github.com/bol-van/zapret

[6] nodpi: https://github.com/theo0x0/nodpi

[7] pytubefix: https://github.com/JuanBindez/pytubefix

[8] pytube: https://github.com/pytube/pytube/

[9] Google Диска: https://drive.google.com/file/d/12flZfkV3dKQWPy8mPYfsABttgR-3w1yZ/view?usp=drive_link

[10] Источник: https://habr.com/ru/articles/870110/?utm_source=habrahabr&utm_medium=rss&utm_campaign=870110