mirror of
https://github.com/qist/tvbox.git
synced 2026-11-01 09:15:42 +00:00
725 lines
31 KiB
Python
725 lines
31 KiB
Python
#!/usr/bin/python3
|
|
# -*- coding: utf-8 -*-
|
|
# @file : 听书世界.py (hipy-server T4 版; 与 听书世界_T3.py 同源)
|
|
"""
|
|
听书世界 (275听书 + 拾音盘) TVBox py 爬虫 —— T4 hipy-server 运行时
|
|
|
|
原理 (逆向自 拾音盘APP com.shiyinpan.app 与 TingShijie java 爬虫):
|
|
1. 275听书官方接口 (app.365ting.com/listen/Apitzg2025/) 书单/搜索/详情正常,
|
|
但其播放地址接口 AppGetChapterUrl2023 已损坏 (所有章节返回同一占位音频,
|
|
网页版 api_chapter.php 同样返回 url:null) —— 原 java 版
|
|
"获取不到播放地址/起播不成功" 的根因。
|
|
2. 拾音盘APP (275官方配套播放器) 使用另一套接口:
|
|
- 搜索: GET https://api.365ting.com/tingyou-app/search?keyword=xx
|
|
- 提取: POST https://api.365ting.com/tingyou-app/import {user_id, album_id}
|
|
-> task_id -> GET import/status?task_id= 轮询 (热门书有克隆缓存秒回)
|
|
- 曲目: GET https://api.365ting.com/api/albums/{platform_album_id}/tracks?user_id=
|
|
- 播放: GET https://api.365ting.com/tingyou/stream?album={tingyou_id}&idx={序号}
|
|
匿名直连, 支持 Range 拖动
|
|
3. 设备登录: POST /api/auth/device-login {device_id} 免注册, 同 device_id 恒定同 user_id。
|
|
|
|
分类/首页/详情元数据走 275 官方接口, 播放走拾音盘 stream 直链;
|
|
275 bookId 与拾音盘专辑 id 体系不同, 详情时按"书名+演播"自动匹配拾音盘专辑。
|
|
|
|
详情提速 (对比上一版):
|
|
- device_id 固定落盘 -> 跨启动同一 user_id, 已提取专辑直接命中歌单, 跳过 import 轮询;
|
|
- 275->拾音盘 映射与专辑 platform_id 落盘 -> 重复打开直接拿曲目列表;
|
|
- 轮询"先查后睡"(0.4s) 替代固定 sleep(1) x2, 去掉提取完成后的多余歌单回查;
|
|
- 启动探活默认地址优先 (原分发地址已失效, 每次白等 3s 超时)。
|
|
|
|
稳健性 (修"偶发选集/暂无播放数据"):
|
|
- 所有请求走 _req: 网络抖动/超时/429/5xx 退避重试 3 次;
|
|
- 提取轮询单次异常只记日志不中断, 整个提取失败再提交一轮;
|
|
- 曲目列表瞬时异常/空列表重试 3 次, 仍空则重置 plat 重新提取 1 次;
|
|
- 曲目彻底拿不到时按元数据集数合成选集 (第N集), 详情页永不出空;
|
|
- detailContent 整体重试 2 轮;
|
|
- playerContent 不再探活 (探活抖动=空url="暂无播放数据"), 直接给拼好的直链。
|
|
|
|
- 拾音盘搜索没有 TME 独家书 (我弟是神棍/盗墓笔记/雨村笔记/祖祀/重启之极海听雷等
|
|
演播方为 TME有声剧场 的书全部 0 结果), 原逻辑匹配失败 -> 详情页空;
|
|
- 现在匹配不到/提取失败/曲目为空时, 自动改走 275 官方章节接口
|
|
chapter?size=1000&page=&sort=asc&bookId= 拉真实选集;
|
|
- 播放走 AppGetChapterUrl (老接口, xmcdn 源, 实测 12/12 全成功), 需 timeStamp+盐
|
|
双重 md5 签名 addItParapet (签名缺失服务端回 data:null); AppGetChapterUrl2023
|
|
(自家 audio.tingshijie.com 源) 常年 status:1 只当备用;
|
|
音频 CDN 拦截官方 UA (Access Denied: Please use the official App),
|
|
播放 header 用 okhttp UA 即可, 支持 Range 拖动;
|
|
- 搜索同步合并 275 appSearch (需 app_token=abcSEARCH-2025), 拾音盘搜不到的书也能搜。
|
|
|
|
配置示例 (hipy-server T4):
|
|
{
|
|
"key": "hipy_tingshijie", "name": "听书世界", "type": 4,
|
|
"api": "http://127.0.0.1:5707/api/v1/vod/听书世界",
|
|
"searchable": 1, "quickSearch": 1, "filterable": 0
|
|
}
|
|
可选 ext: {"name":"自定义源名"} —— 不传则用默认名。
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
|
|
import requests as N
|
|
|
|
sys.path.append('..')
|
|
try:
|
|
from base.spider import Spider as A # 影视仓 / T3 内置 python
|
|
except Exception:
|
|
try:
|
|
from base.spider import BaseSpider as A # hipy-server T4
|
|
except Exception:
|
|
try:
|
|
from t4.base.spider import BaseSpider as A
|
|
except Exception:
|
|
class A(object): # 兜底: 独立加载不报错
|
|
def __init__(self):
|
|
pass
|
|
|
|
UA = 'TingShiJie/1.8.8 (m.i275.com)'
|
|
APP_UA = 'okhttp/4.9.3'
|
|
BROWSER_UA = ('Mozilla/5.0 (Linux; Android 13; M2102K1C) AppleWebKit/537.36 '
|
|
'(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36')
|
|
DEFAULT_BASE = 'https://app.365ting.com/listen/Apitzg2025/'
|
|
CONFIG_URLS = ['http://101.43.48.231:8090/config/tingchina2025.txt']
|
|
API = 'https://api.365ting.com'
|
|
TY = API + '/tingyou-app'
|
|
PLAY_FLAG = '拾音盘'
|
|
NATIVE_FLAG = '275听书'
|
|
# AppGetChapterUrl2023 签名盐 (逆向自 java 版)
|
|
SIGN_KEY = 'J9gSpfUlzYxE8Hn5IXiGaD2jVMrwAm0K'
|
|
SEARCH_TOKEN = 'abcSEARCH-2025'
|
|
SRC_TTL = 300 # 275 播放地址缓存时长 (auth_key 实测过期后仍可播, 5 分钟很安全)
|
|
PAGE_SIZE = 120
|
|
DEVICE_PREFIX = 'tvbox-tingshijie-'
|
|
# 落盘缓存: 固定 device_id + 专辑映射, 让重复打开详情页走快路径
|
|
CACHE_FILE = os.path.join(tempfile.gettempdir(), 'tvbox_tingshijie_cache.json')
|
|
META_KEYS = ('id', 'title', 'cover_url', 'teller', 'author', 'count', 'description')
|
|
|
|
CLASSES = [
|
|
('6', '玄幻奇幻'), ('7', '都市言情'), ('8', '宫斗女频'), ('9', '官场商战'),
|
|
('10', '武侠仙侠'), ('11', '刑侦推理'), ('12', '探险科幻'), ('13', '重生穿越'),
|
|
('14', '恐怖惊悚'), ('15', '文学历史'), ('49', '两性情感'),
|
|
]
|
|
|
|
|
|
def LOG(msg):
|
|
try:
|
|
print('[听书世界]', msg)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _md5(s):
|
|
return hashlib.md5(s.encode('utf-8')).hexdigest()
|
|
|
|
|
|
class Spider(A):
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.name = '听书世界'
|
|
self.error_play_url = ''
|
|
self.extend = {}
|
|
self._app = None
|
|
self.base = DEFAULT_BASE
|
|
self._uid = None
|
|
self._device_id = ''
|
|
self._album_map = {} # 275 bookId -> 拾音盘专辑 id
|
|
self._plat_map = {} # 拾音盘专辑 id -> 平台歌单 id (已提取过)
|
|
self._info_cache = {} # 拾音盘专辑 id -> {tracks,...} 进程内缓存
|
|
self._ty_meta = {} # 拾音盘专辑 id -> 搜索结果元数据 (标题兜底)
|
|
self._src_cache = {} # 275 播放地址短期缓存 (键 -> (url, 时间))
|
|
self._book_cache = {} # 275 bookId -> bookData (进程内, 重复打开秒出)
|
|
self._chap_cache = {} # 275 bookId -> (chapters, total)
|
|
self._ty_miss = set() # 拾音盘确认没这本书的 bookId (本会话不再白搜)
|
|
self._disk = None # 落盘缓存 (懒加载)
|
|
|
|
def getName(self):
|
|
return self.name
|
|
|
|
def init(self, extend='{}'):
|
|
try:
|
|
self.extend = json.loads(extend) if extend else {}
|
|
if not isinstance(self.extend, dict):
|
|
self.extend = {}
|
|
except Exception as e:
|
|
LOG('init extend 解析失败: %s' % e)
|
|
self.extend = {}
|
|
if self.extend.get('name'):
|
|
self.name = self.extend.get('name')
|
|
self._boot()
|
|
|
|
# ==================== 启动 / 落盘缓存 ====================
|
|
def _boot(self):
|
|
"""懒启动: 即使运行时没调 init 也不会挂"""
|
|
if self._app is None:
|
|
self._disk = self._loadDisk()
|
|
self._device_id = self._disk.get('device_id') or ''
|
|
self._album_map = dict(self._disk.get('album_map') or {})
|
|
self._plat_map = dict(self._disk.get('plat_map') or {})
|
|
self._ty_meta = dict(self._disk.get('meta') or {})
|
|
self._app = N.Session()
|
|
self._app.headers.update({'User-Agent': APP_UA, 'Content-Type': 'application/json'})
|
|
self.base = self._resolveBase()
|
|
LOG('base = %s (device=%s, 映射缓存 %d 条)'
|
|
% (self.base, self._device_id or 'new', len(self._album_map)))
|
|
|
|
@staticmethod
|
|
def _loadDisk():
|
|
try:
|
|
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
|
|
d = json.load(f)
|
|
if isinstance(d, dict):
|
|
return d
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
def _saveDisk(self):
|
|
try:
|
|
d = {
|
|
'device_id': self._device_id or '',
|
|
'album_map': self._album_map,
|
|
'plat_map': self._plat_map,
|
|
'meta': self._ty_meta,
|
|
}
|
|
tmp = CACHE_FILE + '.tmp'
|
|
with open(tmp, 'w', encoding='utf-8') as f:
|
|
json.dump(d, f, ensure_ascii=False)
|
|
os.replace(tmp, CACHE_FILE)
|
|
except Exception as e:
|
|
LOG('cache 落盘失败: %s' % e)
|
|
|
|
def _resolveBase(self):
|
|
def probe(c):
|
|
try:
|
|
r = N.get(c + 'appHomeByCategory?categoryId=6&page=1&size=1',
|
|
timeout=4, headers={'User-Agent': UA})
|
|
return r.json().get('status') == 0
|
|
except Exception:
|
|
return False
|
|
# 默认地址直接探活; 分发服务器(常年失效)只在默认地址挂掉时才去查
|
|
if probe(DEFAULT_BASE):
|
|
return DEFAULT_BASE
|
|
for u in CONFIG_URLS:
|
|
try:
|
|
text = N.get(u, timeout=2, headers={'User-Agent': UA}).text.strip()
|
|
if text and len(text) <= 200 and re.match(r'^https?://[^\s"\'<>]+$', text):
|
|
c = text if text.endswith('/') else text + '/'
|
|
if probe(c):
|
|
return c
|
|
except Exception:
|
|
pass
|
|
return DEFAULT_BASE
|
|
|
|
# ==================== 带重试的 HTTP (详情页健壮性核心) ====================
|
|
def _req(self, method, url, tries=3, **kw):
|
|
"""网络抖动/超时/429/5xx 自动退避重试, 全部失败才抛异常"""
|
|
kw.setdefault('timeout', 15)
|
|
last = None
|
|
for i in range(tries):
|
|
try:
|
|
r = self._app.request(method, url, **kw)
|
|
if r.status_code in (429, 500, 502, 503, 504) and i < tries - 1:
|
|
last = RuntimeError('HTTP %d' % r.status_code)
|
|
time.sleep(0.6 * (i + 1))
|
|
continue
|
|
r.raise_for_status()
|
|
return r
|
|
except Exception as e:
|
|
last = e
|
|
time.sleep(0.6 * (i + 1))
|
|
raise last
|
|
|
|
def _json(self, method, url, **kw):
|
|
r = self._req(method, url, **kw)
|
|
try:
|
|
return r.json()
|
|
except Exception:
|
|
raise RuntimeError('响应非 JSON: %s' % url)
|
|
|
|
def _uidGet(self):
|
|
if not self._uid:
|
|
if not self._device_id: # 固定设备号: 跨启动同一账号, 已提取专辑直接命中
|
|
self._device_id = DEVICE_PREFIX + uuid.uuid4().hex[:12]
|
|
self._saveDisk()
|
|
j = self._json('POST', API + '/api/auth/device-login',
|
|
json={'device_id': self._device_id}, timeout=15)
|
|
uid = (j.get('data') or {}).get('user_id')
|
|
if not uid:
|
|
raise RuntimeError('device-login 无 user_id: %s' % str(j)[:120])
|
|
self._uid = uid
|
|
return self._uid
|
|
|
|
# ==================== 275 官方书单接口 ====================
|
|
def _api275(self, path, timeout=10):
|
|
return self._json('GET', self.base + path,
|
|
headers={'User-Agent': UA}, timeout=timeout)
|
|
|
|
def _item(self, x):
|
|
return {
|
|
'vod_id': '275_' + str(x.get('id') or ''),
|
|
'vod_name': x.get('bookTitle') or '',
|
|
'vod_pic': x.get('bookImage') or '',
|
|
'vod_remarks': x.get('bookAnchor') or '',
|
|
}
|
|
|
|
def _listByCat(self, tid, pg, size=PAGE_SIZE):
|
|
data = self._api275('appHomeByCategory?categoryId=%s&page=%d&size=%d'
|
|
% (tid, int(pg or 1), size)).get('data') or []
|
|
return [self._item(x) for x in data]
|
|
|
|
# ==================== 拾音盘接口 ====================
|
|
def _tySearch(self, keyword):
|
|
j = self._json('GET', TY + '/search', params={'keyword': keyword}, timeout=15)
|
|
items = (j.get('data') or {}).get('items') or []
|
|
added = False
|
|
for it in items:
|
|
if it.get('id'):
|
|
k = str(it['id'])
|
|
if k not in self._ty_meta:
|
|
added = True
|
|
self._ty_meta[k] = {x: it.get(x) for x in META_KEYS}
|
|
if added:
|
|
self._saveDisk()
|
|
return items
|
|
|
|
def _tyMatch(self, title, teller):
|
|
"""标题真正对得上才认; 对不上返回 None 走 275 原生兜底。
|
|
不再拿 items[0] 硬凑 —— 否则 TME 独家书会错绑到别的演播版本。"""
|
|
items = self._tySearch(title or '') or []
|
|
title = re.sub(r'\s+', '', title or '')
|
|
if not title or not items:
|
|
return None
|
|
|
|
def ok(it):
|
|
t = re.sub(r'\s+', '', it.get('title') or '')
|
|
return bool(t) and (t.startswith(title) or title in t or t in title)
|
|
|
|
for it in items: # 标题+演播都对上最优先
|
|
if ok(it) and teller and teller in (it.get('teller') or ''):
|
|
return it
|
|
for it in items: # 标题对上即可
|
|
if ok(it):
|
|
return it
|
|
return None
|
|
|
|
def _albumMeta(self, uid, ty_id):
|
|
"""查用户歌单拿专辑元数据 (title/cover), 拿不到返回 {}"""
|
|
try:
|
|
j = self._json('GET', API + '/api/users/%d/albums' % uid, timeout=15)
|
|
for a in j.get('data') or []:
|
|
if str(a.get('external_id')) == str(ty_id):
|
|
return a
|
|
except Exception as e:
|
|
LOG('用户歌单查询失败(忽略): %s' % e)
|
|
return {}
|
|
|
|
def _doImport(self, uid, ty_id, rounds=2):
|
|
"""提交提取任务并轮询; 轮询单次异常不中断, 失败再提交一轮. 返回 plat id 或 None"""
|
|
for _ in range(rounds):
|
|
try:
|
|
j = self._json('POST', TY + '/import',
|
|
json={'user_id': uid, 'album_id': ty_id}, timeout=30)
|
|
task = (j.get('data') or {}).get('task_id')
|
|
except Exception as e:
|
|
LOG('提取提交异常(重试): %s' % e)
|
|
time.sleep(1)
|
|
continue
|
|
if not task:
|
|
LOG('提取任务无 task_id: %s' % str(j)[:120])
|
|
time.sleep(1)
|
|
continue
|
|
deadline = time.time() + 20 # 服务端冷门书抓取上限
|
|
plat, failed = None, False
|
|
while time.time() < deadline:
|
|
try:
|
|
st = (self._json('GET', TY + '/import/status',
|
|
params={'task_id': task}, timeout=10).get('data')) or {}
|
|
except Exception as e:
|
|
LOG('状态轮询异常(继续等): %s' % e) # 抖动不中断提取
|
|
time.sleep(1)
|
|
continue
|
|
if st.get('status') == 'done':
|
|
plat = st.get('album_id')
|
|
break
|
|
if st.get('status') == 'failed':
|
|
failed = True
|
|
break
|
|
time.sleep(0.5) # 先查后睡: 热门书克隆缓存常秒回
|
|
if plat:
|
|
return plat
|
|
LOG('提取%s (task=%s)' % ('失败' if failed else '超时', task))
|
|
time.sleep(1)
|
|
return None
|
|
|
|
def _getTracks(self, plat, uid, tries=3):
|
|
"""拉曲目列表: 瞬时异常/空列表都重试 (服务端 status=done 后曲目可能还没就绪)"""
|
|
tracks = []
|
|
for i in range(tries):
|
|
try:
|
|
j = self._json('GET', API + '/api/albums/%s/tracks' % plat,
|
|
params={'user_id': uid}, timeout=20)
|
|
tracks = j.get('data') or []
|
|
if not isinstance(tracks, list):
|
|
tracks = tracks.get('tracks') or tracks.get('list') or []
|
|
except Exception as e:
|
|
tracks = []
|
|
LOG('曲目获取异常: %s' % e)
|
|
if tracks:
|
|
return tracks
|
|
time.sleep(0.8)
|
|
return []
|
|
|
|
def _ensureAlbum(self, ty_id, _retried=False):
|
|
"""返回 {platform_id, title, cover, desc, teller, count, tracks}, 带缓存"""
|
|
ty_id = str(ty_id)
|
|
if ty_id in self._info_cache:
|
|
return self._info_cache[ty_id]
|
|
uid = self._uidGet()
|
|
meta = {}
|
|
plat = self._plat_map.get(ty_id) # 落盘快路径: 该专辑已提取过
|
|
if plat is None: # 兜底: 从歌单里找 (老缓存/别处提取的)
|
|
meta = self._albumMeta(uid, ty_id)
|
|
plat = meta.get('id')
|
|
if plat is None: # 提交提取任务 (内部自带重试)
|
|
plat = self._doImport(uid, ty_id)
|
|
if plat is None:
|
|
raise RuntimeError('提取未完成, 稍后重试')
|
|
self._plat_map[ty_id] = plat
|
|
self._saveDisk()
|
|
if not meta and ty_id not in self._ty_meta:
|
|
meta = self._albumMeta(uid, ty_id) # 仅缺元数据时才回查 (历史记录直开)
|
|
tracks = self._getTracks(plat, uid)
|
|
if not tracks and not _retried:
|
|
# 盘缓存的 plat 失效 (服务端重建歌单等): 重置缓存重新提取一次
|
|
LOG('曲目为空, 重置提取缓存重试: %s' % ty_id)
|
|
self._plat_map.pop(ty_id, None)
|
|
self._saveDisk()
|
|
return self._ensureAlbum(ty_id, _retried=True)
|
|
cached = self._ty_meta.get(ty_id) or {}
|
|
info = {
|
|
'platform_id': plat, 'ty_id': ty_id,
|
|
'title': meta.get('title') or cached.get('title') or '',
|
|
'cover': meta.get('cover_url') or cached.get('cover_url') or '',
|
|
'desc': meta.get('description') or cached.get('description') or '',
|
|
'teller': cached.get('teller') or '',
|
|
'count': int(meta.get('count') or cached.get('count') or 0),
|
|
'tracks': tracks,
|
|
}
|
|
if tracks: # 空曲目不落缓存 (服务端还没抓好时, 下次进来还能再试)
|
|
self._info_cache[ty_id] = info
|
|
return info
|
|
|
|
# ==================== 275 原生兜底 (拾音盘没有的书) ====================
|
|
def _bookData(self, bid):
|
|
if bid in self._book_cache:
|
|
return self._book_cache[bid]
|
|
try:
|
|
data = (self._api275('book?bookId=' + bid).get('data') or {}).get('bookData') or {}
|
|
except Exception as e:
|
|
LOG('275 book 获取失败: %s' % e)
|
|
return {}
|
|
if data:
|
|
if len(self._book_cache) > 32:
|
|
self._book_cache.clear()
|
|
self._book_cache[bid] = data
|
|
return data
|
|
|
|
def _bookChapters(self, bid, max_pages=8):
|
|
"""275 官方章节列表 -> (章节列表, 总数)"""
|
|
if bid in self._chap_cache:
|
|
return self._chap_cache[bid]
|
|
out, page, total = [], 1, 0
|
|
while page <= max_pages:
|
|
d = self._api275('chapter?size=1000&page=%d&sort=asc&bookId=%s' % (page, bid),
|
|
timeout=20).get('data') or {}
|
|
lst = d.get('list') or []
|
|
out.extend(lst)
|
|
total = int(d.get('count') or 0)
|
|
if not lst or len(out) >= total or len(lst) < 1000:
|
|
break
|
|
page += 1
|
|
if out:
|
|
if len(self._chap_cache) > 8: # 章节列表大, 少存点
|
|
self._chap_cache.clear()
|
|
self._chap_cache[bid] = (out, total)
|
|
return out, total
|
|
|
|
def _bookNative(self, bid, book):
|
|
"""拾音盘拿不到时用 275 原生章节造详情 (TME 独家书也能出选集)"""
|
|
try:
|
|
chapters, total = self._bookChapters(bid)
|
|
except Exception as e:
|
|
LOG('275 章节获取失败 [%s]: %s' % (bid, e))
|
|
return None
|
|
if not chapters:
|
|
LOG('275 章节为空: %s' % bid)
|
|
return None
|
|
return {
|
|
'native': True, 'bid': str(bid), 'ty_id': '', 'platform_id': '',
|
|
'title': book.get('bookTitle') or '',
|
|
'cover': book.get('bookImage') or '',
|
|
'desc': book.get('bookDesc') or '',
|
|
'teller': book.get('bookAnchor') or '',
|
|
'count': total or len(chapters),
|
|
'tracks': [], 'chapters': chapters,
|
|
}
|
|
|
|
def _bookToAlbum(self, bid):
|
|
"""拾音盘优先; 搜不到/提取失败/曲目为空 -> 275 原生章节兜底, 详情永不出空"""
|
|
book = self._bookData(bid)
|
|
info = None
|
|
if bid in self._album_map:
|
|
try: # 已有映射: 书名拿不到也不影响播放
|
|
info = self._ensureAlbum(self._album_map[bid])
|
|
except Exception as e:
|
|
LOG('拾音盘缓存专辑失败, 转275原生: %s' % e)
|
|
elif book.get('bookTitle'):
|
|
hit = None
|
|
if bid in self._ty_miss: # 会话内已确认拾音盘没有, 直接走原生
|
|
LOG('拾音盘已知无此书, 走275原生: %s' % book.get('bookTitle'))
|
|
else:
|
|
hit = self._tyMatch(book.get('bookTitle', ''), book.get('bookAnchor') or '')
|
|
if hit:
|
|
ty_id = str(hit['id'])
|
|
self._album_map[bid] = ty_id
|
|
self._saveDisk()
|
|
try:
|
|
info = self._ensureAlbum(ty_id)
|
|
except Exception as e:
|
|
LOG('拾音盘提取失败, 转275原生: %s' % e)
|
|
else:
|
|
self._ty_miss.add(bid)
|
|
LOG('拾音盘未匹配到专辑, 走275原生: %s' % book.get('bookTitle'))
|
|
if info and info.get('tracks'):
|
|
# 275 侧的书名/封面/简介更干净, 优先使用
|
|
info['title'] = book.get('bookTitle') or info['title']
|
|
info['cover'] = book.get('bookImage') or info['cover']
|
|
info['desc'] = book.get('bookDesc') or info['desc']
|
|
info['teller'] = book.get('bookAnchor') or info.get('teller') or ''
|
|
return info
|
|
if info:
|
|
LOG('拾音盘曲目为空, 转275原生: %s' % book.get('bookTitle'))
|
|
if not book.get('bookTitle'):
|
|
LOG('275 book 无数据: %s' % bid)
|
|
return info
|
|
native = self._bookNative(bid, book)
|
|
if native:
|
|
return native
|
|
return info # 原生也拿不到才退回按集数合成
|
|
|
|
# ==================== 模板接口 ====================
|
|
def homeContent(self, filter):
|
|
S = {'class': [], 'filters': {}, 'list': [], 'parse': 0, 'jx': 0}
|
|
try:
|
|
self._boot()
|
|
S['class'] = [{'type_id': tid, 'type_name': name} for tid, name in CLASSES]
|
|
S['list'] = self._listByCat('6', 1, 12) # 首页推荐 (玄幻热门)
|
|
except Exception as e:
|
|
LOG('homeContent 失败: %s' % e)
|
|
return S
|
|
|
|
def homeVideoContent(self):
|
|
try:
|
|
self._boot()
|
|
return {'list': self._listByCat('6', 1, 12)}
|
|
except Exception as e:
|
|
LOG('homeVideoContent 失败: %s' % e)
|
|
return {'list': []}
|
|
|
|
def categoryContent(self, cid, page, filter, ext):
|
|
S = {'list': [], 'page': int(page or 1), 'pagecount': int(page or 1),
|
|
'limit': PAGE_SIZE, 'total': 0, 'parse': 0, 'jx': 0}
|
|
try:
|
|
self._boot()
|
|
S['list'] = self._listByCat(cid, page)
|
|
if len(S['list']) >= PAGE_SIZE:
|
|
S['pagecount'] = int(page or 1) + 1
|
|
S['total'] = S['pagecount'] * PAGE_SIZE
|
|
except Exception as e:
|
|
LOG('categoryContent 失败: %s' % e)
|
|
return S
|
|
|
|
@staticmethod
|
|
def _buildVod(raw, info):
|
|
"""三路出选集: 拾音盘 tracks / 275 原生章节 / 按集数合成 (详情永不出空)"""
|
|
eps, flag = [], PLAY_FLAG
|
|
if info.get('native'):
|
|
bid = info['bid']
|
|
for i, ch in enumerate(info.get('chapters') or []):
|
|
cid = ch.get('chapterId') or ch.get('url')
|
|
if not cid:
|
|
continue
|
|
name = re.sub(r'[#$]', ' ', ch.get('title') or '') or ('第%d集' % (i + 1))
|
|
eps.append('%s$275|%s|%s' % (name, bid, cid))
|
|
if not eps:
|
|
return None
|
|
flag = NATIVE_FLAG
|
|
else:
|
|
ty_id = info['ty_id']
|
|
tracks = info.get('tracks') or []
|
|
if tracks:
|
|
for i, t in enumerate(tracks):
|
|
idx = t.get('track_no') or i + 1
|
|
name = re.sub(r'[#$]', ' ', t.get('title') or '') or ('第%d集' % idx)
|
|
eps.append('%s$%s|%s' % (name, ty_id, idx))
|
|
else:
|
|
count = int(info.get('count') or 0)
|
|
if count <= 0:
|
|
return None
|
|
LOG('曲目列表不可用, 按集数合成选集兜底: %s x%d' % (ty_id, count))
|
|
eps = ['第%d集$%s|%d' % (n, ty_id, n) for n in range(1, count + 1)]
|
|
return {
|
|
'vod_id': raw,
|
|
'vod_name': info.get('title') or '',
|
|
'vod_pic': info.get('cover') or '',
|
|
'vod_remarks': info.get('teller') or '',
|
|
'vod_content': info.get('desc') or '',
|
|
'vod_play_from': flag,
|
|
'vod_play_url': '#'.join(eps),
|
|
}
|
|
|
|
def detailContent(self, did):
|
|
raw = str(did[0]).strip()
|
|
S = {'list': [], 'parse': 0, 'jx': 0}
|
|
t0 = time.time()
|
|
for attempt in (1, 2): # 整体重试一轮: 瞬时失败时第一轮往往已把各环节缓存焐热
|
|
info, vod = None, None
|
|
try:
|
|
self._boot()
|
|
if raw.startswith('ty_'):
|
|
info = self._ensureAlbum(raw[3:])
|
|
else:
|
|
info = self._bookToAlbum(raw.replace('275_', ''))
|
|
if info:
|
|
vod = self._buildVod(raw, info)
|
|
except Exception as e:
|
|
LOG('detailContent 异常(第%d次) [%s]: %s' % (attempt, raw, e))
|
|
if vod:
|
|
S['list'].append(vod)
|
|
LOG('detail %s: %d集 %.2fs (第%d次)'
|
|
% (raw, vod['vod_play_url'].count('#') + 1, time.time() - t0, attempt))
|
|
return S
|
|
if attempt < 2:
|
|
time.sleep(0.8)
|
|
LOG('detailContent 失败 [%s] %.2fs' % (raw, time.time() - t0))
|
|
return S
|
|
|
|
def searchContent(self, key, quick, page='1'):
|
|
S = {'list': [], 'page': 1, 'pagecount': 1, 'limit': 20, 'total': 0,
|
|
'parse': 0, 'jx': 0}
|
|
try:
|
|
self._boot()
|
|
seen = set()
|
|
for it in self._tySearch(key) or []:
|
|
name = it.get('title') or ''
|
|
seen.add(re.sub(r'\s+', '', name))
|
|
S['list'].append({
|
|
'vod_id': 'ty_' + str(it.get('id') or ''),
|
|
'vod_name': name,
|
|
'vod_pic': it.get('cover_url') or '',
|
|
'vod_remarks': '%s·%s集' % (it.get('teller') or it.get('author') or '',
|
|
it.get('count') or '?'),
|
|
})
|
|
# 275 官方搜索: 拾音盘没有的书 (TME 独家等) 也能搜到
|
|
try:
|
|
j = self._json('GET', self.base + 'appSearch',
|
|
params={'client': 'babala-android', 'search': key,
|
|
'app_token': SEARCH_TOKEN},
|
|
headers={'User-Agent': UA}, timeout=15)
|
|
for x in (j.get('data') or {}).get('bookData') or []:
|
|
name = x.get('bookTitle') or ''
|
|
k = re.sub(r'\s+', '', name)
|
|
if not k or k in seen:
|
|
continue
|
|
seen.add(k)
|
|
S['list'].append({
|
|
'vod_id': '275_' + str(x.get('id') or ''),
|
|
'vod_name': name,
|
|
'vod_pic': x.get('bookImage') or '',
|
|
'vod_remarks': x.get('bookAnchor') or '',
|
|
})
|
|
except Exception as e:
|
|
LOG('275 搜索失败(忽略): %s' % e)
|
|
S['total'] = len(S['list'])
|
|
except Exception as e:
|
|
LOG('searchContent 失败 [%s]: %s' % (key, e))
|
|
return S
|
|
|
|
def _nativeSrc(self, bid, cid):
|
|
"""275 原生播放地址: timeStamp+盐 双重 md5 签名 (缺签名服务端回 data:null)。
|
|
主用老接口 AppGetChapterUrl (xmcdn 源, 实测 12/12 稳定);
|
|
AppGetChapterUrl2023 (自家 audio.tingshijie.com 源) 经常 status:1
|
|
"分集地址解析失败", 只当备用。拿到后短期缓存, 复播不再打接口。"""
|
|
key = '%s|%s' % (bid, cid)
|
|
c = self._src_cache.get(key)
|
|
if c and time.time() - c[1] < SRC_TTL:
|
|
return c[0]
|
|
for i in range(3):
|
|
for path in ('AppGetChapterUrl', 'AppGetChapterUrl2023'):
|
|
try:
|
|
ts = str(int(time.time() * 1000))
|
|
sign = _md5(_md5(ts + SIGN_KEY) + SIGN_KEY)
|
|
j = self._json('GET', self.base + path,
|
|
params={'timeStamp': ts, 'uid': '', 'chapterId': cid,
|
|
'addItParapet': sign, 'bookId': bid},
|
|
headers={'User-Agent': APP_UA}, tries=2, timeout=10)
|
|
src = j.get('src')
|
|
if src:
|
|
self._src_cache[key] = (src, time.time())
|
|
if len(self._src_cache) > 64:
|
|
self._src_cache.clear()
|
|
return src
|
|
LOG('275 播放地址为空[%s]: %s/%s %s' % (path[-4:], bid, cid, str(j)[:70]))
|
|
except Exception as e:
|
|
LOG('275 播放地址获取异常[%s]: %s' % (path[-4:], e))
|
|
time.sleep(1.0 * (i + 1)) # 抖动/频控冷却
|
|
return ''
|
|
|
|
def playerContent(self, flag, pid, vipFlags):
|
|
# 直链由 album+idx 规则拼出, 不做探活: 探活请求一旦抖动就返回空 url,
|
|
# 播放器必然报"暂无播放数据" —— 交给播放器自己拉流即可。
|
|
# 275 原生源: okhttp UA (audio.tingshijie.com 拦官方 UA, okhttp 放行)
|
|
# 拾音盘直链: 浏览器 UA —— api.365ting.com/tingyou/stream 对 okhttp UA
|
|
# 会卡死 (0 KB/s), 浏览器 UA 可正常播放; xmcdn 源不受影响
|
|
S = {'parse': 0, 'jx': 0, 'playUrl': '', 'url': '', 'header': {'User-Agent': APP_UA}}
|
|
try:
|
|
parts = str(pid).split('|')
|
|
if len(parts) == 3 and parts[0] == '275': # 275 原生章节
|
|
S['url'] = self._nativeSrc(parts[1], parts[2])
|
|
if not S['url']:
|
|
LOG('playerContent 275 播放地址获取失败: %s' % pid)
|
|
elif len(parts) == 2 and parts[1].isdigit(): # 拾音盘直链
|
|
S['header']['User-Agent'] = BROWSER_UA
|
|
S['url'] = '%s/tingyou/stream?album=%s&idx=%s' % (API, parts[0], parts[1])
|
|
else:
|
|
LOG('playerContent 参数异常: %s' % pid)
|
|
except Exception as e:
|
|
LOG('playerContent 参数异常 [%s]: %s' % (pid, e))
|
|
return S
|
|
|
|
def localProxy(self, params):
|
|
return 0
|
|
|
|
def isVideoFormat(self, url):
|
|
return False
|
|
|
|
def manualVideoCheck(self):
|
|
return False
|
|
|
|
def destroy(self):
|
|
try:
|
|
self._app.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if __name__ == '__main__':
|
|
0
|