แก้ปัญหา 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 เราพบอุปสรรคสามจุดตามลำดับ หน้าเว็บใหม่เปลี่ยนไปใช้บล็อกข้อมูล Next.js ทำให้นิพจน์ปรกติสำหรับ ID ห้องใช้ไม่ได้ หลังแก้ ID ห้อง โค้ดต้นทางยังเรียก PhantomJS ที่เลิกแนะนำให้ใช้แล้วแบบตายตัวเพื่อสร้างลายเซ็น เมื่อสร้างลายเซ็นสำเร็จและแสดงรายการความละเอียดได้ URL ไลฟ์สตรีม FLV ที่ประกอบขึ้นกลับไม่มีไดเรกทอรี /live/ ต่อไปนี้เป็นขั้นตอนแก้ไข ตามด้วยสาเหตุของแต่ละปัญหา
อาการและตำแหน่งที่เกิดปัญหา
| ข้อผิดพลาดหรืออาการ | ขั้นตอนที่มีปัญหา |
|---|---|
Unable to extract room id |
หน้าไลฟ์ใหม่ใช้สตรีมข้อมูล Next.js ทำให้รูปแบบเดิม $ROOM.room_id จับคู่ไม่ได้ |
PhantomJS not found |
douyutv.py ของต้นทางเรียก PhantomJSwrapper แบบตายตัว การติดตั้ง Deno ในระบบอย่างเดียวจะไม่เปลี่ยนการเรียกนี้ |
แสดงรายการรูปแบบได้ (-F ทำงาน) แต่ดาวน์โหลดแล้วจบด้วยข้อผิดพลาด |
rtmp_url ที่ API ส่งกลับไม่มีเครื่องหมายทับท้าย ทำให้ urljoin ตัดไดเรกทอรี /live/ ออกระหว่างประกอบ URL |
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 ปลั๊กอินใช้เมธอด _search_nextjs_v13_data ที่มีอยู่ใน yt-dlp หากเวอร์ชันบนเครื่องเก่าเกินไป ให้อัปเดต 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] แสดงว่าปลั๊กอินทำงานแล้ว หากห้องกำลังไลฟ์ ระบบจะเรียก API getH5Play และแสดงรูปแบบแต่ละระดับความละเอียด หากพบ The channel is not currently live แสดงว่าห้องไม่ได้กำลังไลฟ์ ให้เปลี่ยนไปทดสอบห้องที่กำลังถ่ายทอดสด
3. ทดลองบันทึก 30 วินาทีเพื่อตรวจสอบสตรีมสื่อ
การแสดงรายการรูปแบบบอกได้เพียงว่าคำขอ API ที่มีลายเซ็นส่งกลับ error: 0 ต้องรับสตรีมจริงด้วยจึงจะยืนยันได้ว่า URL ของ 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 คือ 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 -
วิเคราะห์สาเหตุทางเทคนิคของปัญหาทั้งสามจุด
จุดที่หนึ่ง: หน้าเปลี่ยนไปใช้ข้อมูล Next.js ทำให้นิพจน์ปรกติเดิมอ่าน ID ห้องไม่ได้
ตัวดึงข้อมูลของต้นทางเคยอ่าน ID ห้องจากคำสั่งกำหนดค่า $ROOM.room_id ภายในหน้า โดยมีรายงานข้อผิดพลาดที่เกี่ยวข้องใน issue #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)ของต้นทางโดยอัตโนมัติ
จุดที่สอง: ต้นทางเรียก PhantomJSwrapper แบบตายตัว
หลังได้ room_id จริงแล้ว ตัวดึงข้อมูลต้องเรียกฟังก์ชันสร้างลายเซ็น ub98484234 ที่ Douyu ส่งมาเพื่อคำนวณพารามิเตอร์ทั้งสี่ คือ v, did, tt และ sign แม้ติดตั้ง Deno ตามที่ yt-dlp แนะนำแล้ว แต่ _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 ใช้จำกัดการเข้าถึง API ของ JavaScript ไม่ได้ทดแทนแซนด์บ็อกซ์ระดับระบบปฏิบัติการ - กำหนดเวลารอสูงสุดไว้ที่ 15 วินาที ตรวจสอบว่าสตริงคิวรีที่ส่งกลับมีฟิลด์
signที่ไม่ว่าง แล้วจึงส่งไปยัง/lapi/live/getH5Play/{room_id}หรือ API ของ VOD ในการทดสอบจริง API ทั้งสองส่งกลับerror: 0
จุดที่สาม: urljoin ตัดไดเรกทอรี /live/ ทำให้รับสตรีมไม่ได้
หลังแก้ ID ห้องและลายเซ็นแล้ว -F แสดงรูปแบบได้หลายบิตเรต แต่การบันทึกด้วย FFmpeg ครั้งแรกยังล้มเหลวด้วยรหัสออก 8 (ffmpeg exited with code 8)
เมื่อตรวจ JSON ที่ getH5Play ส่งกลับ พบว่า API แยกที่อยู่สตรีมเป็นส่วนไดเรกทอรี 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}),
}),
}