# 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']*>(.*?)') if not title: title = self._first_text(first_html, r'book-detail-title[^>]*>(.*?)') pic_match = re.search( r'
.*?]+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'内容简介:.*?text-desc-content[^>]*>\s*

(.*?)

', 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']+class="[^"]*section-box-list-item[^"]*"[^>]*>(.*?)' r'(?=]+class="[^"]*section-box-list-item||
]*>\s*]*>(.*?)', block, re.S ) image = re.search(r']+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']+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'
(.*?)
\s*
', page_html, re.S | re.I, ): titles = re.findall(r']+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*\s*' r']*text-desc-content[^>]*>(.*?)' % 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'', '\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('#', '﹟')