ytdlp.online
Главная Tampermonkey

Все решения

На этой странице

yt-dlp и Douyu: исправление PhantomJS not found и ошибок ID комнаты

Дата публикации

Скачать плагин с исправлением douyu_signing.py

Если yt-dlp сообщает PhantomJS not found при скачивании видео Douyu или Unable to extract room id при записи прямого эфира, загрузите плагин совместимости из этой статьи. Он решает три проблемы: извлечение ID комнаты из новых страниц, вычисление подписи с помощью Deno и сборку адреса потока прямой трансляции.

Если вы не хотите настраивать Deno и каталог плагинов локально, вставьте ссылку Douyu в загрузчик на нашей главной странице и воспользуйтесь онлайн-инструментом.

При поиске причин сбоев прямых эфиров и видео по запросу Douyu мы последовательно столкнулись с тремя препятствиями. После перехода страниц на блоки данных Next.js прежнее регулярное выражение перестало находить ID комнаты. Когда это было исправлено, исходный экстрактор всё ещё напрямую вызывал устаревший PhantomJS для вычисления подписи. После успешного получения подписи и списка разрешений выяснилось, что в собранном адресе FLV-потока отсутствует каталог /live/. Ниже сначала приведены действия для исправления, затем разбор каждой причины.

Симптомы и этапы сбоя

Сообщение или симптом Причина
Unable to extract room id Новая страница трансляции использует поток данных Next.js, поэтому прежний поиск $ROOM.room_id не находит совпадений
PhantomJS not found В исходном douyutv.py жёстко задан вызов PhantomJSwrapper; установка Deno сама по себе его не заменяет
Форматы выводятся (-F работает), но скачивание завершается с ошибкой В возвращаемом API поле rtmp_url нет завершающего слеша; при сборке адреса urljoin удаляет каталог /live/
The channel is not currently live Комната не в эфире; это штатная остановка по состоянию комнаты
The channel is auto-playing VODs Комната циклически воспроизводит видео по запросу (videoLoop: 1); плагин прекращает извлечение данных

Быстрое исправление

1. Подготовьте окружение и файл плагина

Убедитесь, что в терминале доступны yt-dlp, deno и ffmpeg. Для тестов в этой статье использовались yt-dlp nightly 2026.10.29.232701, Deno 2.9.8 и FFmpeg 7.1.5. Плагин зависит от встроенного в yt-dlp метода _search_nextjs_v13_data. Если ваша версия слишком старая, сначала обновите yt-dlp.

Создайте в рабочем каталоге следующую структуру папок и сохраните полный код плагина из конца статьи в файл douyu_signing.py:

plugins/
└── douyu/
    └── yt_dlp_plugins/
        └── extractor/
            └── douyu_signing.py

Все последующие команды выполняйте из каталога, содержащего папку plugins. Параметр --plugin-dirs ./plugins загружает плагин без изменения исходного кода в каталоге установки yt-dlp.

2. Проверьте список форматов трансляции

В примере используется публичная комната. Замените URL адресом нужной комнаты:

yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -v -F "https://www.douyu.com/pigff"

Метка [DouyuTV+DenoSigning] в журнале означает, что плагин работает. Если комната в эфире, yt-dlp запросит API getH5Play и выведет форматы с разными разрешениями. Сообщение The channel is not currently live означает, что трансляция сейчас не идёт. Для проверки выберите другую комнату с активным эфиром.

3. Запишите 30 секунд для проверки медиапотока

Вывод списка форматов подтверждает только то, что API вернул error: 0 для подписанного запроса. Чтобы убедиться в правильной сборке адреса CDN, нужно действительно получить поток:

yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir --downloader ffmpeg --downloader-args "ffmpeg_i:-rw_timeout 15000000" --downloader-args "ffmpeg_o:-t 30" -f worst -o "douyu-live.%(ext)s" "https://www.douyu.com/pigff"

Параметр -f worst выбирает минимальный битрейт, чтобы уменьшить размер тестового файла. -t 30 ограничивает запись 30 секундами, а тайм-аут чтения и записи по сети составляет 15 секунд.

4. Проверьте видео по запросу и файлы (необязательно)

Тот же плагин поддерживает DouyuShowIE, экстрактор Douyu для видео по запросу (VOD). При проверке VOD в журнале появляется [DouyuShow+DenoSigning]:

# List VOD formats
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -F "https://v.douyu.com/show/mPyq7oVNe5Yv1gLY"

# Download the VOD sample
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -f worst -o "douyu-video.%(ext)s" "https://v.douyu.com/show/mPyq7oVNe5Yv1gLY"

Чтобы проверить записанный .flv или .mp4 на повреждённые кадры и убедиться в целостности аудио- и видеодорожек, выполните ffprobe и полное декодирование:

ffprobe -v error -show_entries stream=codec_name,codec_type,width,height:format=duration,size -of json "douyu-live.flv"
ffmpeg -nostdin -v error -xerror -i "douyu-live.flv" -map 0:v:0 -map "0:a:0?" -f null -

Технический разбор трёх ошибок

Первая ошибка: блоки Next.js заменили прежний способ хранения ID комнаты

Исходный экстрактор получал ID комнаты из присваивания $ROOM.room_id в коде страницы. Соответствующая ошибка описана в issue #16230 проекта yt-dlp. На новых страницах Douyu, например для псевдонима pigff или числового адреса комнаты 593229, метаданные комнаты уже перенесены в блоки данных скриптов Next.js App Router (__next_f.push).

Слишком широкое регулярное выражение для поиска room_id во всей странице может захватить ID рекомендуемой комнаты в боковой панели или внизу страницы. Поэтому плагин разбирает поток методом _search_nextjs_v13_data и читает только словарь roomInfo.room, относящийся к текущей комнате:

  • Псевдоним pigff корректно преобразуется в настоящий ID комнаты 24422.
  • Состояние комнаты проверяется до запроса подписи. При show_status == 2 сразу выдаётся сообщение об отсутствии эфира, как для комнаты 593229, которая во время теста была офлайн. При room_biz_all.videoLoop == 1 сообщается о циклическом воспроизведении видео. Это позволяет избежать ненужного вычисления подписи.
  • Если на странице нет блоков Next.js и используется старая структура, плагин автоматически передаёт обработку исходному методу super()._real_extract(url).

Вторая ошибка: жёстко заданный вызов PhantomJSwrapper

Получив настоящий room_id, экстрактор должен выполнить предоставленную Douyu функцию подписи ub98484234, чтобы вычислить четыре параметра: v, did, tt и sign. Даже если рекомендованный yt-dlp Deno уже установлен, метод _calc_sign в исходном douyutv.py по-прежнему напрямую создаёт экземпляр PhantomJSwrapper. Это приводит к PhantomJS not found как для прямых эфиров, так и для VOD.

Плагин переопределяет _calc_sign, сохраняя исходную логику получения скрипта подписи и встроенной зависимости CryptoJS MD5. Для прямых эфиров передаётся room_id, для VOD используется ID видео, извлечённый из поля ROOM.point_id объекта window.$DATA. Аргументы безопасно кодируются через json.dumps и передаются через стандартный ввод дочернему процессу Deno без предоставленных разрешений:

  • Явно указываются --no-prompt, --no-config и --no-remote. Права доступа к файлам, переменным окружения и сети не выдаются. Модель разрешений Deno ограничивает доступ через JavaScript API, но не заменяет изоляцию на уровне операционной системы.
  • Установлен тайм-аут 15 секунд. Перед отправкой параметров в /lapi/live/getH5Play/{room_id} или VOD API плагин проверяет, что возвращённая строка запроса содержит непустое поле sign. Во время тестов оба API возвращали error: 0.

Третья ошибка: urljoin удаляет каталог /live/

После исправления ID комнаты и подписи команда -F уже выводила форматы с разными битрейтами. Однако первая попытка записи через FFmpeg всё ещё завершалась кодом 8 (ffmpeg exited with code 8).

Анализ JSON-ответа getH5Play показал, что адрес потока разделён на две части: префикс каталога rtmp_url и имя файла rtmp_live. При этом rtmp_url заканчивался на /live без завершающего слеша. При прямом объединении через urllib.parse.urljoin компонент live считался последним именем файла и заменялся. В issue #11998 также описаны ответы CDN со статусом 403 из-за отсутствия /live/:

urljoin('https://cdn.example/live', '24422.flv?sign=example')
# Incorrect result: https://cdn.example/24422.flv?sign=example

urljoin('https://cdn.example/live/', '24422.flv?sign=example')
# Correct result: https://cdn.example/live/24422.flv?sign=example

В _extract_stream_formats плагин сначала добавляет завершающий слеш к rtmp_url с помощью data['rtmp_url'].rstrip('/') + '/', затем передаёт данные исходному обработчику форматов. Имя файла потока и параметры подписи в строке запроса остаются неизменными. После исправления удалось полностью получить данные как при записи прямого эфира, так и при скачивании фрагментов VOD. В обоих случаях проверка декодированием прошла успешно.


Полный код плагина

Сохраните следующий код в plugins/douyu/yt_dlp_plugins/extractor/douyu_signing.py:

"""Run Douyu signing scripts in Deno without file, environment, or network access."""
import json
import shutil
import subprocess
import time
import urllib.parse
import uuid

from yt_dlp.extractor.douyutv import DouyuShowIE as UpstreamDouyuShowIE, DouyuTVIE as UpstreamDouyuTVIE
from yt_dlp.utils import (
    ExtractorError,
    UserNotLive,
    int_or_none,
    str_or_none,
    traverse_obj,
    unescapeHTML,
    url_or_none,
    urlencode_postdata,
)


class _DenoSigning:
    def _calc_sign(self, sign_func, video_id, room_id):
        deno = shutil.which('deno')
        if not deno:
            raise ExtractorError('Deno is required for Douyu signing', expected=True)
        script = ('globalThis.window=globalThis;' + self._get_cryptojs_md5(video_id) + ';' + sign_func
                  + ';console.log(ub98484234(' + ','.join(json.dumps(value) for value in
                    (str(room_id), uuid.uuid4().hex, str(round(time.time())))) + '))')
        try:
            result = subprocess.run([deno, 'run', '--no-prompt', '--no-config', '--no-remote', '-'],
                                    input=script, text=True, capture_output=True, timeout=15)
        except (OSError, subprocess.TimeoutExpired) as exc:
            raise ExtractorError('Douyu signing runtime failed', cause=exc) from exc
        lines = result.stdout.strip().splitlines()
        fields = urllib.parse.parse_qs(lines[-1]) if lines and result.returncode == 0 else {}
        if not fields.get('sign'):
            raise ExtractorError('Unable to sign Douyu request with Deno')
        return {key: values[0] for key, values in fields.items()}


class DouyuShowIE(UpstreamDouyuShowIE, plugin_name='DenoSigning'):
    _calc_sign = _DenoSigning._calc_sign


class DouyuTVIE(UpstreamDouyuTVIE, plugin_name='DenoSigning'):
    _calc_sign = _DenoSigning._calc_sign

    def _extract_stream_formats(self, stream_formats):
        normalized = []
        for response in stream_formats:
            data = response.get('data')
            if isinstance(data, dict) and isinstance(data.get('rtmp_url'), str):
                # Treat /live as a directory so urljoin preserves it.
                response = {**response, 'data': {
                    **data, 'rtmp_url': data['rtmp_url'].rstrip('/') + '/',
                }}
            normalized.append(response)
        return super()._extract_stream_formats(normalized)

    def _real_extract(self, url):
        video_id = self._match_id(url)
        webpage = self._download_webpage(url, video_id)
        room = traverse_obj(
            self._search_nextjs_v13_data(webpage, video_id, fatal=False),
            (..., 'roomInfo', 'room', {dict}), get_all=False)
        if not room:
            # Let upstream handle older pages without guessing IDs from recommendations or URLs.
            return super()._real_extract(url)

        room_id = str_or_none(room.get('room_id'))
        if not room_id or not room_id.isascii() or not room_id.isdecimal() or int(room_id) <= 0:
            raise ExtractorError('Unable to extract room id')
        if traverse_obj(room, ('room_biz_all', 'videoLoop', {int_or_none})) == 1:
            raise UserNotLive('The channel is auto-playing VODs', video_id=video_id)
        if int_or_none(room.get('show_status')) == 2:
            raise UserNotLive(video_id=video_id)

        # New pages omit the signing function; reuse upstream script fetching and format parsing.
        sign_func = self._get_sign_func(room_id, video_id)
        form_data = {'rate': 0, **self._calc_sign(sign_func, video_id, room_id)}
        stream_formats = [self._download_json(
            f'https://www.douyu.com/lapi/live/getH5Play/{room_id}', video_id,
            note='Downloading livestream format', data=urlencode_postdata(form_data))]
        for rate_id in traverse_obj(stream_formats[0], ('data', 'multirates', ..., 'rate')):
            if rate_id != traverse_obj(stream_formats[0], ('data', 'rate')):
                form_data['rate'] = rate_id
                stream_formats.append(self._download_json(
                    f'https://www.douyu.com/lapi/live/getH5Play/{room_id}', video_id,
                    note=f'Downloading livestream format {rate_id}', data=urlencode_postdata(form_data)))

        return {
            'id': room_id,
            'display_id': video_id,
            'formats': self._extract_stream_formats(stream_formats),
            'is_live': True,
            **traverse_obj(room, {
                'title': ('room_name', {str}, {unescapeHTML}),
                'description': ('show_details', {str}),
                'uploader': ('nickname', {str}),
                'thumbnail': ('coverSrc', {url_or_none}),
            }),
        }
Инструменты yt-dlp
Посмотреть все инструменты yt-dlp
Справка и документация yt-dlp