ytdlp.online
홈 Tampermonkey

전체 해결 방법

목차

yt-dlp Douyu 다운로드 오류: PhantomJS not found와 방송방 ID 문제 해결

게시일

오류 수정 플러그인 다운로드 douyu_signing.py

yt-dlp로 Douyu 동영상을 다운로드할 때 PhantomJS not found 오류가 발생하거나, 라이브 방송을 녹화할 때 Unable to extract room id가 표시된다면 이 글의 호환성 플러그인을 불러와 해결할 수 있습니다. 새 방송 페이지의 방송방 ID 추출, Deno를 이용한 서명 실행, 라이브 스트림 URL 결합이라는 세 가지 문제를 수정합니다.

로컬에서 Deno와 플러그인 디렉터리를 설정하고 싶지 않다면, Douyu 링크를 홈페이지 다운로더에 붙여 넣어 온라인 도구를 이용할 수 있습니다.

Douyu의 라이브 방송과 VOD 오류를 조사하면서 세 가지 문제가 차례로 드러났습니다. 새 페이지가 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 VOD를 반복 재생 중입니다(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]이 나타나면 플러그인이 적용된 것입니다. 방송 중이라면 getH5Play API를 요청하고 화질별 형식을 표시합니다. 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. VOD 다운로드와 파일 검증(선택 사항)

같은 플러그인은 Douyu VOD 추출기인 DouyuShowIE에도 적용됩니다. 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 -

세 가지 오류의 기술적 원인

1. Next.js 데이터 블록으로 바뀌어 기존 정규 표현식이 방송방 ID를 읽지 못함

업스트림 추출기는 원래 페이지의 $ROOM.room_id 대입문에서 방송방 ID를 추출했습니다. 관련 오류는 업스트림 이슈 #16230에 보고되어 있습니다. 하지만 새 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) 처리로 돌아갑니다.

2. 업스트림에 하드코딩된 PhantomJSwrapper 호출

실제 room_id를 얻은 뒤에는 Douyu가 내려주는 서명 함수 ub98484234를 호출해 v, did, tt, sign 네 매개변수를 계산해야 합니다. yt-dlp에서 권장하는 Deno가 설치되어 있어도, 업스트림 douyutv.py의 _calc_sign은 여전히 PhantomJSwrapper를 직접 생성합니다. 따라서 라이브 방송과 VOD 모두에서 PhantomJS not found 오류가 발생합니다.

플러그인은 _calc_sign을 재정의합니다. 서명 스크립트와 내장 CryptoJS MD5를 가져오는 기존 로직은 유지합니다. 라이브 방송에는 room_id를, VOD에는 window.$DATA의 ROOM.point_id 필드에서 추출한 동영상 ID를 전달합니다. 인수는 json.dumps로 안전하게 인코딩한 뒤, 표준 입력을 통해 권한을 부여하지 않은 Deno 하위 프로세스에 전달해 실행합니다.

  • --no-prompt, --no-config, --no-remote를 명시적으로 전달하며 파일, 환경 변수, 네트워크 접근 권한은 부여하지 않습니다. Deno 권한 모델은 JavaScript API 접근을 제한하며, 운영체제 수준의 샌드박스를 대체하지는 않습니다.
  • 타임아웃을 15초로 설정합니다. 반환된 쿼리 문자열에 비어 있지 않은 sign 필드가 있는지 확인한 뒤 /lapi/live/getH5Play/{room_id} 또는 VOD API에 제출합니다. 실제 테스트에서는 두 API 모두 error: 0을 반환했습니다.

3. urljoin이 /live/ 디렉터리를 제거해 스트림 수신 실패

방송방 ID와 서명 문제를 해결한 뒤에는 -F로 여러 비트레이트의 형식을 표시할 수 있었습니다. 하지만 첫 FFmpeg 녹화는 여전히 종료 코드 8(ffmpeg exited with code 8)로 실패했습니다.

getH5Play 응답 JSON을 확인하니 스트림 주소가 디렉터리 접두사인 rtmp_url과 파일명인 rtmp_live로 나뉘어 있었습니다. rtmp_url은 /live로 끝나고 마지막 슬래시가 없었습니다. 업스트림이 이를 urllib.parse.urljoin으로 바로 결합하면 live를 마지막 파일명으로 간주해 덮어씁니다. 업스트림 이슈 #11998에도 /live/ 누락으로 CDN이 403을 반환한 사례가 기록되어 있습니다.

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 도움말 및 문서