Files
sg/py/悦听.py
2026-08-14 11:26:58 +02:00

393 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# coding=utf-8
#!/usr/bin/env python3
"""TVBox / 影视仓 Python 源: 悦听吧有声书。"""
import base64
import hashlib
import html as html_lib
import json
import random
import re
import sys
import time
from urllib.parse import unquote, urljoin
import requests
sys.path.append('..')
try:
from base.spider import Spider as BaseSpider
except ImportError:
class BaseSpider(object):
pass
class Spider(BaseSpider):
site = 'http://www.yuetingba.cn'
_assl_marker = 'xMiP5W1DHBxC5PwQ5oj5QfRn0tsT5UBk'
_assl_key = 'le95G3hnFDJsBE+1/v9eYw=='
_assl_iv = 'IvswQFEUdKYf+d1wKpYLTg=='
def __init__(self):
try:
super().__init__()
except TypeError:
pass
self.session = requests.Session()
self.session.headers.update({
'User-Agent': (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/120.0.0.0 Safari/537.36'
),
'Referer': self.site + '/',
})
self.cateManual = {
'玄幻': '1', '都市': '4', '历史': '2', '名著': '6',
'女频': '7', '科幻': '5', '武侠': '3', '评书': 'a', '社科': '8',
}
self._runtime_cache = {}
def init(self, extend=''):
pass
def getName(self):
return '悦听吧'
def isVideoFormat(self, url):
pass
def manualVideoCheck(self):
pass
def homeContent(self, filter):
return {
'class': [
{'type_id': value, 'type_name': name}
for name, value in self.cateManual.items()
],
'filters': {}, 'list': [], 'parse': 0, 'jx': 0,
}
def homeVideoContent(self):
result = {'list': [], 'parse': 0, 'jx': 0}
try:
result['list'] = self._parse_books(self._get('/').text)[:30]
except Exception as error:
print('homeVideoContent error: %s' % error)
return result
def categoryContent(self, tid, pg, filter, extend):
page = self._page_number(pg)
result = {'list': [], 'parse': 0, 'jx': 0, 'page': page}
try:
page_html = self._get('/book/%s/%s' % (tid, page)).text
result['list'] = self._parse_books(page_html)
total_match = re.search(r'共\s*(\d+)\s*条', page_html)
total = int(total_match.group(1)) if total_match else len(result['list'])
result['total'] = total
result['limit'] = 10
result['pagecount'] = max(page, (total + 9) // 10)
except Exception as error:
print('categoryContent error: %s' % error)
result.update({'total': 0, 'limit': 10, 'pagecount': page})
return result
def detailContent(self, ids):
result = {'list': [], 'parse': 0, 'jx': 0}
book_id = ids[0] if ids else ''
if not book_id:
return result
book_id = self._book_id(book_id)
try:
first_html = self._get('/book/detail/%s/0' % book_id).text
title = self._first_text(first_html, r'<h1[^>]*>(.*?)</h1>')
if not title:
title = self._first_text(first_html, r'book-detail-title[^>]*>(.*?)</h2>')
pic_match = re.search(
r'<div class="books-detail-img">.*?<img[^>]+src=["\']([^"\']+)',
first_html, re.S,
)
pic = urljoin(self.site, pic_match.group(1)) if pic_match else ''
author = self._field(first_html, '作\s*者')
category = self._field(first_html, '分\s*类')
speaker = self._field(first_html, '演\s*播')
status = self._field(first_html, '状\s*态')
count = self._field(first_html, '集\s*数')
desc_match = re.search(
r'内容简介:</h4>.*?text-desc-content[^>]*>\s*<p>(.*?)</p>',
first_html, re.S,
)
desc = self._clean(desc_match.group(1)) if desc_match else ''
offsets = sorted({
int(value) for value in re.findall(
r'/book/detail/%s/(\d+)' % re.escape(book_id), first_html
)
}) or [0]
episodes = self._parse_episodes(first_html)
seen = {episode_id for _, episode_id in episodes}
for offset in offsets:
if offset == 0:
continue
page_html = self._get('/book/detail/%s/%s' % (book_id, offset)).text
for episode in self._parse_episodes(page_html):
if episode[1] not in seen:
episodes.append(episode)
seen.add(episode[1])
vod = {
'vod_id': book_id,
'vod_name': title,
'vod_pic': pic,
'type_name': category,
'vod_year': '',
'vod_area': '',
'vod_remarks': ('%s · %s集' % (status, count)).strip(' ·'),
'vod_actor': speaker,
'vod_director': author,
'vod_content': desc,
'vod_play_from': '悦听吧',
'vod_play_url': '#'.join(
'%s$%s' % (self._play_name(name), episode_id)
for name, episode_id in episodes
),
}
result['list'].append(vod)
except Exception as error:
print('detailContent error: %s' % error)
return result
def playerContent(self, flag, id, vipFlags):
try:
audio_url = self._resolve_audio(id)
return {
'parse': 0,
'url': audio_url,
'jx': 0,
'header': {
'User-Agent': self.session.headers['User-Agent'],
'Referer': self.site + '/',
},
}
except Exception as error:
print('playerContent error: %s' % error)
return {'parse': 1, 'url': id, 'jx': 0, 'header': {}}
def searchContent(self, key, quick, pg='1'):
page = self._page_number(pg)
result = {'list': [], 'parse': 0, 'jx': 0, 'page': page}
try:
response = self._get('/Search', params={
'type': '1', 'name': key, 'pageIndex': page,
})
result['list'] = self._parse_books(response.text)
total_match = re.search(r'共\s*(\d+)\s*条', response.text)
total = int(total_match.group(1)) if total_match else len(result['list'])
result.update({
'total': total,
'limit': 10,
'pagecount': max(page, (total + 9) // 10),
})
except Exception as error:
print('searchContent error: %s' % error)
result.update({'total': 0, 'limit': 10, 'pagecount': page})
return result
def localProxy(self, params):
return [200, 'audio/mpeg', {}, '']
def _get(self, path, **kwargs):
url = path if path.startswith('http') else urljoin(self.site + '/', path.lstrip('/'))
response = self.session.get(url, timeout=20, **kwargs)
response.raise_for_status()
response.encoding = response.apparent_encoding or 'utf-8'
return response
def _parse_books(self, page_html):
videos = []
seen = set()
pattern = re.compile(
r'<div[^>]+class="[^"]*section-box-list-item[^"]*"[^>]*>(.*?)'
r'(?=<div[^>]+class="[^"]*section-box-list-item|</section>|<div class="pagelist")',
re.S,
)
for block in pattern.findall(page_html):
link = re.search(r'href=["\']/book/detail/([^/"\']+)/\d+["\']', block)
title = re.search(
r'box-list-item-text-title[^>]*>\s*<a[^>]*>(.*?)</a>', block, re.S
)
image = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', block)
if not link or not title:
continue
book_id = link.group(1)
if book_id in seen:
continue
seen.add(book_id)
author_speaker = [self._clean(value) for value in re.findall(
r'<span[^>]+title=["\']([^"\']+)["\']', block
)]
remark = ' / '.join(author_speaker[:2])
videos.append({
'vod_id': book_id,
'vod_name': self._clean(title.group(1)),
'vod_pic': urljoin(self.site, image.group(1)) if image else '',
'vod_remarks': remark,
})
return videos
def _parse_episodes(self, page_html):
episodes = []
for episode_id, block in re.findall(
r'<div id="item_([0-9a-f-]{36})" class="ting-list-content-item">(.*?)</div>\s*</div>',
page_html, re.S | re.I,
):
titles = re.findall(r'<a[^>]+title="([^"]+)"', block, re.S)
name = self._clean(titles[-1]) if titles else episode_id
episodes.append((name, episode_id))
return episodes
def _resolve_audio(self, episode_id):
data = self._get(
'/api/app/docs-listen/%s/ting-with-efi' % episode_id
).json()
book_id = data['bookId']
ting_no = int(data.get('tingNo') or 1)
offset = ((ting_no - 1) // 200) * 200
runtime = self._book_runtime(book_id, offset)
plain_id = data['id'].replace('-', '')
compact_time = re.sub(r'[-:T. ]', '', data['creationTime']).ljust(20, '0')
key = bytes(
(ord(plain_id[index]) + int(compact_time[index % 20])) & 0xff
for index in range(len(plain_id))
)
iv = bytes(
(ord(plain_id[index]) + int(compact_time[index - 1])) & 0xff
for index in range(20, 4, -1)
)
source_path = self._aes_decrypt(data['efi'], key, iv).decode('utf-8').strip()
filename = unquote(source_path.rstrip('/').split('/')[-1])
server = self._pick_server(runtime['servers'], book_id)
path = source_path
if '_p' in server['Name']:
path = '/%s_%s/%s' % (runtime['py'], book_id, filename)
elif '_b' in server['Name']:
path = '/myfiles/host/listen/booksdir/%s_%s/%s' % (
runtime['py'], book_id, filename,
)
expires = int(time.time()) + 600
signature = hashlib.md5(
('%s|%s|%s' % (filename, expires, self._assl_marker)).encode('utf-8')
).hexdigest()
host = '%s://%s:%s' % (server['Scheme'], server['Value'], server['Port'])
return '%s%s?token=%s&expire=%s' % (host, path, signature, expires)
def _book_runtime(self, book_id, offset):
cached = self._runtime_cache.get(book_id)
if cached and time.time() - cached['cached_at'] < 1800:
return cached
page_html = self._get('/book/detail/%s/%s' % (book_id, offset)).text
assl = self._js_var(page_html, 'assl').replace(self._assl_marker, '')
servers = json.loads(self._aes_decrypt(
assl, base64.b64decode(self._assl_key), base64.b64decode(self._assl_iv)
).decode('utf-8'))
runtime = {
'servers': servers,
'py': self._js_var(page_html, 'py'),
'cached_at': time.time(),
}
self._runtime_cache[book_id] = runtime
return runtime
@staticmethod
def _pick_server(servers, book_id):
available = [
item for item in servers
if str(item.get('AsType')) == '1' and item.get('Type') == 'A'
]
group = book_id.split('-')[4]
dedicated = [
item for item in available
if item.get('BookIds') and group in item['BookIds']
]
if dedicated:
available = dedicated
else:
available = [
item for item in available
if int(item.get('Ratio') or 0) > 0
and not str(item.get('BookIds') or '').strip()
]
if not available:
raise ValueError('没有可用的音频服务器')
weights = [max(0, int(item.get('Ratio') or 0)) for item in available]
return random.choices(available, weights=weights or None, k=1)[0]
@staticmethod
def _aes_decrypt(cipher_text, key, iv):
encrypted = base64.b64decode(re.sub(r'\s+', '', cipher_text))
try:
from Crypto.Cipher import AES
padded = AES.new(key, AES.MODE_CBC, iv).decrypt(encrypted)
except ImportError:
try:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
except ImportError:
raise RuntimeError('需要 pycryptodome 或 cryptography 才能解析音频')
decryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
padded = decryptor.update(encrypted) + decryptor.finalize()
padding_size = padded[-1]
if padding_size < 1 or padding_size > 16:
raise ValueError('AES 填充无效')
return padded[:-padding_size]
@staticmethod
def _js_var(page_html, name):
match = re.search(
r'\b(?:var|let|const)\s+%s\s*=\s*["\']([^"\']*)["\']' % re.escape(name),
page_html,
)
if not match:
raise ValueError('页面缺少参数: %s' % name)
return html_lib.unescape(match.group(1))
@staticmethod
def _field(page_html, label):
match = re.search(
r'text-desc-title[^>]*>\s*%s?\s*</span>\s*'
r'<span[^>]*text-desc-content[^>]*>(.*?)</span>' % label,
page_html, re.S,
)
return Spider._clean(match.group(1)) if match else ''
@staticmethod
def _first_text(page_html, pattern):
match = re.search(pattern, page_html, re.S)
return Spider._clean(match.group(1)) if match else ''
@staticmethod
def _clean(value):
value = re.sub(r'<br\s*/?>', '\n', value or '', flags=re.I)
value = re.sub(r'<[^>]+>', '', value)
value = html_lib.unescape(value)
return re.sub(r'[ \t\r\f\v]+', ' ', value).strip()
@staticmethod
def _page_number(value):
try:
return max(1, int(value or 1))
except (TypeError, ValueError):
return 1
@staticmethod
def _book_id(value):
match = re.search(r'/book/detail/([^/]+)/', value or '')
return match.group(1) if match else value
@staticmethod
def _play_name(name):
return (name or '').replace('$', '¥').replace('#', '﹟')