feat(douyu): 构建斗鱼直播 tct & hs 流链接 (#1256)

* feat(douyu): 构建斗鱼直播流链接

* 构建斗鱼直播流链接

* fix func err

* cleanup

* 默认 cdn 更换为 hw-h5

* Update __init__.py

---------

Co-authored-by: ForgQi <34411314+ForgQi@users.noreply.github.com>
This commit is contained in:
XuanRan_NyanandForgQi authored and GitHub committed 2025-04-24 08:04:46 +00:00
1 parent 32162101f1
commit 0b00a33f92
3 files changed
+173 -19

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ import logging
import platform
import sys
__version__ = "0.4.93"
__version__ = "0.4.94"
LOG_CONF = {
'version': 1,
+4 -1
View File
@@ -210,7 +210,10 @@ class DownloadBase(ABC):
# 文件名不含后戳
fmt_file_name = self.gen_download_filename(is_fmt=True)
# ffmpeg 输入参数
input_args = []
input_args = [
# '-http_proxy', 'http://127.0.0.1:10808',
# "-fflags", "+genpts",
]
# ffmpeg 输出参数
output_args = [
'-c',
+168 -17
View File
@@ -1,6 +1,7 @@
import hashlib
import time
from urllib.parse import parse_qs
import random
from urllib.parse import parse_qs, urlencode, quote
from functools import lru_cache
from biliup.common.util import client
@@ -8,7 +9,7 @@ from biliup.config import config
from biliup.Danmaku import DanmakuClient
from ..engine.decorators import Plugin
from ..engine.download import DownloadBase
from ..plugins import logger, match1, random_user_agent
from ..plugins import logger, match1, random_user_agent, json_loads
@Plugin.download(regexp=r'https?://(?:(?:www|m)\.)?douyu\.com')
@@ -18,7 +19,7 @@ class Douyu(DownloadBase):
self.__room_id = match1(url, r'rid=(\d+)')
self.douyu_danmaku = config.get('douyu_danmaku', False)
self.douyu_disable_interactive_game = config.get('douyu_disable_interactive_game', False)
self.douyu_cdn = config.get('douyu_cdn', 'tct-h5')
self.douyu_cdn = config.get('douyu_cdn', 'hw-h5')
self.douyu_rate = config.get('douyu_rate', 0)
async def acheck_stream(self, is_check=False):
@@ -26,6 +27,9 @@ class Douyu(DownloadBase):
logger.error(f"{self.plugin_msg}: 直播间地址错误")
return False
self.fake_headers['referer'] = f"https://www.douyu.com"
self.fake_headers['origin'] = f"https://www.douyu.com"
try:
if not self.__room_id:
self.__room_id = _get_real_rid(self.url)
@@ -34,9 +38,9 @@ class Douyu(DownloadBase):
return False
try:
room_info = (
await client.get(f"https://www.douyu.com/betard/{self.__room_id}", headers=self.fake_headers)
).json()['room']
room_info = await client.get(f"https://www.douyu.com/betard/{self.__room_id}", headers=self.fake_headers)
room_info.raise_for_status()
room_info = json_loads(room_info.text)['room']
except:
logger.exception(f"{self.plugin_msg}: 获取直播间信息错误")
return False
@@ -93,16 +97,43 @@ class Douyu(DownloadBase):
logger.exception(f"{self.plugin_msg}: 获取签名参数异常")
return False
params['cdn'] = self.douyu_cdn
params['rate'] = int(self.douyu_rate)
params.update({
'cdn': [self.douyu_cdn],
'rate': [str(self.douyu_rate)],
'iar': ['1'],
'ive': ['0'],
'rid': self.__room_id,
'hevc': ['0'],
'fa': ['0'],
'sov': ['0'],
'ver': ['219032101'],
})
self._req_data = parse_qs(urlencode(params, doseq=True, encoding='utf-8'))
try:
live_data = await self.get_play_info(self.__room_id, params)
self.raw_stream_url = f"{live_data['rtmp_url']}/{live_data['rtmp_live']}"
live_data = await self.get_play_info(self.__room_id, self._req_data)
url = f"{live_data['rtmp_url']}/{live_data['rtmp_live']}"
except:
logger.exception(f"{self.plugin_msg}: ")
return False
host = None
is_tct = (live_data['rtmp_url'].find('tc-tct.douyucdn2.cn') != -1)
# HACK: 构建斗鱼直播流链接
try:
if self.douyu_cdn == 'hs-h5':
host, _, url = await self.build_hs_url(url)
elif self.douyu_cdn == 'tct-h5' and not is_tct:
_, _, url = await self.build_tx_url(url)
except (RuntimeError, ValueError) as e:
logger.error(f"{self.plugin_msg}: {e}")
if url:
self.raw_stream_url = url
if host:
self.fake_headers['host'] = host
return True
def danmaku_init(self):
@@ -112,19 +143,139 @@ class Douyu(DownloadBase):
}
self.danmaku = DanmakuClient(self.url, self.gen_download_filename(), content)
async def get_play_info(self, room_id, params):
live_data = await client.post(
f'https://www.douyu.com/lapi/live/getH5Play/{room_id}', headers=self.fake_headers, params=params)
async def get_play_info(self, room_id, data, mobile=False, preview=False):
req_headers = self.fake_headers.copy()
if mobile:
url = 'https://m.douyu.com/api/room/ratestream'
elif preview:
c_time_str = str(time.time_ns())
url = f'https://playweb.douyucdn.cn/lapi/live/hlsH5Preview/{room_id}?{c_time_str[:18]}'
data = {
'rid': self.__room_id,
'did': data.get('did', ["10000000000000000000000000001501"])[0],
}
req_headers.update({
'Rid': self.__room_id,
'Time': c_time_str[:13],
'Auth': hashlib.md5(f"{self.__room_id}{c_time_str[:13]}".encode('utf-8')).hexdigest(),
})
else:
url = f'https://www.douyu.com/lapi/live/getH5Play/{room_id}'
live_data = await client.post(url, headers=req_headers, data=data)
if not live_data.is_success:
raise RuntimeError(live_data.text)
live_data = live_data.json().get('data')
live_data = json_loads(live_data.text).get('data')
if mobile or preview:
return live_data
if isinstance(live_data, dict):
if not live_data['rtmp_cdn'].endswith('h5') or 'akm' in live_data['rtmp_cdn']:
params['cdn'] = live_data['cdnsWithName'][-1]['cdn']
return await self.get_play_info(room_id, params)
if live_data.get('rtmp_cdn', '').startswith('scdn'):
import copy
__data = copy.deepcopy(data)
__data['cdn'] = live_data['cdnsWithName'][-1]['cdn']
return await self.get_play_info(room_id, __data)
return live_data
raise RuntimeError(live_data)
def get_p2p_play_info(self, xp2p_unity_api_domain):
url = f"https://{xp2p_unity_api_domain}"
pass
def parse_stream_info(self, url) -> tuple[str, str, str]:
'''
解析推流信息
'''
def get_tx_app_name(rtmp_url) -> str:
'''
获取腾讯云推流应用名
'''
host = rtmp_url.split('//')[1].split('/')[0]
app_name = rtmp_url.split('/')[-1]
# group 按顺序排序
i = match1(host, r'.+(sa|3a|1a|3|1)')
if i:
if i == "sa":
i = "1"
return f"dyliveflv{i}"
return app_name
list = url.split('?')
query = parse_qs(list[1])
origin = query.get('origin', ['0'])[0]
if origin not in ['tct', 'hw', 'dy']:
'''
dy: 斗鱼自建
tct: 腾讯云
hw: 华为云
'''
raise ValueError(f"当前流来源 {origin} 不支持切换为腾讯云推流")
elif origin == 'dy':
logger.warning(f"{self.plugin_msg}: 当前流来源 {origin} 可能不存在腾讯云流")
query_str = urlencode(query, doseq=True, encoding='utf-8')
stream_id = list[0].split('/')[-1].split('.')[0]
rtmp_url = list[0].split(stream_id)[0]
return get_tx_app_name(rtmp_url[:-1]), stream_id.split('_')[0], query_str
async def build_tx_url(self, url, callback=None) -> tuple[str, str, str]:
'''
构建腾讯CDN URL
return: tx_host, stream_id, tx_url
'''
tx_app_name, stream_id, query = self.parse_stream_info(url)
tx_host = "tc-tct.douyucdn2.cn"
tx_url = f"https://{tx_host}/{tx_app_name}/{stream_id}.flv?%s"
query = parse_qs(query)
m_data = await self.get_play_info(self.__room_id, self._req_data, mobile=True)
_, _, m_query = self.parse_stream_info(m_data['url'])
m_query = parse_qs(m_query)
m_query.pop('vhost', None)
query.update({
'fcdn': ['tct'],
**m_query,
})
query = urlencode(query, doseq=True, encoding='utf-8')
return tx_host, stream_id, tx_url % query
async def build_hs_url(self, url, *, tx_host=None, stream_id=None) -> tuple[str, str, str]:
'''
构建火山CDN URL
return: hs_host, stream_id, hs_ip_url
'''
if not tx_host:
tx_host, stream_id, tx_url = await self.build_tx_url(url)
else:
tx_url = url
query = parse_qs(tx_url.split('?')[-1])
encoded_url = quote(tx_url, safe='')
query.update(
{
'fp_user_url': [encoded_url],
'vhost': [tx_host],
'domain': [tx_host],
}
)
query = urlencode(query, doseq=True, encoding='utf-8')
hs_host = "douyu-pull.s.volcfcdndvs.com"
# hs_host = "huos3.douyucdn2.cn"
hs_ip_list = [
"98.98.121.44", # singapore
"128.14.110.71", # singapore
"128.14.110.70", # singapore
"104.166.175.26", # russia
"104.166.175.2", # russia
"45.43.34.227", # hongkong
"192.169.99.102", # hongkong
"156.59.27.66", # japan
"156.59.27.65", # japan
"23.248.183.107", # south-africa
"23.248.183.106", # south-africa
]
hs_ip = random.choice(hs_ip_list)
logger.info(f"{self.plugin_msg}: 使用 {hs_ip} 作为火山CDN节点")
hs_url = f"http://{hs_host}/live/{stream_id}.flv?{query}"
hs_ip_url = hs_url.replace(hs_host, hs_ip)
return hs_host, stream_id, hs_ip_url
@lru_cache(maxsize=None)
def _get_real_rid(url):
import requests