yt-dlp Douyu: mengatasi PhantomJS not found dan error ID ruang
Tanggal terbit
Unduh plugin perbaikan douyu_signing.py
Jika yt-dlp menampilkan PhantomJS not found saat mengunduh video Douyu atau Unable to extract room id saat merekam siaran langsung, Anda dapat memuat plugin kompatibilitas dalam artikel ini. Plugin memperbaiki tiga masalah: ekstraksi ID ruang dari halaman siaran baru, eksekusi penandatanganan dengan Deno, dan penggabungan URL stream siaran langsung.
Jika tidak ingin mengatur Deno dan direktori plugin di komputer, tempel tautan Douyu ke pengunduh di halaman utama untuk memakai alat online.
Saat menelusuri kegagalan siaran langsung dan video on-demand Douyu, kami menemukan tiga hambatan berurutan. Halaman baru beralih ke blok data Next.js sehingga ekspresi reguler untuk ID ruang tidak lagi cocok. Setelah ID ruang diperbaiki, kode upstream masih menggunakan panggilan hardcoded ke PhantomJS yang sudah ditinggalkan untuk membuat tanda tangan. Setelah penandatanganan berhasil dan daftar kualitas muncul, URL stream FLV yang dihasilkan ternyata kehilangan direktori /live/. Berikut langkah penanganannya, disusul penjelasan setiap penyebab.
Gejala dan lokasi masalah
| Error atau gejala | Tahap yang bermasalah |
|---|---|
Unable to extract room id |
Halaman siaran baru memakai aliran data Next.js sehingga pola lama $ROOM.room_id tidak cocok |
PhantomJS not found |
douyutv.py upstream memanggil PhantomJSwrapper secara hardcoded; menginstal Deno saja tidak otomatis mengganti panggilan ini |
Daftar format muncul (-F normal), tetapi unduhan berakhir dengan error |
rtmp_url dari API tidak memiliki garis miring penutup sehingga urljoin menghilangkan direktori /live/ |
The channel is not currently live |
Ruang sedang tidak siaran; penghentian ini normal sesuai status ruang |
The channel is auto-playing VODs |
Ruang sedang memutar VOD secara berulang (videoLoop: 1); plugin menghentikan ekstraksi |
Langkah perbaikan cepat
1. Siapkan lingkungan dan berkas plugin
Pastikan yt-dlp, deno, dan ffmpeg tersedia di terminal. Pengujian artikel ini memakai yt-dlp nightly 2026.10.29.232701, Deno 2.9.8, dan FFmpeg 7.1.5. Plugin bergantung pada metode bawaan yt-dlp _search_nextjs_v13_data. Jika versi lokal terlalu lama, perbarui yt-dlp terlebih dahulu.
Buat struktur folder berikut di direktori kerja, lalu simpan kode plugin lengkap di akhir artikel sebagai douyu_signing.py:
plugins/
└── douyu/
└── yt_dlp_plugins/
└── extractor/
└── douyu_signing.py
Jalankan semua perintah berikutnya dari direktori yang berisi folder plugins. Opsi --plugin-dirs ./plugins memuat plugin tanpa mengubah kode sumber di direktori instalasi yt-dlp.
2. Periksa daftar format siaran langsung
Contoh berikut memakai ruang siaran publik. Ganti URL dengan ruang yang ingin diperiksa:
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -v -F "https://www.douyu.com/pigff"
Jika log menampilkan [DouyuTV+DenoSigning], plugin sudah aktif. Jika ruang sedang siaran, terminal akan meminta API getH5Play dan menampilkan format untuk setiap kualitas. Jika muncul The channel is not currently live, ruang sedang tidak siaran; gunakan ruang lain yang sedang aktif untuk pengujian.
3. Rekam selama 30 detik untuk memverifikasi stream media
Daftar format hanya menunjukkan bahwa permintaan API bertanda tangan mengembalikan error: 0. Stream perlu benar-benar diterima untuk memastikan URL CDN telah digabungkan dengan benar:
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"
Perintah di atas menggunakan -f worst untuk memilih bitrate terendah agar berkas uji lebih kecil. Opsi -t 30 membatasi rekaman hingga 30 detik, sedangkan batas waktu baca/tulis jaringan ditetapkan menjadi 15 detik.
4. Periksa VOD dan berkas hasil rekaman (opsional)
Plugin yang sama juga menangani ekstraktor video on-demand Douyu, DouyuShowIE. Saat menguji VOD, log akan menampilkan [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"
Untuk memeriksa apakah berkas .flv atau .mp4 yang direkam memiliki frame rusak atau track video dan audio yang tidak lengkap, jalankan ffprobe dan uji dekode seluruh berkas:
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 -
Penjelasan teknis ketiga masalah
1. Peralihan ke blok data Next.js membuat pola lama gagal membaca ID ruang
Ekstraktor upstream awalnya membaca ID ruang dari pernyataan penetapan $ROOM.room_id pada halaman. Error terkait dilaporkan di issue #16230. Namun, pada halaman siaran Douyu baru, misalnya alias khusus pigff atau nomor ruang biasa 593229, metadata ruang telah dipindahkan ke blok data skrip Next.js App Router (__next_f.push).
Mencari room_id di seluruh halaman dengan ekspresi reguler yang terlalu luas mudah menghasilkan ID ruang rekomendasi di bilah samping atau bagian bawah halaman. Karena itu, plugin memakai _search_nextjs_v13_data untuk mengurai aliran data dan hanya membaca dictionary roomInfo.room yang mewakili ruang saat ini:
- Mengurai alias
pigffmenjadi ID ruang sebenarnya,24422. - Memeriksa status ruang sebelum meminta tanda tangan. Jika
show_status == 2, langsung menampilkan pesan bahwa ruang tidak sedang siaran, seperti ruang593229saat pengujian. Jikaroom_biz_all.videoLoop == 1, plugin memberi tahu bahwa ruang sedang memutar video berulang agar tidak menghitung tanda tangan yang tidak diperlukan. - Jika halaman tidak memiliki blok data Next.js dan masih memakai struktur lama, plugin otomatis menyerahkan kembali proses ke
super()._real_extract(url)upstream.
2. Panggilan PhantomJSwrapper di-hardcode dalam upstream
Setelah mendapatkan room_id sebenarnya, ekstraktor harus memanggil fungsi penandatanganan ub98484234 dari Douyu untuk menghitung empat parameter: v, did, tt, dan sign. Meskipun Deno yang direkomendasikan yt-dlp sudah terinstal, _calc_sign dalam douyutv.py upstream masih membuat instans PhantomJSwrapper secara langsung. Akibatnya muncul PhantomJS not found pada siaran langsung maupun VOD.
Plugin mengganti _calc_sign, sambil mempertahankan logika upstream untuk mengambil skrip penandatanganan dan CryptoJS MD5 bawaan. Siaran langsung menggunakan room_id, sedangkan VOD menggunakan ID video yang diambil dari kolom ROOM.point_id dalam window.$DATA. Argumen dienkode dengan aman menggunakan json.dumps, lalu dikirim melalui input standar untuk dijalankan oleh subproses Deno tanpa izin akses:
- Opsi
--no-prompt,--no-config, dan--no-remotediberikan secara eksplisit. Tidak ada izin akses berkas, variabel lingkungan, atau jaringan. Model izin Deno membatasi akses API JavaScript, tetapi tidak menggantikan sandbox tingkat sistem operasi. - Batas waktu ditetapkan menjadi 15 detik. Plugin memeriksa bahwa string kueri yang dikembalikan memiliki kolom
signyang tidak kosong sebelum mengirimnya ke/lapi/live/getH5Play/{room_id}atau API VOD. Dalam pengujian nyata, kedua API mengembalikanerror: 0.
3. urljoin menghilangkan direktori /live/ sehingga stream gagal diterima
Setelah ID ruang dan tanda tangan diperbaiki, -F sudah dapat menampilkan beberapa format bitrate. Namun, rekaman pertama menggunakan FFmpeg tetap gagal dengan kode keluar 8 (ffmpeg exited with code 8).
Pemeriksaan JSON dari getH5Play menunjukkan bahwa API membagi alamat stream menjadi awalan direktori rtmp_url dan nama berkas rtmp_live. rtmp_url berakhir dengan /live tanpa garis miring penutup. Ketika upstream langsung menggabungkannya menggunakan urllib.parse.urljoin, live dianggap sebagai nama berkas terakhir dan digantikan. Issue #11998 upstream juga mencatat CDN mengembalikan 403 akibat hilangnya /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
Dalam _extract_stream_formats, plugin memastikan rtmp_url memiliki satu garis miring penutup melalui data['rtmp_url'].rstrip('/') + '/' sebelum meneruskannya ke pengurai upstream. Nama berkas stream dan parameter tanda tangan dalam kueri tetap sama. Setelah perbaikan, rekaman siaran langsung dan unduhan segmen VOD sama-sama selesai serta lolos pemeriksaan dekode.
Kode plugin lengkap
Simpan kode berikut sebagai 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}),
}),
}