上传文件至「py」
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
# 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('#', '﹟')
|
||||
Reference in New Issue
Block a user