Sync all projects
This commit is contained in:
@@ -0,0 +1,753 @@
|
||||
# coding=utf-8
|
||||
#!/usr/bin/python
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import requests
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from base.spider import Spider
|
||||
|
||||
sys.path.append("..")
|
||||
|
||||
|
||||
class Spider(Spider):
|
||||
|
||||
def init(self, extend="{}"):
|
||||
# 1. 创建 Session
|
||||
self.create_session_with_retry()
|
||||
|
||||
# 2. 备用域名列表
|
||||
self.dynamic_urls = [
|
||||
" https://zh.stripol.com/",
|
||||
"https://zh.pikpedcams.com/",
|
||||
"https://zh.virtualtaboo.live/",
|
||||
]
|
||||
|
||||
# 3. 基础 Header 及属性初始化
|
||||
self.Doppiocdn = "doppiocdn.org"
|
||||
user_agent = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101"
|
||||
" Firefox/153.0"
|
||||
)
|
||||
self.headers = {
|
||||
"User-Agent": user_agent,
|
||||
"Accept-Language": "zh,en;q=0.5",
|
||||
}
|
||||
|
||||
# 默认选第一个,后续请求会轮询切换
|
||||
self.host = self.dynamic_urls[0]
|
||||
self._update_headers_for_host(self.host)
|
||||
|
||||
# 4. 其他配置及弹幕锁
|
||||
self.stripchat_preferredVideoCodec = "H264" # 可选H264或AV1
|
||||
self.stripchat_key = "YzWScuyQRGAGcxx1KIJmiQ7BY9Vi35ftwLqUOVO8uoo="
|
||||
self.stripchat_pkey = "Fq6m2TO2ZeBkRPm9"
|
||||
self.stripchat_play = "0 0"
|
||||
self.danmu_cache = {}
|
||||
self.danmu_threads = {}
|
||||
self.danmu_lock = threading.Lock()
|
||||
|
||||
def _update_headers_for_host(self, host_url):
|
||||
"""根据当前使用的 Host 刷新请求头"""
|
||||
self.host = host_url
|
||||
self.headers["Origin"] = host_url
|
||||
self.headers["Referer"] = f"{host_url}/"
|
||||
self.json_headers = {
|
||||
**self.headers,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
|
||||
def _request_with_failover(self, path, timeout=(3, 5)):
|
||||
"""核心逻辑:逐个域名尝试请求列表/详情,直到成功为止"""
|
||||
# 优先使用上一次成功的域名,若不在第一位则将其调整到前面
|
||||
urls_to_try = list(self.dynamic_urls)
|
||||
if self.host in urls_to_try:
|
||||
urls_to_try.remove(self.host)
|
||||
urls_to_try.insert(0, self.host)
|
||||
|
||||
last_error = None
|
||||
|
||||
for domain in urls_to_try:
|
||||
clean_domain = domain.strip().rstrip('/')
|
||||
full_url = (
|
||||
f'{clean_domain}{path}' if path.startswith('/') else f'/{path}'
|
||||
)
|
||||
|
||||
# 临时构造对应域名的 Header
|
||||
headers = {
|
||||
**self.headers,
|
||||
'Origin': clean_domain,
|
||||
'Referer': f'{clean_domain}/',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.session.get(full_url, headers=headers, timeout=timeout)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# 校验是否拿到了正确的 json 数据
|
||||
if isinstance(data, dict):
|
||||
# 成功!更新全局 Host 状态
|
||||
if self.host != clean_domain:
|
||||
self.log(f'[HOST] 切换可用域名为: {clean_domain}')
|
||||
self._update_headers_for_host(clean_domain)
|
||||
return data
|
||||
else:
|
||||
self.log(
|
||||
f'[HOST] 域名 {clean_domain} 请求失败,状态码: {response.status_code},尝试下一个...'
|
||||
)
|
||||
except Exception as e:
|
||||
self.log(f'[HOST] 域名 {clean_domain} 访问异常: {e},尝试下一个...')
|
||||
last_error = e
|
||||
continue
|
||||
|
||||
self.log(f'[HOST] 所有域名均访问失败! 最后一次错误: {last_error}')
|
||||
return {}
|
||||
|
||||
def getName(self):
|
||||
return 'StripChat'
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
def homeVideoContent(self):
|
||||
pass
|
||||
|
||||
def datetime_utc8(self, strTime, outFormat):
|
||||
return (
|
||||
datetime.strptime(strTime, '%Y-%m-%dT%H:%M:%SZ') + timedelta(hours=8)
|
||||
).strftime(outFormat)
|
||||
|
||||
def homeContent(self, filter):
|
||||
CLASSES = [
|
||||
{'type_name': '女主播', 'type_id': 'girls'},
|
||||
{'type_name': '情侣', 'type_id': 'couples'},
|
||||
{'type_name': '男主播', 'type_id': 'men'},
|
||||
{'type_name': '跨性别', 'type_id': 'trans'},
|
||||
]
|
||||
VALUE = [
|
||||
{'n': '新主播', 'v': 'autoTagNew'},
|
||||
{'n': '推荐', 'v': 'recommended'},
|
||||
{'v': 'fuckMachine', 'n': '炮机'},
|
||||
{'n': '青年', 'v': 'ageTeen'},
|
||||
{'n': 'VR', 'v': 'autoTagVr'},
|
||||
{'n': '亚洲人', 'v': 'ethnicityAsian'},
|
||||
{'n': '🇨🇳中国', 'v': 'tagLanguageChinese'},
|
||||
{'n': '🇯🇵日本', 'v': 'tagLanguageJapanese'},
|
||||
{'n': '🇰🇷韩国', 'v': 'tagLanguageKorean'},
|
||||
{'n': '🇻🇳越南', 'v': 'tagLanguageVietnamese'},
|
||||
{'v': 'tagLanguageUkrainian', 'n': '🇺🇦乌克兰'},
|
||||
{'v': 'tagLanguageRussianSpeaking', 'n': '🇷🇺俄罗斯'},
|
||||
{'v': 'tagLanguageUSModels', 'n': '🇺🇸美国'},
|
||||
{'v': 'tagLanguageColombian', 'n': '🇨🇴哥伦比亚'},
|
||||
{'v': 'tagLanguageGermanSpeaking', 'n': '🇩🇪德国'},
|
||||
{'v': 'tagLanguageFrench', 'n': '🇫🇷法国'},
|
||||
{'v': 'tagLanguageUKModels', 'n': '🇬🇧英国'},
|
||||
{'v': 'tagLanguageCanadian', 'n': '🇨🇦加拿大'},
|
||||
{'v': 'tagLanguageMexican', 'n': '🇲🇽墨西哥'},
|
||||
{'v': 'ethnicityIndian', 'n': '🇮🇳印度'},
|
||||
{'v': 'tagLanguageVenezuelan', 'n': '🇻🇪委内瑞拉'},
|
||||
{'v': 'tagLanguageRomanian', 'n': '🇷🇴罗马尼亚'},
|
||||
{'v': 'tagLanguageAfrican', 'n': '🌍非洲'},
|
||||
{'v': 'tagLanguageSpanishSpeaking', 'n': '🇪🇸西班牙'},
|
||||
{'v': 'ethnicityMiddleEastern', 'n': '🇸🇦🇦🇪阿拉伯'},
|
||||
{'v': 'tagLanguageKenyan', 'n': '🇰🇪肯尼亚'},
|
||||
{'v': 'tagLanguageSouthAfrican', 'n': '🇿🇦南非'},
|
||||
{'v': 'tagLanguageBrazilian', 'n': '🇧🇷巴西'},
|
||||
{'v': 'tagLanguageThai', 'n': '🇹🇭泰国'},
|
||||
{'v': 'tagLanguageItalian', 'n': '🇮🇹意大利'},
|
||||
{'n': '亚洲', 'v': 'ethnicityAsian'},
|
||||
{'n': '白人', 'v': 'ethnicityWhite'},
|
||||
{'n': '拉丁', 'v': 'ethnicityLatino'},
|
||||
{'n': '混血', 'v': 'ethnicityMultiracial'},
|
||||
{'n': '印度', 'v': 'ethnicityIndian'},
|
||||
{'n': '阿拉伯', 'v': 'ethnicityMiddleEastern'},
|
||||
{'n': '黑人', 'v': 'ethnicityEbony'},
|
||||
{'n': '✨新主播', 'v': 'autoTagNew'},
|
||||
{'n': 'VR直播', 'v': 'autoTagVr'},
|
||||
{'n': '18+', 'v': 'ageTeen'},
|
||||
{'n': '鲜嫩青年22+', 'v': 'ageYoung'},
|
||||
{'n': '学生', 'v': 'subcultureStudent'},
|
||||
{'n': '口交', 'v': 'doBlowjob'},
|
||||
{'n': '深喉', 'v': 'doDeepThroat'},
|
||||
{'n': '恋足', 'v': 'doFootFetish'},
|
||||
{'n': '互动玩具', 'v': 'autoTagInteractiveToy'},
|
||||
{'n': '自慰', 'v': 'doMasturbation'},
|
||||
{'n': '肛交', 'v': 'doAnal'},
|
||||
{'n': '潮吹', 'v': 'doSquirt'},
|
||||
{'n': '狗式', 'v': 'doDoggyStyle'},
|
||||
{'n': 'Cosplay', 'v': 'doCosplay'},
|
||||
{'n': 'RolePlay', 'v': 'doRolePlay'},
|
||||
]
|
||||
VALUE_MEN = [
|
||||
{'n': '情侣', 'v': 'sexGayCouples'},
|
||||
{'n': '直男', 'v': 'orientationStraight'},
|
||||
]
|
||||
TIDS = ('girls', 'couples', 'men', 'trans')
|
||||
filters = {
|
||||
tid: [{'key': 'tag', 'value': VALUE_MEN + VALUE if tid == 'men' else VALUE}]
|
||||
for tid in TIDS
|
||||
}
|
||||
return {'class': CLASSES, 'filters': filters}
|
||||
|
||||
def _parse_status_remark(self, is_live, status, viewers=0):
|
||||
if not is_live or status == 'off':
|
||||
status_text = '⚫ 已下播'
|
||||
elif status == 'public':
|
||||
status_text = '🔴 直播中'
|
||||
else:
|
||||
status_text = '收费房'
|
||||
|
||||
return f'👤 {viewers}人 | {status_text}' if viewers else status_text
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
try:
|
||||
pg_str = str(pg)
|
||||
page_num = int(pg_str)
|
||||
|
||||
# 1. 搜索场景逻辑
|
||||
if tid.startswith('search '):
|
||||
_, tag, key = tid.split(maxsplit=2)
|
||||
path = f'/api/front/v4/models/search/group/username?query={key}&limit=900&primaryTag={tag}'
|
||||
rsp = self._request_with_failover(path)
|
||||
|
||||
videos = []
|
||||
for u in rsp.get('models', []):
|
||||
if not u.get('isLive'):
|
||||
continue
|
||||
viewers = u.get('viewersCount', 0)
|
||||
is_live = u.get('isLive', False)
|
||||
status = u.get('status', 'off')
|
||||
|
||||
remark = self._parse_status_remark(is_live, status, viewers)
|
||||
videos.append({
|
||||
'vod_id': str(u['username']),
|
||||
'vod_name': (
|
||||
f"{self.country_code_to_flag(str(u.get('country', '')))}{u['username']}"
|
||||
),
|
||||
'vod_pic': (
|
||||
f"https://img.{self.Doppiocdn}/snapshot/{u['id']}/{u.get('snapshotTimestamp', '')}"
|
||||
),
|
||||
'style': {'type': 'rect', 'ratio': 1.78},
|
||||
'vod_remarks': remark,
|
||||
})
|
||||
|
||||
return {
|
||||
'list': videos,
|
||||
'page': pg_str,
|
||||
'pagecount': '1',
|
||||
'limit': '900',
|
||||
'total': str(len(videos)),
|
||||
}
|
||||
|
||||
# 2. 普通分类列表场景逻辑 (已移除 host_card 保证追加)
|
||||
limit = 60
|
||||
offset = limit * (page_num - 1)
|
||||
path = f'/api/front/models?improveTs=false&removeShows=false&limit={limit}&offset={offset}&primaryTag={tid}&sortBy=stripRanking&rcmGrp=A&rbCnGr=true&prxCnGr=false&nic=false'
|
||||
if 'tag' in extend and extend['tag']:
|
||||
path += f'&filterGroupTags=[["{extend["tag"]}"]]'
|
||||
|
||||
# 使用轮询切域名获取列表数据
|
||||
rsp = self._request_with_failover(path)
|
||||
|
||||
videos = []
|
||||
for v in rsp.get('models', []):
|
||||
is_live = v.get('isLive', False)
|
||||
status = v.get('status', 'public')
|
||||
viewers = v.get('viewersCount', 0)
|
||||
|
||||
remark = self._parse_status_remark(is_live, status, viewers)
|
||||
|
||||
videos.append({
|
||||
'vod_id': str(v['username']),
|
||||
'vod_name': (
|
||||
f"{self.country_code_to_flag(str(v.get('country', '')))}{v['username']}"
|
||||
),
|
||||
'vod_pic': (
|
||||
f"https://img.{self.Doppiocdn}/snapshot/{v['id']}/{v.get('snapshotTimestamp', '')}"
|
||||
),
|
||||
'vod_remarks': remark,
|
||||
})
|
||||
|
||||
total = int(rsp.get('filteredCount', 0))
|
||||
pagecount = (total + limit - 1) // limit if total > 0 else 1
|
||||
|
||||
return {
|
||||
'list': videos,
|
||||
'page': pg_str,
|
||||
'pagecount': str(pagecount),
|
||||
'limit': str(limit),
|
||||
'total': str(total),
|
||||
}
|
||||
except Exception as e:
|
||||
self.log(f'获取分类内容失败: {e}')
|
||||
return {
|
||||
'list': [],
|
||||
'page': str(pg),
|
||||
'pagecount': '1',
|
||||
'limit': '60',
|
||||
'total': '0',
|
||||
}
|
||||
|
||||
def detailContent(self, array):
|
||||
username = array[0]
|
||||
|
||||
try:
|
||||
path = f'/api/front/v2/models/username/{username}/cam'
|
||||
# 使用轮询切域名获取详情数据
|
||||
rsp = self._request_with_failover(path)
|
||||
|
||||
info = rsp.get('cam', {})
|
||||
user = rsp.get('user', {}).get('user', {})
|
||||
uid, isLive = str(user.get('id', '')), user.get('isLive', False)
|
||||
|
||||
oldName = self.stripchat_play.rsplit(' ', 1)[-1]
|
||||
if username != oldName:
|
||||
timestp = int(time.time())
|
||||
self.stripchat_play = f'0 {timestp} {username}'
|
||||
flag = self.country_code_to_flag(str(user.get('country', '')).strip())
|
||||
|
||||
remark = '🔴 直播中' if isLive else '⚫ 已下播'
|
||||
show = info.get('show') or info.get('groupShowAnnouncement')
|
||||
if show:
|
||||
startAt = show.get('createdAt') or show.get('startAt')
|
||||
if startAt:
|
||||
remark = (
|
||||
f"🎫 购票表演始于 {self.datetime_utc8(startAt, '%m月%d日 %H:%M')}"
|
||||
)
|
||||
|
||||
director = f'{flag}{username}'
|
||||
desc = self.get_danmaku_desc(uid)
|
||||
|
||||
vod_play_from = '高清线路$$$标清线路二$$$标清线路三'
|
||||
vod_play_url = (
|
||||
f'主线路${uid}$$$备用线路$lemon_{uid}$$$备用线路三$sacf_{uid}'
|
||||
)
|
||||
|
||||
return {
|
||||
'list': [{
|
||||
'vod_id': username,
|
||||
'vod_name': str(info.get('topic', ''))[:80],
|
||||
'vod_pic': str(user.get('avatarUrl', '')),
|
||||
'vod_director': director,
|
||||
'vod_content': desc,
|
||||
'vod_remarks': remark,
|
||||
'vod_play_from': vod_play_from,
|
||||
'vod_play_url': vod_play_url,
|
||||
}]
|
||||
}
|
||||
except Exception as e:
|
||||
self.log(f'获取详情失败: {e}')
|
||||
return {'list': []}
|
||||
|
||||
def searchContent(self, key, quick, pg='1'):
|
||||
if int(pg) > 1:
|
||||
return {}
|
||||
return {
|
||||
'list': [
|
||||
{
|
||||
'vod_id': f'search {t["type_id"]} {key}',
|
||||
'vod_name': t['type_name'],
|
||||
'vod_tag': 'folder',
|
||||
}
|
||||
for t in self.homeContent(False).get('class', [])
|
||||
]
|
||||
}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
urls = []
|
||||
try:
|
||||
sid = id.split('_')[-1]
|
||||
self.start_danmu(sid)
|
||||
|
||||
# 统一使用动态 self.host 配置 Origin 和 Referer
|
||||
headers = {
|
||||
'User-Agent': self.headers.get('User-Agent'),
|
||||
'Origin': self.host,
|
||||
'Referer': f'{self.host}/',
|
||||
}
|
||||
|
||||
# --- 线路2: stripchat.global ---
|
||||
if id.startswith('lemon'):
|
||||
rsp = self.session_get(
|
||||
f'https://edge-hls.growcdnssedge.com/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency'
|
||||
).text
|
||||
lines = rsp.strip().split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
qn_start = line.find('NAME="') + 6
|
||||
qn = line[qn_start : line.find('"', qn_start)]
|
||||
url = lines[i + 1]
|
||||
urls.extend([qn, url])
|
||||
|
||||
# --- 线路3: StripOl ---
|
||||
elif id.startswith('sacf'):
|
||||
rsp = self.session_get(
|
||||
f'https://edge-hls.sacfedge.com/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency'
|
||||
).text
|
||||
lines = rsp.strip().split('\n')
|
||||
psch, pkey = 'v2', self.stripchat_pkey
|
||||
for i, line in enumerate(lines):
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
qn_start = line.find('NAME="') + 6
|
||||
qn = line[qn_start : line.find('"', qn_start)]
|
||||
full_url = f'{lines[i+1]}&psch={psch}&pkey={pkey}&preferredVideoCodec={self.stripchat_preferredVideoCodec}'
|
||||
urls.extend([qn, f'{self.getProxyUrl()}&url={quote(full_url)}'])
|
||||
|
||||
# --- 线路1: StripChat (主线路) ---
|
||||
else:
|
||||
rsp = self.session_get(
|
||||
f'https://edge-hls.{self.Doppiocdn}/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency'
|
||||
).text
|
||||
lines = rsp.strip().split('\n')
|
||||
psch, pkey = 'v2', self.stripchat_pkey
|
||||
for i, line in enumerate(lines):
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
qn_start = line.find('NAME="') + 6
|
||||
qn = line[qn_start : line.find('"', qn_start)]
|
||||
full_url = f'{lines[i+1]}&psch={psch}&pkey={pkey}&preferredVideoCodec={self.stripchat_preferredVideoCodec}'
|
||||
urls.extend([qn, f'{self.getProxyUrl()}&url={quote(full_url)}'])
|
||||
|
||||
return {'url': urls, 'parse': '0', 'position': '0', 'header': headers}
|
||||
except Exception as e:
|
||||
self.log(f'播放失败 {id}: {e}')
|
||||
return {'url': urls, 'parse': 0}
|
||||
|
||||
def update_vod(self, username):
|
||||
try:
|
||||
content_data = self.detailContent([username]).get('list')[0]
|
||||
payload = {'json': json.dumps(content_data, ensure_ascii=False)}
|
||||
self.post('http://127.0.0.1:9978/action?do=refresh&type=vod', data=payload)
|
||||
except Exception as e:
|
||||
self.log(f'刷新详情失败: {e}')
|
||||
|
||||
def localProxy(self, param):
|
||||
url, type = param['url'], param.get('type', '')
|
||||
if type == 'media':
|
||||
data = self.session_get(url, timeout=(5, 15))
|
||||
return [200, 'video/mp4', data.content]#更改这个可以让羊壳正常播放video/mp4和application/octet-stream
|
||||
rsp = self.session_get(url)
|
||||
oldCode, oldtmp, username = self.stripchat_play.rsplit(' ')
|
||||
timestp = int(time.time())
|
||||
is_time_up = (timestp - 10) > int(oldtmp)
|
||||
is_code_changed = int(oldCode) != 0 and rsp.status_code != int(oldCode)
|
||||
if is_time_up or is_code_changed:
|
||||
self.stripchat_play = f'{rsp.status_code} {timestp} {username}'
|
||||
self.log('计划更新')
|
||||
self.update_vod(username)
|
||||
if is_code_changed:
|
||||
self.log('code变更')
|
||||
self.post('http://127.0.0.1:9978/action?do=refresh&type=player')
|
||||
return [404, 'text/plain', '']
|
||||
if rsp.status_code == 403:
|
||||
rsp = self.session_get(
|
||||
re.sub(r'(_\d+p\d*)?\.m3u8', '_160p_blurred.m3u8', url)
|
||||
)
|
||||
if rsp.status_code != 200:
|
||||
return [404, 'text/plain', '']
|
||||
data = (
|
||||
self.process_m3u8(rsp.text)
|
||||
if '#EXT-X-MOUFLON:URI:' in rsp.text
|
||||
else rsp.text
|
||||
)
|
||||
return [200, 'application/vnd.apple.mpegur', data]
|
||||
|
||||
URL_PATTERN = re.compile(
|
||||
r'https://media-hls\.doppiocdn\.\w+/b-hls-\d+/media\.mp4'
|
||||
)
|
||||
MAP_URI_PATTERN = re.compile(r'URI=["\']?(https?://[^\s"\'<>]+)["\']?')
|
||||
MOUFLON_TAIL_PATTERN = re.compile(r'(_part\d+)?\.mp4$')
|
||||
|
||||
def process_m3u8(self, content):
|
||||
lines = content.strip().split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith('#EXT-X-MOUFLON:URI:') and 'media.mp4' in lines[i + 1]:
|
||||
mouflon = line.split(':', 2)[2].strip()
|
||||
encrypted = self.MOUFLON_TAIL_PATTERN.sub('', mouflon).rsplit('_', 2)[1]
|
||||
new_url = mouflon.replace(
|
||||
encrypted, self._decode(encrypted[::-1], self.stripchat_key)
|
||||
)
|
||||
proxy_url = f'{self.getProxyUrl()}&type=media&url={quote(new_url)}'
|
||||
lines[i + 1] = self.URL_PATTERN.sub(proxy_url, lines[i + 1])
|
||||
elif line.startswith('#EXT-X-MAP:URI'):
|
||||
match = self.MAP_URI_PATTERN.search(line)
|
||||
if match:
|
||||
original_url = match.group(1)
|
||||
proxy_url = (
|
||||
f'{self.getProxyUrl()}&type=media&url={quote(original_url)}'
|
||||
)
|
||||
lines[i] = line.replace(original_url, proxy_url)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def country_code_to_flag(self, code):
|
||||
return (
|
||||
''.join(
|
||||
chr(ord(c.upper()) - ord('A') + 0x1F1E6)
|
||||
for c in code
|
||||
if len(code) == 2 and code.isalpha()
|
||||
)
|
||||
if len(code) == 2 and code.isalpha()
|
||||
else code
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=20)
|
||||
def _decode(encrypted_b64: str, key_b64: str) -> str:
|
||||
encrypted_b64 += '=' * (4 - len(encrypted_b64) % 4)
|
||||
key_bytes = base64.b64decode(key_b64)
|
||||
encrypted = base64.b64decode(encrypted_b64)
|
||||
decrypted = bytearray(len(encrypted))
|
||||
for i in range(len(encrypted)):
|
||||
decrypted[i] = encrypted[i] ^ (key_bytes[i % len(key_bytes)] & 0xFF)
|
||||
return decrypted.decode('utf-8')
|
||||
|
||||
def create_session_with_retry(self):
|
||||
self.session = requests.Session()
|
||||
retry = Retry(
|
||||
total=2,
|
||||
backoff_factor=0.2,
|
||||
status_forcelist=[408, 429, 500, 502, 503, 504],
|
||||
raise_on_status=False,
|
||||
)
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
max_retries=retry, pool_connections=20, pool_maxsize=50, pool_block=False
|
||||
)
|
||||
self.session.mount('http://', adapter)
|
||||
self.session.mount('https://', adapter)
|
||||
|
||||
def session_get(self, url, headers=None, stream=False, timeout=(3, 5)):
|
||||
return self.session.get(
|
||||
url,
|
||||
headers=self.headers if headers is None else headers,
|
||||
timeout=timeout,
|
||||
stream=stream,
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
def start_danmu(self, room_id):
|
||||
try:
|
||||
entry = self.danmu_threads.get(room_id)
|
||||
if entry:
|
||||
t, _ = entry
|
||||
if t.is_alive():
|
||||
self.log(f'弹幕线程已存在: {room_id}')
|
||||
return
|
||||
for rid, (ot, stop_evt) in list(self.danmu_threads.items()):
|
||||
if rid == room_id:
|
||||
continue
|
||||
if ot.is_alive():
|
||||
self.log(f'正在关闭其他房间弹幕线程: {rid}')
|
||||
stop_evt.set()
|
||||
ot.join(timeout=1.0)
|
||||
del self.danmu_threads[rid]
|
||||
stop_event = threading.Event()
|
||||
t = threading.Thread(
|
||||
target=self._danmu_poll_worker,
|
||||
args=(room_id, stop_event),
|
||||
daemon=True,
|
||||
)
|
||||
self.danmu_threads[room_id] = (t, stop_event)
|
||||
t.start()
|
||||
self.log(f'弹幕线程启动: {room_id}')
|
||||
except Exception as e:
|
||||
self.log(f'弹幕线程启动失败: {e}')
|
||||
|
||||
def _danmu_poll_worker(self, room_id, stop_event):
|
||||
while True:
|
||||
if self.base_url:
|
||||
try:
|
||||
r = self.fetch(f'{self.base_url}/media', timeout=1).json()
|
||||
if not r.get('state', False):
|
||||
stop_event.set()
|
||||
except:
|
||||
stop_event.set()
|
||||
if stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
self.fetch_chat_once(room_id)
|
||||
if stop_event.wait(5):
|
||||
break
|
||||
except Exception as e:
|
||||
self.log(f'弹幕轮询异常 {room_id}: {e}')
|
||||
if stop_event.wait(10):
|
||||
break
|
||||
self.log(f'弹幕线程结束: {room_id}')
|
||||
|
||||
def fetch_chat_once(self, room_id):
|
||||
path = f'/api/front/v2/models/{room_id}/chat?source=regular&uniq={int(time.time()*1000)}'
|
||||
data = self._request_with_failover(path)
|
||||
|
||||
arr = data.get('messages') if isinstance(data, dict) else []
|
||||
if not isinstance(arr, list):
|
||||
return 0
|
||||
newId, newMsg = 0, []
|
||||
with self.danmu_lock:
|
||||
cache = self.danmu_cache.get(room_id, {})
|
||||
oldId = cache.get('id', 0)
|
||||
cacheMsg = cache.get('msg', [])
|
||||
for raw in reversed(arr):
|
||||
id = int(raw.get('id', 0))
|
||||
if oldId and id <= oldId:
|
||||
break
|
||||
if not newId:
|
||||
newId = id
|
||||
item = self.normalize_chat_message(raw)
|
||||
if not item:
|
||||
continue
|
||||
newMsg.append(item)
|
||||
if newId:
|
||||
if newMsg:
|
||||
cacheMsg = newMsg + cacheMsg
|
||||
cacheMsg = cacheMsg[:30]
|
||||
self.danmu_cache[room_id] = {'id': newId, 'msg': cacheMsg}
|
||||
if oldId:
|
||||
for m in reversed(newMsg):
|
||||
self.send_live_danmaku(m)
|
||||
time.sleep(0.15)
|
||||
|
||||
def replace_emoji(self, text: str) -> str:
|
||||
emoji_map = {
|
||||
':heart:': '❤️',
|
||||
':dancing:': '💃',
|
||||
':thumbsup:': '👍',
|
||||
':flower:': '🌹',
|
||||
':lol:': '😄',
|
||||
':flirt:': '😉',
|
||||
':devil:': '😈',
|
||||
':hideeyes:': '🙈',
|
||||
':ask:': '❓',
|
||||
':inlove:': '😍',
|
||||
':tongue:': '😛',
|
||||
':cry:': '😭',
|
||||
':fire:': '🔥',
|
||||
':asking:': '🤔',
|
||||
':wink:': '😉',
|
||||
':ok:': '👌',
|
||||
':shy:': '😳',
|
||||
':angry:': '😡',
|
||||
':facepalm:': '🤦♂️',
|
||||
':ass:': '🍑',
|
||||
}
|
||||
for code, emoji_char in emoji_map.items():
|
||||
text = text.replace(code, emoji_char)
|
||||
return text
|
||||
|
||||
def normalize_chat_message(self, msg):
|
||||
try:
|
||||
if not isinstance(msg, dict):
|
||||
return None
|
||||
details = msg.get('details') or {}
|
||||
text = (
|
||||
msg.get('text')
|
||||
or msg.get('message')
|
||||
or msg.get('content')
|
||||
or msg.get('body')
|
||||
or ''
|
||||
)
|
||||
if not text and isinstance(details, dict):
|
||||
text = (
|
||||
details.get('body')
|
||||
or details.get('message')
|
||||
or details.get('text')
|
||||
or ''
|
||||
)
|
||||
if isinstance(text, dict):
|
||||
text = text.get('text') or text.get('body') or ''
|
||||
tp = msg.get('type') or ''
|
||||
if not text and tp == 'tip':
|
||||
amount = (
|
||||
details.get('amount') or details.get('tokens') or ''
|
||||
if isinstance(details, dict)
|
||||
else ''
|
||||
)
|
||||
text = f'打赏 {amount} tk' if amount else '打赏'
|
||||
if not text and tp == 'lovense':
|
||||
text = 'Lovense互动'
|
||||
ud = msg.get('userData') or msg.get('user') or msg.get('sender') or {}
|
||||
user = ''
|
||||
if isinstance(ud, dict):
|
||||
user = ud.get('username') or ud.get('name') or ud.get('login') or ''
|
||||
elif isinstance(ud, str):
|
||||
user = ud
|
||||
if not user:
|
||||
user = msg.get('username') or msg.get('userName') or ''
|
||||
text, user = str(text).strip(), str(user).strip()
|
||||
if not text:
|
||||
return None
|
||||
return {
|
||||
'time': msg.get('createdAt'),
|
||||
'user': user[:32],
|
||||
'text': self.replace_emoji(text)[:120],
|
||||
}
|
||||
except Exception as e:
|
||||
self.log(f'弹幕解析失败: {e}')
|
||||
return None
|
||||
|
||||
def send_live_danmaku(self, item):
|
||||
try:
|
||||
text = str(item.get('text', '')).strip()
|
||||
user = str(item.get('user', '')).strip()
|
||||
show = (f'{user}: {text}' if user else text)[:80]
|
||||
ok = self.call_local_action(
|
||||
f'do=danmaku&text={quote(show)}', f'实时弹幕发送: {show}'
|
||||
)
|
||||
if not ok:
|
||||
self.log('实时弹幕 action 未确认')
|
||||
except Exception as e:
|
||||
self.log(f'实时弹幕发送失败: {e}')
|
||||
|
||||
def get_danmaku_desc(self, room_id):
|
||||
cache = self.danmu_cache.get(room_id, {})
|
||||
cacheMsg = cache.get('msg', [])
|
||||
msg = []
|
||||
for item in cacheMsg:
|
||||
t = self.datetime_utc8(item.get('time'), '%H:%M')
|
||||
text = str(item.get('text', '')).strip()
|
||||
user = str(item.get('user', '')).strip()
|
||||
show = f'{t} {user}: {text}' if user else f'{t} {text}'
|
||||
msg.append(show)
|
||||
return '\n'.join(msg)
|
||||
|
||||
def get_action_bases(self):
|
||||
bases = []
|
||||
try:
|
||||
p = urlparse(self.getProxyUrl())
|
||||
if p.scheme and p.netloc:
|
||||
bases.append(f'{p.scheme}://{p.netloc}')
|
||||
except Exception:
|
||||
pass
|
||||
for b in ['http://127.0.0.1:9978', 'http://127.0.0.1:9979']:
|
||||
if b not in bases:
|
||||
bases.append(b)
|
||||
return bases
|
||||
|
||||
base_url = ''
|
||||
|
||||
def call_local_action(self, query, log_name):
|
||||
for base in [self.base_url] if self.base_url else self.get_action_bases():
|
||||
try:
|
||||
url = f'{base}/action?{query}'
|
||||
r = self.fetch(url, timeout=1)
|
||||
if r.text.strip() == 'OK':
|
||||
self.base_url = base
|
||||
self.log(log_name)
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
self.log(f'失败: {log_name}')
|
||||
return False
|
||||
@@ -1,132 +1,753 @@
|
||||
# coding=utf-8
|
||||
# !/usr/bin/python
|
||||
#!/usr/bin/python
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import requests
|
||||
sys.path.append('..')
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from base.spider import Spider
|
||||
|
||||
sys.path.append("..")
|
||||
|
||||
|
||||
class Spider(Spider):
|
||||
|
||||
def init(self, extend="{}"):
|
||||
self.host='https://zh.stripol.com/'
|
||||
self.headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:142.0) Gecko/20100101 Firefox/142.0'
|
||||
}
|
||||
def init(self, extend="{}"):
|
||||
# 1. 创建 Session
|
||||
self.create_session_with_retry()
|
||||
|
||||
def getName(self):
|
||||
pass
|
||||
# 2. 备用域名列表
|
||||
self.dynamic_urls = [
|
||||
" https://zh.stripol.com/",
|
||||
"https://zh.pikpedcams.com/",
|
||||
"https://zh.virtualtaboo.live/",
|
||||
]
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
# 3. 基础 Header 及属性初始化
|
||||
self.Doppiocdn = "doppiocdn.org"
|
||||
user_agent = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101"
|
||||
" Firefox/153.0"
|
||||
)
|
||||
self.headers = {
|
||||
"User-Agent": user_agent,
|
||||
"Accept-Language": "zh,en;q=0.5",
|
||||
}
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
# 默认选第一个,后续请求会轮询切换
|
||||
self.host = self.dynamic_urls[0]
|
||||
self._update_headers_for_host(self.host)
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
# 4. 其他配置及弹幕锁
|
||||
self.stripchat_preferredVideoCodec = "H264" # 可选H264或AV1
|
||||
self.stripchat_key = "YzWScuyQRGAGcxx1KIJmiQ7BY9Vi35ftwLqUOVO8uoo="
|
||||
self.stripchat_pkey = "Fq6m2TO2ZeBkRPm9"
|
||||
self.stripchat_play = "0 0"
|
||||
self.danmu_cache = {}
|
||||
self.danmu_threads = {}
|
||||
self.danmu_lock = threading.Lock()
|
||||
|
||||
def homeContent(self, filter):
|
||||
result = {}
|
||||
classes = [{'type_name': '女主播', 'type_id': 'girls'}, {'type_name': '情侣', 'type_id': 'couples'}, {'type_name': '男主播', 'type_id': 'men'}, {'type_name': '跨性别', 'type_id': 'trans'}]
|
||||
filters = {}
|
||||
value = [{'n': '中国', 'v': 'tagLanguageChinese'}, {'n': '亚洲', 'v': 'ethnicityAsian'}, {'n': '白人', 'v': 'ethnicityWhite'}, {'n': '拉丁', 'v': 'ethnicityLatino'}, {'n': '混血', 'v': 'ethnicityMultiracial'}, {'n': '印度', 'v': 'ethnicityIndian'}, {'n': '阿拉伯', 'v': 'ethnicityMiddleEastern'}, {'n': '黑人', 'v': 'ethnicityEbony'}]
|
||||
value_gay = [{'n': '情侣', 'v': 'sexGayCouples'}, {'n': '直男', 'v': 'orientationStraight'}]
|
||||
for tid in ['girls', 'couples', 'men', 'trans']:
|
||||
c_value = value[:]
|
||||
if tid == 'men':
|
||||
c_value += value_gay
|
||||
filters[tid] = [{'key': 'tag', 'value': c_value}]
|
||||
result['class'] = classes
|
||||
result['filters'] = filters
|
||||
return result
|
||||
def _update_headers_for_host(self, host_url):
|
||||
"""根据当前使用的 Host 刷新请求头"""
|
||||
self.host = host_url
|
||||
self.headers["Origin"] = host_url
|
||||
self.headers["Referer"] = f"{host_url}/"
|
||||
self.json_headers = {
|
||||
**self.headers,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
|
||||
def homeVideoContent(self):
|
||||
pass
|
||||
def _request_with_failover(self, path, timeout=(3, 5)):
|
||||
"""核心逻辑:逐个域名尝试请求列表/详情,直到成功为止"""
|
||||
# 优先使用上一次成功的域名,若不在第一位则将其调整到前面
|
||||
urls_to_try = list(self.dynamic_urls)
|
||||
if self.host in urls_to_try:
|
||||
urls_to_try.remove(self.host)
|
||||
urls_to_try.insert(0, self.host)
|
||||
|
||||
last_error = None
|
||||
|
||||
for domain in urls_to_try:
|
||||
clean_domain = domain.strip().rstrip('/')
|
||||
full_url = (
|
||||
f'{clean_domain}{path}' if path.startswith('/') else f'/{path}'
|
||||
)
|
||||
|
||||
# 临时构造对应域名的 Header
|
||||
headers = {
|
||||
**self.headers,
|
||||
'Origin': clean_domain,
|
||||
'Referer': f'{clean_domain}/',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.session.get(full_url, headers=headers, timeout=timeout)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# 校验是否拿到了正确的 json 数据
|
||||
if isinstance(data, dict):
|
||||
# 成功!更新全局 Host 状态
|
||||
if self.host != clean_domain:
|
||||
self.log(f'[HOST] 切换可用域名为: {clean_domain}')
|
||||
self._update_headers_for_host(clean_domain)
|
||||
return data
|
||||
else:
|
||||
self.log(
|
||||
f'[HOST] 域名 {clean_domain} 请求失败,状态码: {response.status_code},尝试下一个...'
|
||||
)
|
||||
except Exception as e:
|
||||
self.log(f'[HOST] 域名 {clean_domain} 访问异常: {e},尝试下一个...')
|
||||
last_error = e
|
||||
continue
|
||||
|
||||
self.log(f'[HOST] 所有域名均访问失败! 最后一次错误: {last_error}')
|
||||
return {}
|
||||
|
||||
def getName(self):
|
||||
return 'StripChat'
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
def homeVideoContent(self):
|
||||
pass
|
||||
|
||||
def datetime_utc8(self, strTime, outFormat):
|
||||
return (
|
||||
datetime.strptime(strTime, '%Y-%m-%dT%H:%M:%SZ') + timedelta(hours=8)
|
||||
).strftime(outFormat)
|
||||
|
||||
def homeContent(self, filter):
|
||||
CLASSES = [
|
||||
{'type_name': '女主播', 'type_id': 'girls'},
|
||||
{'type_name': '情侣', 'type_id': 'couples'},
|
||||
{'type_name': '男主播', 'type_id': 'men'},
|
||||
{'type_name': '跨性别', 'type_id': 'trans'},
|
||||
]
|
||||
VALUE = [
|
||||
{'n': '新主播', 'v': 'autoTagNew'},
|
||||
{'n': '推荐', 'v': 'recommended'},
|
||||
{'v': 'fuckMachine', 'n': '炮机'},
|
||||
{'n': '青年', 'v': 'ageTeen'},
|
||||
{'n': 'VR', 'v': 'autoTagVr'},
|
||||
{'n': '亚洲人', 'v': 'ethnicityAsian'},
|
||||
{'n': '🇨🇳中国', 'v': 'tagLanguageChinese'},
|
||||
{'n': '🇯🇵日本', 'v': 'tagLanguageJapanese'},
|
||||
{'n': '🇰🇷韩国', 'v': 'tagLanguageKorean'},
|
||||
{'n': '🇻🇳越南', 'v': 'tagLanguageVietnamese'},
|
||||
{'v': 'tagLanguageUkrainian', 'n': '🇺🇦乌克兰'},
|
||||
{'v': 'tagLanguageRussianSpeaking', 'n': '🇷🇺俄罗斯'},
|
||||
{'v': 'tagLanguageUSModels', 'n': '🇺🇸美国'},
|
||||
{'v': 'tagLanguageColombian', 'n': '🇨🇴哥伦比亚'},
|
||||
{'v': 'tagLanguageGermanSpeaking', 'n': '🇩🇪德国'},
|
||||
{'v': 'tagLanguageFrench', 'n': '🇫🇷法国'},
|
||||
{'v': 'tagLanguageUKModels', 'n': '🇬🇧英国'},
|
||||
{'v': 'tagLanguageCanadian', 'n': '🇨🇦加拿大'},
|
||||
{'v': 'tagLanguageMexican', 'n': '🇲🇽墨西哥'},
|
||||
{'v': 'ethnicityIndian', 'n': '🇮🇳印度'},
|
||||
{'v': 'tagLanguageVenezuelan', 'n': '🇻🇪委内瑞拉'},
|
||||
{'v': 'tagLanguageRomanian', 'n': '🇷🇴罗马尼亚'},
|
||||
{'v': 'tagLanguageAfrican', 'n': '🌍非洲'},
|
||||
{'v': 'tagLanguageSpanishSpeaking', 'n': '🇪🇸西班牙'},
|
||||
{'v': 'ethnicityMiddleEastern', 'n': '🇸🇦🇦🇪阿拉伯'},
|
||||
{'v': 'tagLanguageKenyan', 'n': '🇰🇪肯尼亚'},
|
||||
{'v': 'tagLanguageSouthAfrican', 'n': '🇿🇦南非'},
|
||||
{'v': 'tagLanguageBrazilian', 'n': '🇧🇷巴西'},
|
||||
{'v': 'tagLanguageThai', 'n': '🇹🇭泰国'},
|
||||
{'v': 'tagLanguageItalian', 'n': '🇮🇹意大利'},
|
||||
{'n': '亚洲', 'v': 'ethnicityAsian'},
|
||||
{'n': '白人', 'v': 'ethnicityWhite'},
|
||||
{'n': '拉丁', 'v': 'ethnicityLatino'},
|
||||
{'n': '混血', 'v': 'ethnicityMultiracial'},
|
||||
{'n': '印度', 'v': 'ethnicityIndian'},
|
||||
{'n': '阿拉伯', 'v': 'ethnicityMiddleEastern'},
|
||||
{'n': '黑人', 'v': 'ethnicityEbony'},
|
||||
{'n': '✨新主播', 'v': 'autoTagNew'},
|
||||
{'n': 'VR直播', 'v': 'autoTagVr'},
|
||||
{'n': '18+', 'v': 'ageTeen'},
|
||||
{'n': '鲜嫩青年22+', 'v': 'ageYoung'},
|
||||
{'n': '学生', 'v': 'subcultureStudent'},
|
||||
{'n': '口交', 'v': 'doBlowjob'},
|
||||
{'n': '深喉', 'v': 'doDeepThroat'},
|
||||
{'n': '恋足', 'v': 'doFootFetish'},
|
||||
{'n': '互动玩具', 'v': 'autoTagInteractiveToy'},
|
||||
{'n': '自慰', 'v': 'doMasturbation'},
|
||||
{'n': '肛交', 'v': 'doAnal'},
|
||||
{'n': '潮吹', 'v': 'doSquirt'},
|
||||
{'n': '狗式', 'v': 'doDoggyStyle'},
|
||||
{'n': 'Cosplay', 'v': 'doCosplay'},
|
||||
{'n': 'RolePlay', 'v': 'doRolePlay'},
|
||||
]
|
||||
VALUE_MEN = [
|
||||
{'n': '情侣', 'v': 'sexGayCouples'},
|
||||
{'n': '直男', 'v': 'orientationStraight'},
|
||||
]
|
||||
TIDS = ('girls', 'couples', 'men', 'trans')
|
||||
filters = {
|
||||
tid: [{'key': 'tag', 'value': VALUE_MEN + VALUE if tid == 'men' else VALUE}]
|
||||
for tid in TIDS
|
||||
}
|
||||
return {'class': CLASSES, 'filters': filters}
|
||||
|
||||
def _parse_status_remark(self, is_live, status, viewers=0):
|
||||
if not is_live or status == 'off':
|
||||
status_text = '⚫ 已下播'
|
||||
elif status == 'public':
|
||||
status_text = '🔴 直播中'
|
||||
else:
|
||||
status_text = '收费房'
|
||||
|
||||
return f'👤 {viewers}人 | {status_text}' if viewers else status_text
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
try:
|
||||
pg_str = str(pg)
|
||||
page_num = int(pg_str)
|
||||
|
||||
# 1. 搜索场景逻辑
|
||||
if tid.startswith('search '):
|
||||
_, tag, key = tid.split(maxsplit=2)
|
||||
path = f'/api/front/v4/models/search/group/username?query={key}&limit=900&primaryTag={tag}'
|
||||
rsp = self._request_with_failover(path)
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
limit = 60
|
||||
offset = limit * (int(pg) - 1)
|
||||
domain = f"{self.host}api/front/models?improveTs=false&removeShows=false&limit={limit}&offset={offset}&primaryTag={tid}&sortBy=viewersRating&rcmGrp=A&rbCnGr=true&prxCnGr=false&nic=false"
|
||||
if 'tag' in extend:
|
||||
domain += "&filterGroupTags=%5B%5B%22" + extend['tag'] + "%22%5D%5D"
|
||||
rsp = requests.get(domain, headers=self.headers).json()
|
||||
vodList = rsp['models']
|
||||
videos = []
|
||||
for vod in vodList:
|
||||
id = str(vod['id'])
|
||||
title = str(vod['username']).strip()
|
||||
stamp = vod['snapshotTimestamp']
|
||||
videos.append({
|
||||
"vod_id": title,
|
||||
"vod_name": title,
|
||||
"vod_pic": f"https://img.doppiocdn.net/thumbs/{stamp}/{id}",
|
||||
"vod_remarks": "购票表演中" if vod['groupShowType'] else ""
|
||||
})
|
||||
total = int(rsp['filteredCount'])
|
||||
result = {}
|
||||
result['list'] = videos
|
||||
result['page'] = pg
|
||||
result['pagecount'] = (total + limit - 1) // limit
|
||||
result['limit'] = limit
|
||||
result['total'] = total
|
||||
return result
|
||||
for u in rsp.get('models', []):
|
||||
if not u.get('isLive'):
|
||||
continue
|
||||
viewers = u.get('viewersCount', 0)
|
||||
is_live = u.get('isLive', False)
|
||||
status = u.get('status', 'off')
|
||||
|
||||
def detailContent(self, array):
|
||||
username = array[0]
|
||||
domain = f"{self.host}api/front/v2/models/username/{username}/cam"
|
||||
rsp = requests.get(domain, headers=self.headers).json()
|
||||
info = rsp['cam']
|
||||
user = rsp['user']['user']
|
||||
id = str(user['id'])
|
||||
vod = {
|
||||
"vod_id": id,
|
||||
"vod_name": str(info['topic']).strip(),
|
||||
"vod_pic": str(user['avatarUrl']),
|
||||
"vod_director": username,
|
||||
"vod_area": str(user['country']),
|
||||
'vod_play_from': '直播线路',
|
||||
'vod_play_url': f"{id}${id}"
|
||||
remark = self._parse_status_remark(is_live, status, viewers)
|
||||
videos.append({
|
||||
'vod_id': str(u['username']),
|
||||
'vod_name': (
|
||||
f"{self.country_code_to_flag(str(u.get('country', '')))}{u['username']}"
|
||||
),
|
||||
'vod_pic': (
|
||||
f"https://img.{self.Doppiocdn}/snapshot/{u['id']}/{u.get('snapshotTimestamp', '')}"
|
||||
),
|
||||
'style': {'type': 'rect', 'ratio': 1.78},
|
||||
'vod_remarks': remark,
|
||||
})
|
||||
|
||||
return {
|
||||
'list': videos,
|
||||
'page': pg_str,
|
||||
'pagecount': '1',
|
||||
'limit': '900',
|
||||
'total': str(len(videos)),
|
||||
}
|
||||
result = {
|
||||
'list': [
|
||||
vod
|
||||
]
|
||||
}
|
||||
return result
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
pass
|
||||
# 2. 普通分类列表场景逻辑 (已移除 host_card 保证追加)
|
||||
limit = 60
|
||||
offset = limit * (page_num - 1)
|
||||
path = f'/api/front/models?improveTs=false&removeShows=false&limit={limit}&offset={offset}&primaryTag={tid}&sortBy=stripRanking&rcmGrp=A&rbCnGr=true&prxCnGr=false&nic=false'
|
||||
if 'tag' in extend and extend['tag']:
|
||||
path += f'&filterGroupTags=[["{extend["tag"]}"]]'
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
domain = f"https://edge-hls.growcdnssedge.com/hls/{id}/master/{id}_auto.m3u8?playlistType=lowLatency"
|
||||
rsp = requests.get(domain, headers=self.headers).text
|
||||
# 使用轮询切域名获取列表数据
|
||||
rsp = self._request_with_failover(path)
|
||||
|
||||
videos = []
|
||||
for v in rsp.get('models', []):
|
||||
is_live = v.get('isLive', False)
|
||||
status = v.get('status', 'public')
|
||||
viewers = v.get('viewersCount', 0)
|
||||
|
||||
remark = self._parse_status_remark(is_live, status, viewers)
|
||||
|
||||
videos.append({
|
||||
'vod_id': str(v['username']),
|
||||
'vod_name': (
|
||||
f"{self.country_code_to_flag(str(v.get('country', '')))}{v['username']}"
|
||||
),
|
||||
'vod_pic': (
|
||||
f"https://img.{self.Doppiocdn}/snapshot/{v['id']}/{v.get('snapshotTimestamp', '')}"
|
||||
),
|
||||
'vod_remarks': remark,
|
||||
})
|
||||
|
||||
total = int(rsp.get('filteredCount', 0))
|
||||
pagecount = (total + limit - 1) // limit if total > 0 else 1
|
||||
|
||||
return {
|
||||
'list': videos,
|
||||
'page': pg_str,
|
||||
'pagecount': str(pagecount),
|
||||
'limit': str(limit),
|
||||
'total': str(total),
|
||||
}
|
||||
except Exception as e:
|
||||
self.log(f'获取分类内容失败: {e}')
|
||||
return {
|
||||
'list': [],
|
||||
'page': str(pg),
|
||||
'pagecount': '1',
|
||||
'limit': '60',
|
||||
'total': '0',
|
||||
}
|
||||
|
||||
def detailContent(self, array):
|
||||
username = array[0]
|
||||
|
||||
try:
|
||||
path = f'/api/front/v2/models/username/{username}/cam'
|
||||
# 使用轮询切域名获取详情数据
|
||||
rsp = self._request_with_failover(path)
|
||||
|
||||
info = rsp.get('cam', {})
|
||||
user = rsp.get('user', {}).get('user', {})
|
||||
uid, isLive = str(user.get('id', '')), user.get('isLive', False)
|
||||
|
||||
oldName = self.stripchat_play.rsplit(' ', 1)[-1]
|
||||
if username != oldName:
|
||||
timestp = int(time.time())
|
||||
self.stripchat_play = f'0 {timestp} {username}'
|
||||
flag = self.country_code_to_flag(str(user.get('country', '')).strip())
|
||||
|
||||
remark = '🔴 直播中' if isLive else '⚫ 已下播'
|
||||
show = info.get('show') or info.get('groupShowAnnouncement')
|
||||
if show:
|
||||
startAt = show.get('createdAt') or show.get('startAt')
|
||||
if startAt:
|
||||
remark = (
|
||||
f"🎫 购票表演始于 {self.datetime_utc8(startAt, '%m月%d日 %H:%M')}"
|
||||
)
|
||||
|
||||
director = f'{flag}{username}'
|
||||
desc = self.get_danmaku_desc(uid)
|
||||
|
||||
vod_play_from = '高清线路$$$标清线路二$$$标清线路三'
|
||||
vod_play_url = (
|
||||
f'主线路${uid}$$$备用线路$lemon_{uid}$$$备用线路三$sacf_{uid}'
|
||||
)
|
||||
|
||||
return {
|
||||
'list': [{
|
||||
'vod_id': username,
|
||||
'vod_name': str(info.get('topic', ''))[:80],
|
||||
'vod_pic': str(user.get('avatarUrl', '')),
|
||||
'vod_director': director,
|
||||
'vod_content': desc,
|
||||
'vod_remarks': remark,
|
||||
'vod_play_from': vod_play_from,
|
||||
'vod_play_url': vod_play_url,
|
||||
}]
|
||||
}
|
||||
except Exception as e:
|
||||
self.log(f'获取详情失败: {e}')
|
||||
return {'list': []}
|
||||
|
||||
def searchContent(self, key, quick, pg='1'):
|
||||
if int(pg) > 1:
|
||||
return {}
|
||||
return {
|
||||
'list': [
|
||||
{
|
||||
'vod_id': f'search {t["type_id"]} {key}',
|
||||
'vod_name': t['type_name'],
|
||||
'vod_tag': 'folder',
|
||||
}
|
||||
for t in self.homeContent(False).get('class', [])
|
||||
]
|
||||
}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
urls = []
|
||||
try:
|
||||
sid = id.split('_')[-1]
|
||||
self.start_danmu(sid)
|
||||
|
||||
# 统一使用动态 self.host 配置 Origin 和 Referer
|
||||
headers = {
|
||||
'User-Agent': self.headers.get('User-Agent'),
|
||||
'Origin': self.host,
|
||||
'Referer': f'{self.host}/',
|
||||
}
|
||||
|
||||
# --- 线路2: stripchat.global ---
|
||||
if id.startswith('lemon'):
|
||||
rsp = self.session_get(
|
||||
f'https://edge-hls.growcdnssedge.com/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency'
|
||||
).text
|
||||
lines = rsp.strip().split('\n')
|
||||
psch = ''
|
||||
pkey = ''
|
||||
url = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith('#EXT-X-MOUFLON:'):
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 4:
|
||||
psch = parts[2]
|
||||
pkey = parts[3]
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
name_start = line.find('NAME="') + 6
|
||||
name_end = line.find('"', name_start)
|
||||
qn = line[name_start:name_end]
|
||||
# URL在下一行
|
||||
url_base = lines[i + 1]
|
||||
# 组合最终的URL,并加上psch和pkey参数
|
||||
full_url = f"{url_base}&psch={psch}&pkey={pkey}"
|
||||
# 将画质和URL添加到列表中
|
||||
url.append(qn)
|
||||
url.append(full_url)
|
||||
result = {}
|
||||
result["url"] = url
|
||||
result["parse"] = '0'
|
||||
result["contentType"] = ''
|
||||
result["header"] = self.headers
|
||||
return result
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
qn_start = line.find('NAME="') + 6
|
||||
qn = line[qn_start : line.find('"', qn_start)]
|
||||
url = lines[i + 1]
|
||||
urls.extend([qn, url])
|
||||
|
||||
def localProxy(self, param):
|
||||
pass
|
||||
# --- 线路3: StripOl ---
|
||||
elif id.startswith('sacf'):
|
||||
rsp = self.session_get(
|
||||
f'https://edge-hls.sacfedge.com/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency'
|
||||
).text
|
||||
lines = rsp.strip().split('\n')
|
||||
psch, pkey = 'v2', self.stripchat_pkey
|
||||
for i, line in enumerate(lines):
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
qn_start = line.find('NAME="') + 6
|
||||
qn = line[qn_start : line.find('"', qn_start)]
|
||||
full_url = f'{lines[i+1]}&psch={psch}&pkey={pkey}&preferredVideoCodec={self.stripchat_preferredVideoCodec}'
|
||||
urls.extend([qn, f'{self.getProxyUrl()}&url={quote(full_url)}'])
|
||||
|
||||
# --- 线路1: StripChat (主线路) ---
|
||||
else:
|
||||
rsp = self.session_get(
|
||||
f'https://edge-hls.{self.Doppiocdn}/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency'
|
||||
).text
|
||||
lines = rsp.strip().split('\n')
|
||||
psch, pkey = 'v2', self.stripchat_pkey
|
||||
for i, line in enumerate(lines):
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
qn_start = line.find('NAME="') + 6
|
||||
qn = line[qn_start : line.find('"', qn_start)]
|
||||
full_url = f'{lines[i+1]}&psch={psch}&pkey={pkey}&preferredVideoCodec={self.stripchat_preferredVideoCodec}'
|
||||
urls.extend([qn, f'{self.getProxyUrl()}&url={quote(full_url)}'])
|
||||
|
||||
return {'url': urls, 'parse': '0', 'position': '0', 'header': headers}
|
||||
except Exception as e:
|
||||
self.log(f'播放失败 {id}: {e}')
|
||||
return {'url': urls, 'parse': 0}
|
||||
|
||||
def update_vod(self, username):
|
||||
try:
|
||||
content_data = self.detailContent([username]).get('list')[0]
|
||||
payload = {'json': json.dumps(content_data, ensure_ascii=False)}
|
||||
self.post('http://127.0.0.1:9978/action?do=refresh&type=vod', data=payload)
|
||||
except Exception as e:
|
||||
self.log(f'刷新详情失败: {e}')
|
||||
|
||||
def localProxy(self, param):
|
||||
url, type = param['url'], param.get('type', '')
|
||||
if type == 'media':
|
||||
data = self.session_get(url, timeout=(5, 15))
|
||||
return [200, 'video/mp4', data.content]#更改这个可以让羊壳正常播放video/mp4和application/octet-stream
|
||||
rsp = self.session_get(url)
|
||||
oldCode, oldtmp, username = self.stripchat_play.rsplit(' ')
|
||||
timestp = int(time.time())
|
||||
is_time_up = (timestp - 10) > int(oldtmp)
|
||||
is_code_changed = int(oldCode) != 0 and rsp.status_code != int(oldCode)
|
||||
if is_time_up or is_code_changed:
|
||||
self.stripchat_play = f'{rsp.status_code} {timestp} {username}'
|
||||
self.log('计划更新')
|
||||
self.update_vod(username)
|
||||
if is_code_changed:
|
||||
self.log('code变更')
|
||||
self.post('http://127.0.0.1:9978/action?do=refresh&type=player')
|
||||
return [404, 'text/plain', '']
|
||||
if rsp.status_code == 403:
|
||||
rsp = self.session_get(
|
||||
re.sub(r'(_\d+p\d*)?\.m3u8', '_160p_blurred.m3u8', url)
|
||||
)
|
||||
if rsp.status_code != 200:
|
||||
return [404, 'text/plain', '']
|
||||
data = (
|
||||
self.process_m3u8(rsp.text)
|
||||
if '#EXT-X-MOUFLON:URI:' in rsp.text
|
||||
else rsp.text
|
||||
)
|
||||
return [200, 'application/vnd.apple.mpegur', data]
|
||||
|
||||
URL_PATTERN = re.compile(
|
||||
r'https://media-hls\.doppiocdn\.\w+/b-hls-\d+/media\.mp4'
|
||||
)
|
||||
MAP_URI_PATTERN = re.compile(r'URI=["\']?(https?://[^\s"\'<>]+)["\']?')
|
||||
MOUFLON_TAIL_PATTERN = re.compile(r'(_part\d+)?\.mp4$')
|
||||
|
||||
def process_m3u8(self, content):
|
||||
lines = content.strip().split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith('#EXT-X-MOUFLON:URI:') and 'media.mp4' in lines[i + 1]:
|
||||
mouflon = line.split(':', 2)[2].strip()
|
||||
encrypted = self.MOUFLON_TAIL_PATTERN.sub('', mouflon).rsplit('_', 2)[1]
|
||||
new_url = mouflon.replace(
|
||||
encrypted, self._decode(encrypted[::-1], self.stripchat_key)
|
||||
)
|
||||
proxy_url = f'{self.getProxyUrl()}&type=media&url={quote(new_url)}'
|
||||
lines[i + 1] = self.URL_PATTERN.sub(proxy_url, lines[i + 1])
|
||||
elif line.startswith('#EXT-X-MAP:URI'):
|
||||
match = self.MAP_URI_PATTERN.search(line)
|
||||
if match:
|
||||
original_url = match.group(1)
|
||||
proxy_url = (
|
||||
f'{self.getProxyUrl()}&type=media&url={quote(original_url)}'
|
||||
)
|
||||
lines[i] = line.replace(original_url, proxy_url)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def country_code_to_flag(self, code):
|
||||
return (
|
||||
''.join(
|
||||
chr(ord(c.upper()) - ord('A') + 0x1F1E6)
|
||||
for c in code
|
||||
if len(code) == 2 and code.isalpha()
|
||||
)
|
||||
if len(code) == 2 and code.isalpha()
|
||||
else code
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=20)
|
||||
def _decode(encrypted_b64: str, key_b64: str) -> str:
|
||||
encrypted_b64 += '=' * (4 - len(encrypted_b64) % 4)
|
||||
key_bytes = base64.b64decode(key_b64)
|
||||
encrypted = base64.b64decode(encrypted_b64)
|
||||
decrypted = bytearray(len(encrypted))
|
||||
for i in range(len(encrypted)):
|
||||
decrypted[i] = encrypted[i] ^ (key_bytes[i % len(key_bytes)] & 0xFF)
|
||||
return decrypted.decode('utf-8')
|
||||
|
||||
def create_session_with_retry(self):
|
||||
self.session = requests.Session()
|
||||
retry = Retry(
|
||||
total=2,
|
||||
backoff_factor=0.2,
|
||||
status_forcelist=[408, 429, 500, 502, 503, 504],
|
||||
raise_on_status=False,
|
||||
)
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
max_retries=retry, pool_connections=20, pool_maxsize=50, pool_block=False
|
||||
)
|
||||
self.session.mount('http://', adapter)
|
||||
self.session.mount('https://', adapter)
|
||||
|
||||
def session_get(self, url, headers=None, stream=False, timeout=(3, 5)):
|
||||
return self.session.get(
|
||||
url,
|
||||
headers=self.headers if headers is None else headers,
|
||||
timeout=timeout,
|
||||
stream=stream,
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
def start_danmu(self, room_id):
|
||||
try:
|
||||
entry = self.danmu_threads.get(room_id)
|
||||
if entry:
|
||||
t, _ = entry
|
||||
if t.is_alive():
|
||||
self.log(f'弹幕线程已存在: {room_id}')
|
||||
return
|
||||
for rid, (ot, stop_evt) in list(self.danmu_threads.items()):
|
||||
if rid == room_id:
|
||||
continue
|
||||
if ot.is_alive():
|
||||
self.log(f'正在关闭其他房间弹幕线程: {rid}')
|
||||
stop_evt.set()
|
||||
ot.join(timeout=1.0)
|
||||
del self.danmu_threads[rid]
|
||||
stop_event = threading.Event()
|
||||
t = threading.Thread(
|
||||
target=self._danmu_poll_worker,
|
||||
args=(room_id, stop_event),
|
||||
daemon=True,
|
||||
)
|
||||
self.danmu_threads[room_id] = (t, stop_event)
|
||||
t.start()
|
||||
self.log(f'弹幕线程启动: {room_id}')
|
||||
except Exception as e:
|
||||
self.log(f'弹幕线程启动失败: {e}')
|
||||
|
||||
def _danmu_poll_worker(self, room_id, stop_event):
|
||||
while True:
|
||||
if self.base_url:
|
||||
try:
|
||||
r = self.fetch(f'{self.base_url}/media', timeout=1).json()
|
||||
if not r.get('state', False):
|
||||
stop_event.set()
|
||||
except:
|
||||
stop_event.set()
|
||||
if stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
self.fetch_chat_once(room_id)
|
||||
if stop_event.wait(5):
|
||||
break
|
||||
except Exception as e:
|
||||
self.log(f'弹幕轮询异常 {room_id}: {e}')
|
||||
if stop_event.wait(10):
|
||||
break
|
||||
self.log(f'弹幕线程结束: {room_id}')
|
||||
|
||||
def fetch_chat_once(self, room_id):
|
||||
path = f'/api/front/v2/models/{room_id}/chat?source=regular&uniq={int(time.time()*1000)}'
|
||||
data = self._request_with_failover(path)
|
||||
|
||||
arr = data.get('messages') if isinstance(data, dict) else []
|
||||
if not isinstance(arr, list):
|
||||
return 0
|
||||
newId, newMsg = 0, []
|
||||
with self.danmu_lock:
|
||||
cache = self.danmu_cache.get(room_id, {})
|
||||
oldId = cache.get('id', 0)
|
||||
cacheMsg = cache.get('msg', [])
|
||||
for raw in reversed(arr):
|
||||
id = int(raw.get('id', 0))
|
||||
if oldId and id <= oldId:
|
||||
break
|
||||
if not newId:
|
||||
newId = id
|
||||
item = self.normalize_chat_message(raw)
|
||||
if not item:
|
||||
continue
|
||||
newMsg.append(item)
|
||||
if newId:
|
||||
if newMsg:
|
||||
cacheMsg = newMsg + cacheMsg
|
||||
cacheMsg = cacheMsg[:30]
|
||||
self.danmu_cache[room_id] = {'id': newId, 'msg': cacheMsg}
|
||||
if oldId:
|
||||
for m in reversed(newMsg):
|
||||
self.send_live_danmaku(m)
|
||||
time.sleep(0.15)
|
||||
|
||||
def replace_emoji(self, text: str) -> str:
|
||||
emoji_map = {
|
||||
':heart:': '❤️',
|
||||
':dancing:': '💃',
|
||||
':thumbsup:': '👍',
|
||||
':flower:': '🌹',
|
||||
':lol:': '😄',
|
||||
':flirt:': '😉',
|
||||
':devil:': '😈',
|
||||
':hideeyes:': '🙈',
|
||||
':ask:': '❓',
|
||||
':inlove:': '😍',
|
||||
':tongue:': '😛',
|
||||
':cry:': '😭',
|
||||
':fire:': '🔥',
|
||||
':asking:': '🤔',
|
||||
':wink:': '😉',
|
||||
':ok:': '👌',
|
||||
':shy:': '😳',
|
||||
':angry:': '😡',
|
||||
':facepalm:': '🤦♂️',
|
||||
':ass:': '🍑',
|
||||
}
|
||||
for code, emoji_char in emoji_map.items():
|
||||
text = text.replace(code, emoji_char)
|
||||
return text
|
||||
|
||||
def normalize_chat_message(self, msg):
|
||||
try:
|
||||
if not isinstance(msg, dict):
|
||||
return None
|
||||
details = msg.get('details') or {}
|
||||
text = (
|
||||
msg.get('text')
|
||||
or msg.get('message')
|
||||
or msg.get('content')
|
||||
or msg.get('body')
|
||||
or ''
|
||||
)
|
||||
if not text and isinstance(details, dict):
|
||||
text = (
|
||||
details.get('body')
|
||||
or details.get('message')
|
||||
or details.get('text')
|
||||
or ''
|
||||
)
|
||||
if isinstance(text, dict):
|
||||
text = text.get('text') or text.get('body') or ''
|
||||
tp = msg.get('type') or ''
|
||||
if not text and tp == 'tip':
|
||||
amount = (
|
||||
details.get('amount') or details.get('tokens') or ''
|
||||
if isinstance(details, dict)
|
||||
else ''
|
||||
)
|
||||
text = f'打赏 {amount} tk' if amount else '打赏'
|
||||
if not text and tp == 'lovense':
|
||||
text = 'Lovense互动'
|
||||
ud = msg.get('userData') or msg.get('user') or msg.get('sender') or {}
|
||||
user = ''
|
||||
if isinstance(ud, dict):
|
||||
user = ud.get('username') or ud.get('name') or ud.get('login') or ''
|
||||
elif isinstance(ud, str):
|
||||
user = ud
|
||||
if not user:
|
||||
user = msg.get('username') or msg.get('userName') or ''
|
||||
text, user = str(text).strip(), str(user).strip()
|
||||
if not text:
|
||||
return None
|
||||
return {
|
||||
'time': msg.get('createdAt'),
|
||||
'user': user[:32],
|
||||
'text': self.replace_emoji(text)[:120],
|
||||
}
|
||||
except Exception as e:
|
||||
self.log(f'弹幕解析失败: {e}')
|
||||
return None
|
||||
|
||||
def send_live_danmaku(self, item):
|
||||
try:
|
||||
text = str(item.get('text', '')).strip()
|
||||
user = str(item.get('user', '')).strip()
|
||||
show = (f'{user}: {text}' if user else text)[:80]
|
||||
ok = self.call_local_action(
|
||||
f'do=danmaku&text={quote(show)}', f'实时弹幕发送: {show}'
|
||||
)
|
||||
if not ok:
|
||||
self.log('实时弹幕 action 未确认')
|
||||
except Exception as e:
|
||||
self.log(f'实时弹幕发送失败: {e}')
|
||||
|
||||
def get_danmaku_desc(self, room_id):
|
||||
cache = self.danmu_cache.get(room_id, {})
|
||||
cacheMsg = cache.get('msg', [])
|
||||
msg = []
|
||||
for item in cacheMsg:
|
||||
t = self.datetime_utc8(item.get('time'), '%H:%M')
|
||||
text = str(item.get('text', '')).strip()
|
||||
user = str(item.get('user', '')).strip()
|
||||
show = f'{t} {user}: {text}' if user else f'{t} {text}'
|
||||
msg.append(show)
|
||||
return '\n'.join(msg)
|
||||
|
||||
def get_action_bases(self):
|
||||
bases = []
|
||||
try:
|
||||
p = urlparse(self.getProxyUrl())
|
||||
if p.scheme and p.netloc:
|
||||
bases.append(f'{p.scheme}://{p.netloc}')
|
||||
except Exception:
|
||||
pass
|
||||
for b in ['http://127.0.0.1:9978', 'http://127.0.0.1:9979']:
|
||||
if b not in bases:
|
||||
bases.append(b)
|
||||
return bases
|
||||
|
||||
base_url = ''
|
||||
|
||||
def call_local_action(self, query, log_name):
|
||||
for base in [self.base_url] if self.base_url else self.get_action_bases():
|
||||
try:
|
||||
url = f'{base}/action?{query}'
|
||||
r = self.fetch(url, timeout=1)
|
||||
if r.text.strip() == 'OK':
|
||||
self.base_url = base
|
||||
self.log(log_name)
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
self.log(f'失败: {log_name}')
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# coding=utf-8
|
||||
# !/usr/bin/python
|
||||
import sys
|
||||
import requests
|
||||
sys.path.append('..')
|
||||
from base.spider import Spider
|
||||
|
||||
class Spider(Spider):
|
||||
|
||||
def init(self, extend="{}"):
|
||||
self.host='https://zh.stripol.com/'
|
||||
self.headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:142.0) Gecko/20100101 Firefox/142.0'
|
||||
}
|
||||
|
||||
def getName(self):
|
||||
pass
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
def homeContent(self, filter):
|
||||
result = {}
|
||||
classes = [{'type_name': '女主播', 'type_id': 'girls'}, {'type_name': '情侣', 'type_id': 'couples'}, {'type_name': '男主播', 'type_id': 'men'}, {'type_name': '跨性别', 'type_id': 'trans'}]
|
||||
filters = {}
|
||||
value = [{'n': '中国', 'v': 'tagLanguageChinese'}, {'n': '亚洲', 'v': 'ethnicityAsian'}, {'n': '白人', 'v': 'ethnicityWhite'}, {'n': '拉丁', 'v': 'ethnicityLatino'}, {'n': '混血', 'v': 'ethnicityMultiracial'}, {'n': '印度', 'v': 'ethnicityIndian'}, {'n': '阿拉伯', 'v': 'ethnicityMiddleEastern'}, {'n': '黑人', 'v': 'ethnicityEbony'}]
|
||||
value_gay = [{'n': '情侣', 'v': 'sexGayCouples'}, {'n': '直男', 'v': 'orientationStraight'}]
|
||||
for tid in ['girls', 'couples', 'men', 'trans']:
|
||||
c_value = value[:]
|
||||
if tid == 'men':
|
||||
c_value += value_gay
|
||||
filters[tid] = [{'key': 'tag', 'value': c_value}]
|
||||
result['class'] = classes
|
||||
result['filters'] = filters
|
||||
return result
|
||||
|
||||
def homeVideoContent(self):
|
||||
pass
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
limit = 60
|
||||
offset = limit * (int(pg) - 1)
|
||||
domain = f"{self.host}api/front/models?improveTs=false&removeShows=false&limit={limit}&offset={offset}&primaryTag={tid}&sortBy=viewersRating&rcmGrp=A&rbCnGr=true&prxCnGr=false&nic=false"
|
||||
if 'tag' in extend:
|
||||
domain += "&filterGroupTags=%5B%5B%22" + extend['tag'] + "%22%5D%5D"
|
||||
rsp = requests.get(domain, headers=self.headers).json()
|
||||
vodList = rsp['models']
|
||||
videos = []
|
||||
for vod in vodList:
|
||||
id = str(vod['id'])
|
||||
title = str(vod['username']).strip()
|
||||
stamp = vod['snapshotTimestamp']
|
||||
videos.append({
|
||||
"vod_id": title,
|
||||
"vod_name": title,
|
||||
"vod_pic": f"https://img.doppiocdn.net/thumbs/{stamp}/{id}",
|
||||
"vod_remarks": "购票表演中" if vod['groupShowType'] else ""
|
||||
})
|
||||
total = int(rsp['filteredCount'])
|
||||
result = {}
|
||||
result['list'] = videos
|
||||
result['page'] = pg
|
||||
result['pagecount'] = (total + limit - 1) // limit
|
||||
result['limit'] = limit
|
||||
result['total'] = total
|
||||
return result
|
||||
|
||||
def detailContent(self, array):
|
||||
username = array[0]
|
||||
domain = f"{self.host}api/front/v2/models/username/{username}/cam"
|
||||
rsp = requests.get(domain, headers=self.headers).json()
|
||||
info = rsp['cam']
|
||||
user = rsp['user']['user']
|
||||
id = str(user['id'])
|
||||
vod = {
|
||||
"vod_id": id,
|
||||
"vod_name": str(info['topic']).strip(),
|
||||
"vod_pic": str(user['avatarUrl']),
|
||||
"vod_director": username,
|
||||
"vod_area": str(user['country']),
|
||||
'vod_play_from': '直播线路',
|
||||
'vod_play_url': f"{id}${id}"
|
||||
}
|
||||
result = {
|
||||
'list': [
|
||||
vod
|
||||
]
|
||||
}
|
||||
return result
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
pass
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
domain = f"https://edge-hls.growcdnssedge.com/hls/{id}/master/{id}_auto.m3u8?playlistType=lowLatency"
|
||||
rsp = requests.get(domain, headers=self.headers).text
|
||||
lines = rsp.strip().split('\n')
|
||||
psch = ''
|
||||
pkey = ''
|
||||
url = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith('#EXT-X-MOUFLON:'):
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 4:
|
||||
psch = parts[2]
|
||||
pkey = parts[3]
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
name_start = line.find('NAME="') + 6
|
||||
name_end = line.find('"', name_start)
|
||||
qn = line[name_start:name_end]
|
||||
# URL在下一行
|
||||
url_base = lines[i + 1]
|
||||
# 组合最终的URL,并加上psch和pkey参数
|
||||
full_url = f"{url_base}&psch={psch}&pkey={pkey}"
|
||||
# 将画质和URL添加到列表中
|
||||
url.append(qn)
|
||||
url.append(full_url)
|
||||
result = {}
|
||||
result["url"] = url
|
||||
result["parse"] = '0'
|
||||
result["contentType"] = ''
|
||||
result["header"] = self.headers
|
||||
return result
|
||||
|
||||
def localProxy(self, param):
|
||||
pass
|
||||
@@ -0,0 +1,753 @@
|
||||
# coding=utf-8
|
||||
#!/usr/bin/python
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import requests
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from base.spider import Spider
|
||||
|
||||
sys.path.append("..")
|
||||
|
||||
|
||||
class Spider(Spider):
|
||||
|
||||
def init(self, extend="{}"):
|
||||
# 1. 创建 Session
|
||||
self.create_session_with_retry()
|
||||
|
||||
# 2. 备用域名列表
|
||||
self.dynamic_urls = [
|
||||
" https://zh.stripol.com/",
|
||||
"https://zh.pikpedcams.com/",
|
||||
"https://zh.virtualtaboo.live/",
|
||||
]
|
||||
|
||||
# 3. 基础 Header 及属性初始化
|
||||
self.Doppiocdn = "doppiocdn.org"
|
||||
user_agent = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101"
|
||||
" Firefox/153.0"
|
||||
)
|
||||
self.headers = {
|
||||
"User-Agent": user_agent,
|
||||
"Accept-Language": "zh,en;q=0.5",
|
||||
}
|
||||
|
||||
# 默认选第一个,后续请求会轮询切换
|
||||
self.host = self.dynamic_urls[0]
|
||||
self._update_headers_for_host(self.host)
|
||||
|
||||
# 4. 其他配置及弹幕锁
|
||||
self.stripchat_preferredVideoCodec = "H264" # 可选H264或AV1
|
||||
self.stripchat_key = "YzWScuyQRGAGcxx1KIJmiQ7BY9Vi35ftwLqUOVO8uoo="
|
||||
self.stripchat_pkey = "Fq6m2TO2ZeBkRPm9"
|
||||
self.stripchat_play = "0 0"
|
||||
self.danmu_cache = {}
|
||||
self.danmu_threads = {}
|
||||
self.danmu_lock = threading.Lock()
|
||||
|
||||
def _update_headers_for_host(self, host_url):
|
||||
"""根据当前使用的 Host 刷新请求头"""
|
||||
self.host = host_url
|
||||
self.headers["Origin"] = host_url
|
||||
self.headers["Referer"] = f"{host_url}/"
|
||||
self.json_headers = {
|
||||
**self.headers,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
|
||||
def _request_with_failover(self, path, timeout=(3, 5)):
|
||||
"""核心逻辑:逐个域名尝试请求列表/详情,直到成功为止"""
|
||||
# 优先使用上一次成功的域名,若不在第一位则将其调整到前面
|
||||
urls_to_try = list(self.dynamic_urls)
|
||||
if self.host in urls_to_try:
|
||||
urls_to_try.remove(self.host)
|
||||
urls_to_try.insert(0, self.host)
|
||||
|
||||
last_error = None
|
||||
|
||||
for domain in urls_to_try:
|
||||
clean_domain = domain.strip().rstrip('/')
|
||||
full_url = (
|
||||
f'{clean_domain}{path}' if path.startswith('/') else f'/{path}'
|
||||
)
|
||||
|
||||
# 临时构造对应域名的 Header
|
||||
headers = {
|
||||
**self.headers,
|
||||
'Origin': clean_domain,
|
||||
'Referer': f'{clean_domain}/',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.session.get(full_url, headers=headers, timeout=timeout)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# 校验是否拿到了正确的 json 数据
|
||||
if isinstance(data, dict):
|
||||
# 成功!更新全局 Host 状态
|
||||
if self.host != clean_domain:
|
||||
self.log(f'[HOST] 切换可用域名为: {clean_domain}')
|
||||
self._update_headers_for_host(clean_domain)
|
||||
return data
|
||||
else:
|
||||
self.log(
|
||||
f'[HOST] 域名 {clean_domain} 请求失败,状态码: {response.status_code},尝试下一个...'
|
||||
)
|
||||
except Exception as e:
|
||||
self.log(f'[HOST] 域名 {clean_domain} 访问异常: {e},尝试下一个...')
|
||||
last_error = e
|
||||
continue
|
||||
|
||||
self.log(f'[HOST] 所有域名均访问失败! 最后一次错误: {last_error}')
|
||||
return {}
|
||||
|
||||
def getName(self):
|
||||
return 'StripChat'
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
def homeVideoContent(self):
|
||||
pass
|
||||
|
||||
def datetime_utc8(self, strTime, outFormat):
|
||||
return (
|
||||
datetime.strptime(strTime, '%Y-%m-%dT%H:%M:%SZ') + timedelta(hours=8)
|
||||
).strftime(outFormat)
|
||||
|
||||
def homeContent(self, filter):
|
||||
CLASSES = [
|
||||
{'type_name': '女主播', 'type_id': 'girls'},
|
||||
{'type_name': '情侣', 'type_id': 'couples'},
|
||||
{'type_name': '男主播', 'type_id': 'men'},
|
||||
{'type_name': '跨性别', 'type_id': 'trans'},
|
||||
]
|
||||
VALUE = [
|
||||
{'n': '新主播', 'v': 'autoTagNew'},
|
||||
{'n': '推荐', 'v': 'recommended'},
|
||||
{'v': 'fuckMachine', 'n': '炮机'},
|
||||
{'n': '青年', 'v': 'ageTeen'},
|
||||
{'n': 'VR', 'v': 'autoTagVr'},
|
||||
{'n': '亚洲人', 'v': 'ethnicityAsian'},
|
||||
{'n': '🇨🇳中国', 'v': 'tagLanguageChinese'},
|
||||
{'n': '🇯🇵日本', 'v': 'tagLanguageJapanese'},
|
||||
{'n': '🇰🇷韩国', 'v': 'tagLanguageKorean'},
|
||||
{'n': '🇻🇳越南', 'v': 'tagLanguageVietnamese'},
|
||||
{'v': 'tagLanguageUkrainian', 'n': '🇺🇦乌克兰'},
|
||||
{'v': 'tagLanguageRussianSpeaking', 'n': '🇷🇺俄罗斯'},
|
||||
{'v': 'tagLanguageUSModels', 'n': '🇺🇸美国'},
|
||||
{'v': 'tagLanguageColombian', 'n': '🇨🇴哥伦比亚'},
|
||||
{'v': 'tagLanguageGermanSpeaking', 'n': '🇩🇪德国'},
|
||||
{'v': 'tagLanguageFrench', 'n': '🇫🇷法国'},
|
||||
{'v': 'tagLanguageUKModels', 'n': '🇬🇧英国'},
|
||||
{'v': 'tagLanguageCanadian', 'n': '🇨🇦加拿大'},
|
||||
{'v': 'tagLanguageMexican', 'n': '🇲🇽墨西哥'},
|
||||
{'v': 'ethnicityIndian', 'n': '🇮🇳印度'},
|
||||
{'v': 'tagLanguageVenezuelan', 'n': '🇻🇪委内瑞拉'},
|
||||
{'v': 'tagLanguageRomanian', 'n': '🇷🇴罗马尼亚'},
|
||||
{'v': 'tagLanguageAfrican', 'n': '🌍非洲'},
|
||||
{'v': 'tagLanguageSpanishSpeaking', 'n': '🇪🇸西班牙'},
|
||||
{'v': 'ethnicityMiddleEastern', 'n': '🇸🇦🇦🇪阿拉伯'},
|
||||
{'v': 'tagLanguageKenyan', 'n': '🇰🇪肯尼亚'},
|
||||
{'v': 'tagLanguageSouthAfrican', 'n': '🇿🇦南非'},
|
||||
{'v': 'tagLanguageBrazilian', 'n': '🇧🇷巴西'},
|
||||
{'v': 'tagLanguageThai', 'n': '🇹🇭泰国'},
|
||||
{'v': 'tagLanguageItalian', 'n': '🇮🇹意大利'},
|
||||
{'n': '亚洲', 'v': 'ethnicityAsian'},
|
||||
{'n': '白人', 'v': 'ethnicityWhite'},
|
||||
{'n': '拉丁', 'v': 'ethnicityLatino'},
|
||||
{'n': '混血', 'v': 'ethnicityMultiracial'},
|
||||
{'n': '印度', 'v': 'ethnicityIndian'},
|
||||
{'n': '阿拉伯', 'v': 'ethnicityMiddleEastern'},
|
||||
{'n': '黑人', 'v': 'ethnicityEbony'},
|
||||
{'n': '✨新主播', 'v': 'autoTagNew'},
|
||||
{'n': 'VR直播', 'v': 'autoTagVr'},
|
||||
{'n': '18+', 'v': 'ageTeen'},
|
||||
{'n': '鲜嫩青年22+', 'v': 'ageYoung'},
|
||||
{'n': '学生', 'v': 'subcultureStudent'},
|
||||
{'n': '口交', 'v': 'doBlowjob'},
|
||||
{'n': '深喉', 'v': 'doDeepThroat'},
|
||||
{'n': '恋足', 'v': 'doFootFetish'},
|
||||
{'n': '互动玩具', 'v': 'autoTagInteractiveToy'},
|
||||
{'n': '自慰', 'v': 'doMasturbation'},
|
||||
{'n': '肛交', 'v': 'doAnal'},
|
||||
{'n': '潮吹', 'v': 'doSquirt'},
|
||||
{'n': '狗式', 'v': 'doDoggyStyle'},
|
||||
{'n': 'Cosplay', 'v': 'doCosplay'},
|
||||
{'n': 'RolePlay', 'v': 'doRolePlay'},
|
||||
]
|
||||
VALUE_MEN = [
|
||||
{'n': '情侣', 'v': 'sexGayCouples'},
|
||||
{'n': '直男', 'v': 'orientationStraight'},
|
||||
]
|
||||
TIDS = ('girls', 'couples', 'men', 'trans')
|
||||
filters = {
|
||||
tid: [{'key': 'tag', 'value': VALUE_MEN + VALUE if tid == 'men' else VALUE}]
|
||||
for tid in TIDS
|
||||
}
|
||||
return {'class': CLASSES, 'filters': filters}
|
||||
|
||||
def _parse_status_remark(self, is_live, status, viewers=0):
|
||||
if not is_live or status == 'off':
|
||||
status_text = '⚫ 已下播'
|
||||
elif status == 'public':
|
||||
status_text = '🔴 直播中'
|
||||
else:
|
||||
status_text = '收费房'
|
||||
|
||||
return f'👤 {viewers}人 | {status_text}' if viewers else status_text
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
try:
|
||||
pg_str = str(pg)
|
||||
page_num = int(pg_str)
|
||||
|
||||
# 1. 搜索场景逻辑
|
||||
if tid.startswith('search '):
|
||||
_, tag, key = tid.split(maxsplit=2)
|
||||
path = f'/api/front/v4/models/search/group/username?query={key}&limit=900&primaryTag={tag}'
|
||||
rsp = self._request_with_failover(path)
|
||||
|
||||
videos = []
|
||||
for u in rsp.get('models', []):
|
||||
if not u.get('isLive'):
|
||||
continue
|
||||
viewers = u.get('viewersCount', 0)
|
||||
is_live = u.get('isLive', False)
|
||||
status = u.get('status', 'off')
|
||||
|
||||
remark = self._parse_status_remark(is_live, status, viewers)
|
||||
videos.append({
|
||||
'vod_id': str(u['username']),
|
||||
'vod_name': (
|
||||
f"{self.country_code_to_flag(str(u.get('country', '')))}{u['username']}"
|
||||
),
|
||||
'vod_pic': (
|
||||
f"https://img.{self.Doppiocdn}/snapshot/{u['id']}/{u.get('snapshotTimestamp', '')}"
|
||||
),
|
||||
'style': {'type': 'rect', 'ratio': 1.78},
|
||||
'vod_remarks': remark,
|
||||
})
|
||||
|
||||
return {
|
||||
'list': videos,
|
||||
'page': pg_str,
|
||||
'pagecount': '1',
|
||||
'limit': '900',
|
||||
'total': str(len(videos)),
|
||||
}
|
||||
|
||||
# 2. 普通分类列表场景逻辑 (已移除 host_card 保证追加)
|
||||
limit = 60
|
||||
offset = limit * (page_num - 1)
|
||||
path = f'/api/front/models?improveTs=false&removeShows=false&limit={limit}&offset={offset}&primaryTag={tid}&sortBy=stripRanking&rcmGrp=A&rbCnGr=true&prxCnGr=false&nic=false'
|
||||
if 'tag' in extend and extend['tag']:
|
||||
path += f'&filterGroupTags=[["{extend["tag"]}"]]'
|
||||
|
||||
# 使用轮询切域名获取列表数据
|
||||
rsp = self._request_with_failover(path)
|
||||
|
||||
videos = []
|
||||
for v in rsp.get('models', []):
|
||||
is_live = v.get('isLive', False)
|
||||
status = v.get('status', 'public')
|
||||
viewers = v.get('viewersCount', 0)
|
||||
|
||||
remark = self._parse_status_remark(is_live, status, viewers)
|
||||
|
||||
videos.append({
|
||||
'vod_id': str(v['username']),
|
||||
'vod_name': (
|
||||
f"{self.country_code_to_flag(str(v.get('country', '')))}{v['username']}"
|
||||
),
|
||||
'vod_pic': (
|
||||
f"https://img.{self.Doppiocdn}/snapshot/{v['id']}/{v.get('snapshotTimestamp', '')}"
|
||||
),
|
||||
'vod_remarks': remark,
|
||||
})
|
||||
|
||||
total = int(rsp.get('filteredCount', 0))
|
||||
pagecount = (total + limit - 1) // limit if total > 0 else 1
|
||||
|
||||
return {
|
||||
'list': videos,
|
||||
'page': pg_str,
|
||||
'pagecount': str(pagecount),
|
||||
'limit': str(limit),
|
||||
'total': str(total),
|
||||
}
|
||||
except Exception as e:
|
||||
self.log(f'获取分类内容失败: {e}')
|
||||
return {
|
||||
'list': [],
|
||||
'page': str(pg),
|
||||
'pagecount': '1',
|
||||
'limit': '60',
|
||||
'total': '0',
|
||||
}
|
||||
|
||||
def detailContent(self, array):
|
||||
username = array[0]
|
||||
|
||||
try:
|
||||
path = f'/api/front/v2/models/username/{username}/cam'
|
||||
# 使用轮询切域名获取详情数据
|
||||
rsp = self._request_with_failover(path)
|
||||
|
||||
info = rsp.get('cam', {})
|
||||
user = rsp.get('user', {}).get('user', {})
|
||||
uid, isLive = str(user.get('id', '')), user.get('isLive', False)
|
||||
|
||||
oldName = self.stripchat_play.rsplit(' ', 1)[-1]
|
||||
if username != oldName:
|
||||
timestp = int(time.time())
|
||||
self.stripchat_play = f'0 {timestp} {username}'
|
||||
flag = self.country_code_to_flag(str(user.get('country', '')).strip())
|
||||
|
||||
remark = '🔴 直播中' if isLive else '⚫ 已下播'
|
||||
show = info.get('show') or info.get('groupShowAnnouncement')
|
||||
if show:
|
||||
startAt = show.get('createdAt') or show.get('startAt')
|
||||
if startAt:
|
||||
remark = (
|
||||
f"🎫 购票表演始于 {self.datetime_utc8(startAt, '%m月%d日 %H:%M')}"
|
||||
)
|
||||
|
||||
director = f'{flag}{username}'
|
||||
desc = self.get_danmaku_desc(uid)
|
||||
|
||||
vod_play_from = '高清线路$$$标清线路二$$$标清线路三'
|
||||
vod_play_url = (
|
||||
f'主线路${uid}$$$备用线路$lemon_{uid}$$$备用线路三$sacf_{uid}'
|
||||
)
|
||||
|
||||
return {
|
||||
'list': [{
|
||||
'vod_id': username,
|
||||
'vod_name': str(info.get('topic', ''))[:80],
|
||||
'vod_pic': str(user.get('avatarUrl', '')),
|
||||
'vod_director': director,
|
||||
'vod_content': desc,
|
||||
'vod_remarks': remark,
|
||||
'vod_play_from': vod_play_from,
|
||||
'vod_play_url': vod_play_url,
|
||||
}]
|
||||
}
|
||||
except Exception as e:
|
||||
self.log(f'获取详情失败: {e}')
|
||||
return {'list': []}
|
||||
|
||||
def searchContent(self, key, quick, pg='1'):
|
||||
if int(pg) > 1:
|
||||
return {}
|
||||
return {
|
||||
'list': [
|
||||
{
|
||||
'vod_id': f'search {t["type_id"]} {key}',
|
||||
'vod_name': t['type_name'],
|
||||
'vod_tag': 'folder',
|
||||
}
|
||||
for t in self.homeContent(False).get('class', [])
|
||||
]
|
||||
}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
urls = []
|
||||
try:
|
||||
sid = id.split('_')[-1]
|
||||
self.start_danmu(sid)
|
||||
|
||||
# 统一使用动态 self.host 配置 Origin 和 Referer
|
||||
headers = {
|
||||
'User-Agent': self.headers.get('User-Agent'),
|
||||
'Origin': self.host,
|
||||
'Referer': f'{self.host}/',
|
||||
}
|
||||
|
||||
# --- 线路2: stripchat.global ---
|
||||
if id.startswith('lemon'):
|
||||
rsp = self.session_get(
|
||||
f'https://edge-hls.growcdnssedge.com/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency'
|
||||
).text
|
||||
lines = rsp.strip().split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
qn_start = line.find('NAME="') + 6
|
||||
qn = line[qn_start : line.find('"', qn_start)]
|
||||
url = lines[i + 1]
|
||||
urls.extend([qn, url])
|
||||
|
||||
# --- 线路3: StripOl ---
|
||||
elif id.startswith('sacf'):
|
||||
rsp = self.session_get(
|
||||
f'https://edge-hls.sacfedge.com/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency'
|
||||
).text
|
||||
lines = rsp.strip().split('\n')
|
||||
psch, pkey = 'v2', self.stripchat_pkey
|
||||
for i, line in enumerate(lines):
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
qn_start = line.find('NAME="') + 6
|
||||
qn = line[qn_start : line.find('"', qn_start)]
|
||||
full_url = f'{lines[i+1]}&psch={psch}&pkey={pkey}&preferredVideoCodec={self.stripchat_preferredVideoCodec}'
|
||||
urls.extend([qn, f'{self.getProxyUrl()}&url={quote(full_url)}'])
|
||||
|
||||
# --- 线路1: StripChat (主线路) ---
|
||||
else:
|
||||
rsp = self.session_get(
|
||||
f'https://edge-hls.{self.Doppiocdn}/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency'
|
||||
).text
|
||||
lines = rsp.strip().split('\n')
|
||||
psch, pkey = 'v2', self.stripchat_pkey
|
||||
for i, line in enumerate(lines):
|
||||
if '#EXT-X-STREAM-INF' in line:
|
||||
qn_start = line.find('NAME="') + 6
|
||||
qn = line[qn_start : line.find('"', qn_start)]
|
||||
full_url = f'{lines[i+1]}&psch={psch}&pkey={pkey}&preferredVideoCodec={self.stripchat_preferredVideoCodec}'
|
||||
urls.extend([qn, f'{self.getProxyUrl()}&url={quote(full_url)}'])
|
||||
|
||||
return {'url': urls, 'parse': '0', 'position': '0', 'header': headers}
|
||||
except Exception as e:
|
||||
self.log(f'播放失败 {id}: {e}')
|
||||
return {'url': urls, 'parse': 0}
|
||||
|
||||
def update_vod(self, username):
|
||||
try:
|
||||
content_data = self.detailContent([username]).get('list')[0]
|
||||
payload = {'json': json.dumps(content_data, ensure_ascii=False)}
|
||||
self.post('http://127.0.0.1:9978/action?do=refresh&type=vod', data=payload)
|
||||
except Exception as e:
|
||||
self.log(f'刷新详情失败: {e}')
|
||||
|
||||
def localProxy(self, param):
|
||||
url, type = param['url'], param.get('type', '')
|
||||
if type == 'media':
|
||||
data = self.session_get(url, timeout=(5, 15))
|
||||
return [200, 'video/mp4', data.content]#更改这个可以让羊壳正常播放video/mp4和application/octet-stream
|
||||
rsp = self.session_get(url)
|
||||
oldCode, oldtmp, username = self.stripchat_play.rsplit(' ')
|
||||
timestp = int(time.time())
|
||||
is_time_up = (timestp - 10) > int(oldtmp)
|
||||
is_code_changed = int(oldCode) != 0 and rsp.status_code != int(oldCode)
|
||||
if is_time_up or is_code_changed:
|
||||
self.stripchat_play = f'{rsp.status_code} {timestp} {username}'
|
||||
self.log('计划更新')
|
||||
self.update_vod(username)
|
||||
if is_code_changed:
|
||||
self.log('code变更')
|
||||
self.post('http://127.0.0.1:9978/action?do=refresh&type=player')
|
||||
return [404, 'text/plain', '']
|
||||
if rsp.status_code == 403:
|
||||
rsp = self.session_get(
|
||||
re.sub(r'(_\d+p\d*)?\.m3u8', '_160p_blurred.m3u8', url)
|
||||
)
|
||||
if rsp.status_code != 200:
|
||||
return [404, 'text/plain', '']
|
||||
data = (
|
||||
self.process_m3u8(rsp.text)
|
||||
if '#EXT-X-MOUFLON:URI:' in rsp.text
|
||||
else rsp.text
|
||||
)
|
||||
return [200, 'application/vnd.apple.mpegur', data]
|
||||
|
||||
URL_PATTERN = re.compile(
|
||||
r'https://media-hls\.doppiocdn\.\w+/b-hls-\d+/media\.mp4'
|
||||
)
|
||||
MAP_URI_PATTERN = re.compile(r'URI=["\']?(https?://[^\s"\'<>]+)["\']?')
|
||||
MOUFLON_TAIL_PATTERN = re.compile(r'(_part\d+)?\.mp4$')
|
||||
|
||||
def process_m3u8(self, content):
|
||||
lines = content.strip().split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith('#EXT-X-MOUFLON:URI:') and 'media.mp4' in lines[i + 1]:
|
||||
mouflon = line.split(':', 2)[2].strip()
|
||||
encrypted = self.MOUFLON_TAIL_PATTERN.sub('', mouflon).rsplit('_', 2)[1]
|
||||
new_url = mouflon.replace(
|
||||
encrypted, self._decode(encrypted[::-1], self.stripchat_key)
|
||||
)
|
||||
proxy_url = f'{self.getProxyUrl()}&type=media&url={quote(new_url)}'
|
||||
lines[i + 1] = self.URL_PATTERN.sub(proxy_url, lines[i + 1])
|
||||
elif line.startswith('#EXT-X-MAP:URI'):
|
||||
match = self.MAP_URI_PATTERN.search(line)
|
||||
if match:
|
||||
original_url = match.group(1)
|
||||
proxy_url = (
|
||||
f'{self.getProxyUrl()}&type=media&url={quote(original_url)}'
|
||||
)
|
||||
lines[i] = line.replace(original_url, proxy_url)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def country_code_to_flag(self, code):
|
||||
return (
|
||||
''.join(
|
||||
chr(ord(c.upper()) - ord('A') + 0x1F1E6)
|
||||
for c in code
|
||||
if len(code) == 2 and code.isalpha()
|
||||
)
|
||||
if len(code) == 2 and code.isalpha()
|
||||
else code
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=20)
|
||||
def _decode(encrypted_b64: str, key_b64: str) -> str:
|
||||
encrypted_b64 += '=' * (4 - len(encrypted_b64) % 4)
|
||||
key_bytes = base64.b64decode(key_b64)
|
||||
encrypted = base64.b64decode(encrypted_b64)
|
||||
decrypted = bytearray(len(encrypted))
|
||||
for i in range(len(encrypted)):
|
||||
decrypted[i] = encrypted[i] ^ (key_bytes[i % len(key_bytes)] & 0xFF)
|
||||
return decrypted.decode('utf-8')
|
||||
|
||||
def create_session_with_retry(self):
|
||||
self.session = requests.Session()
|
||||
retry = Retry(
|
||||
total=2,
|
||||
backoff_factor=0.2,
|
||||
status_forcelist=[408, 429, 500, 502, 503, 504],
|
||||
raise_on_status=False,
|
||||
)
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
max_retries=retry, pool_connections=20, pool_maxsize=50, pool_block=False
|
||||
)
|
||||
self.session.mount('http://', adapter)
|
||||
self.session.mount('https://', adapter)
|
||||
|
||||
def session_get(self, url, headers=None, stream=False, timeout=(3, 5)):
|
||||
return self.session.get(
|
||||
url,
|
||||
headers=self.headers if headers is None else headers,
|
||||
timeout=timeout,
|
||||
stream=stream,
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
def start_danmu(self, room_id):
|
||||
try:
|
||||
entry = self.danmu_threads.get(room_id)
|
||||
if entry:
|
||||
t, _ = entry
|
||||
if t.is_alive():
|
||||
self.log(f'弹幕线程已存在: {room_id}')
|
||||
return
|
||||
for rid, (ot, stop_evt) in list(self.danmu_threads.items()):
|
||||
if rid == room_id:
|
||||
continue
|
||||
if ot.is_alive():
|
||||
self.log(f'正在关闭其他房间弹幕线程: {rid}')
|
||||
stop_evt.set()
|
||||
ot.join(timeout=1.0)
|
||||
del self.danmu_threads[rid]
|
||||
stop_event = threading.Event()
|
||||
t = threading.Thread(
|
||||
target=self._danmu_poll_worker,
|
||||
args=(room_id, stop_event),
|
||||
daemon=True,
|
||||
)
|
||||
self.danmu_threads[room_id] = (t, stop_event)
|
||||
t.start()
|
||||
self.log(f'弹幕线程启动: {room_id}')
|
||||
except Exception as e:
|
||||
self.log(f'弹幕线程启动失败: {e}')
|
||||
|
||||
def _danmu_poll_worker(self, room_id, stop_event):
|
||||
while True:
|
||||
if self.base_url:
|
||||
try:
|
||||
r = self.fetch(f'{self.base_url}/media', timeout=1).json()
|
||||
if not r.get('state', False):
|
||||
stop_event.set()
|
||||
except:
|
||||
stop_event.set()
|
||||
if stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
self.fetch_chat_once(room_id)
|
||||
if stop_event.wait(5):
|
||||
break
|
||||
except Exception as e:
|
||||
self.log(f'弹幕轮询异常 {room_id}: {e}')
|
||||
if stop_event.wait(10):
|
||||
break
|
||||
self.log(f'弹幕线程结束: {room_id}')
|
||||
|
||||
def fetch_chat_once(self, room_id):
|
||||
path = f'/api/front/v2/models/{room_id}/chat?source=regular&uniq={int(time.time()*1000)}'
|
||||
data = self._request_with_failover(path)
|
||||
|
||||
arr = data.get('messages') if isinstance(data, dict) else []
|
||||
if not isinstance(arr, list):
|
||||
return 0
|
||||
newId, newMsg = 0, []
|
||||
with self.danmu_lock:
|
||||
cache = self.danmu_cache.get(room_id, {})
|
||||
oldId = cache.get('id', 0)
|
||||
cacheMsg = cache.get('msg', [])
|
||||
for raw in reversed(arr):
|
||||
id = int(raw.get('id', 0))
|
||||
if oldId and id <= oldId:
|
||||
break
|
||||
if not newId:
|
||||
newId = id
|
||||
item = self.normalize_chat_message(raw)
|
||||
if not item:
|
||||
continue
|
||||
newMsg.append(item)
|
||||
if newId:
|
||||
if newMsg:
|
||||
cacheMsg = newMsg + cacheMsg
|
||||
cacheMsg = cacheMsg[:30]
|
||||
self.danmu_cache[room_id] = {'id': newId, 'msg': cacheMsg}
|
||||
if oldId:
|
||||
for m in reversed(newMsg):
|
||||
self.send_live_danmaku(m)
|
||||
time.sleep(0.15)
|
||||
|
||||
def replace_emoji(self, text: str) -> str:
|
||||
emoji_map = {
|
||||
':heart:': '❤️',
|
||||
':dancing:': '💃',
|
||||
':thumbsup:': '👍',
|
||||
':flower:': '🌹',
|
||||
':lol:': '😄',
|
||||
':flirt:': '😉',
|
||||
':devil:': '😈',
|
||||
':hideeyes:': '🙈',
|
||||
':ask:': '❓',
|
||||
':inlove:': '😍',
|
||||
':tongue:': '😛',
|
||||
':cry:': '😭',
|
||||
':fire:': '🔥',
|
||||
':asking:': '🤔',
|
||||
':wink:': '😉',
|
||||
':ok:': '👌',
|
||||
':shy:': '😳',
|
||||
':angry:': '😡',
|
||||
':facepalm:': '🤦♂️',
|
||||
':ass:': '🍑',
|
||||
}
|
||||
for code, emoji_char in emoji_map.items():
|
||||
text = text.replace(code, emoji_char)
|
||||
return text
|
||||
|
||||
def normalize_chat_message(self, msg):
|
||||
try:
|
||||
if not isinstance(msg, dict):
|
||||
return None
|
||||
details = msg.get('details') or {}
|
||||
text = (
|
||||
msg.get('text')
|
||||
or msg.get('message')
|
||||
or msg.get('content')
|
||||
or msg.get('body')
|
||||
or ''
|
||||
)
|
||||
if not text and isinstance(details, dict):
|
||||
text = (
|
||||
details.get('body')
|
||||
or details.get('message')
|
||||
or details.get('text')
|
||||
or ''
|
||||
)
|
||||
if isinstance(text, dict):
|
||||
text = text.get('text') or text.get('body') or ''
|
||||
tp = msg.get('type') or ''
|
||||
if not text and tp == 'tip':
|
||||
amount = (
|
||||
details.get('amount') or details.get('tokens') or ''
|
||||
if isinstance(details, dict)
|
||||
else ''
|
||||
)
|
||||
text = f'打赏 {amount} tk' if amount else '打赏'
|
||||
if not text and tp == 'lovense':
|
||||
text = 'Lovense互动'
|
||||
ud = msg.get('userData') or msg.get('user') or msg.get('sender') or {}
|
||||
user = ''
|
||||
if isinstance(ud, dict):
|
||||
user = ud.get('username') or ud.get('name') or ud.get('login') or ''
|
||||
elif isinstance(ud, str):
|
||||
user = ud
|
||||
if not user:
|
||||
user = msg.get('username') or msg.get('userName') or ''
|
||||
text, user = str(text).strip(), str(user).strip()
|
||||
if not text:
|
||||
return None
|
||||
return {
|
||||
'time': msg.get('createdAt'),
|
||||
'user': user[:32],
|
||||
'text': self.replace_emoji(text)[:120],
|
||||
}
|
||||
except Exception as e:
|
||||
self.log(f'弹幕解析失败: {e}')
|
||||
return None
|
||||
|
||||
def send_live_danmaku(self, item):
|
||||
try:
|
||||
text = str(item.get('text', '')).strip()
|
||||
user = str(item.get('user', '')).strip()
|
||||
show = (f'{user}: {text}' if user else text)[:80]
|
||||
ok = self.call_local_action(
|
||||
f'do=danmaku&text={quote(show)}', f'实时弹幕发送: {show}'
|
||||
)
|
||||
if not ok:
|
||||
self.log('实时弹幕 action 未确认')
|
||||
except Exception as e:
|
||||
self.log(f'实时弹幕发送失败: {e}')
|
||||
|
||||
def get_danmaku_desc(self, room_id):
|
||||
cache = self.danmu_cache.get(room_id, {})
|
||||
cacheMsg = cache.get('msg', [])
|
||||
msg = []
|
||||
for item in cacheMsg:
|
||||
t = self.datetime_utc8(item.get('time'), '%H:%M')
|
||||
text = str(item.get('text', '')).strip()
|
||||
user = str(item.get('user', '')).strip()
|
||||
show = f'{t} {user}: {text}' if user else f'{t} {text}'
|
||||
msg.append(show)
|
||||
return '\n'.join(msg)
|
||||
|
||||
def get_action_bases(self):
|
||||
bases = []
|
||||
try:
|
||||
p = urlparse(self.getProxyUrl())
|
||||
if p.scheme and p.netloc:
|
||||
bases.append(f'{p.scheme}://{p.netloc}')
|
||||
except Exception:
|
||||
pass
|
||||
for b in ['http://127.0.0.1:9978', 'http://127.0.0.1:9979']:
|
||||
if b not in bases:
|
||||
bases.append(b)
|
||||
return bases
|
||||
|
||||
base_url = ''
|
||||
|
||||
def call_local_action(self, query, log_name):
|
||||
for base in [self.base_url] if self.base_url else self.get_action_bases():
|
||||
try:
|
||||
url = f'{base}/action?{query}'
|
||||
r = self.fetch(url, timeout=1)
|
||||
if r.text.strip() == 'OK':
|
||||
self.base_url = base
|
||||
self.log(log_name)
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
self.log(f'失败: {log_name}')
|
||||
return False
|
||||
@@ -0,0 +1,212 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
TVBox 本地 Py 爬虫聚合源
|
||||
========================
|
||||
功能: 扫描指定目录下的 .py 爬虫文件,在 TVBox 中以频道形式展示,
|
||||
并在初始化时自动保存 py.json 到指定路径。
|
||||
使用: 将本文件放入 TVBox 配置中,type=3,api 指向本文件路径即可。
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
from base.spider import Spider
|
||||
|
||||
|
||||
class Spider(Spider):
|
||||
# ==========================================================================
|
||||
# 📂 【配置区】在这里修改扫描目录和 JSON 保存路径
|
||||
# ==========================================================================
|
||||
PY_DIR = "/storage/emulated/0/tvbox/py"
|
||||
SAVE_PATH = "/storage/emulated/0/tvbox/py.json"
|
||||
# ==========================================================================
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.inited = False
|
||||
# cache["categories"]: 首页频道列表
|
||||
# cache["file_index"]: 映射频道 ID 到文件路径
|
||||
self.cache = {"categories": [], "file_index": {}}
|
||||
|
||||
def getName(self):
|
||||
return "本地Py爬虫聚合源"
|
||||
|
||||
# ==========================================================================
|
||||
# 🚀 【核心初始化】扫描 py 文件 + 自动保存 JSON 配置
|
||||
# ==========================================================================
|
||||
def init(self, extend):
|
||||
if self.inited:
|
||||
return
|
||||
|
||||
self._scan_py_files()
|
||||
self._save_config_json()
|
||||
self.inited = True
|
||||
|
||||
def _scan_py_files(self):
|
||||
"""递归扫描 PY_DIR,收集所有 .py 爬虫文件"""
|
||||
sites = []
|
||||
self_path = os.path.abspath(__file__)
|
||||
|
||||
if not os.path.isdir(self.PY_DIR):
|
||||
self.cache["categories"] = sites
|
||||
return
|
||||
|
||||
for root, dirs, files in os.walk(self.PY_DIR):
|
||||
for f in sorted(files):
|
||||
if not f.endswith(".py"):
|
||||
continue
|
||||
full_path = os.path.join(root, f)
|
||||
if os.path.abspath(full_path) == self_path:
|
||||
continue # 排除自身
|
||||
|
||||
file_name = f[:-3] # 去掉 .py 后缀
|
||||
rel_path = os.path.relpath(full_path, self.PY_DIR)
|
||||
folder_part = os.path.dirname(rel_path)
|
||||
|
||||
if folder_part:
|
||||
# 子文件夹里的 py 文件 → 不显示文件夹名
|
||||
name_part = file_name
|
||||
else:
|
||||
# py 根目录里直接有的 py 文件 → 显示文件夹名在前
|
||||
folder_name = os.path.basename(self.PY_DIR)
|
||||
name_part = f"{file_name}"
|
||||
|
||||
# Base64 编码 tid,供 TVBox 内部传递
|
||||
tid = base64.b64encode(f"PY|{full_path}".encode()).decode()
|
||||
sites.append({
|
||||
"type_id": tid,
|
||||
"type_name": name_part,
|
||||
# 排序键: 根目录文件排前面,然后按名称排序
|
||||
"sk": (0 if not folder_part else 1, name_part)
|
||||
})
|
||||
self.cache["file_index"][tid] = full_path
|
||||
|
||||
self.cache["categories"] = sorted(sites, key=lambda x: x["sk"])
|
||||
|
||||
def _build_api(self, f_path):
|
||||
"""用 PY_DIR 变量拼接 api 路径(不带 file:// 前缀)"""
|
||||
rel = os.path.relpath(f_path, self.PY_DIR)
|
||||
return f"./py/{rel}"
|
||||
|
||||
def _save_config_json(self):
|
||||
"""将扫描结果保存为 TVBox 可用的 config.json"""
|
||||
config = {
|
||||
"spider": "./spider.jar",
|
||||
"sites": []
|
||||
}
|
||||
for cat in self.cache["categories"]:
|
||||
f_path = self.cache["file_index"].get(cat["type_id"], "")
|
||||
if not f_path:
|
||||
continue
|
||||
config["sites"].append({
|
||||
"key": f"{cat['type_name']}",
|
||||
"name": f"⁽ᵖʸ⁾{cat['type_name']}",
|
||||
"type": 3,
|
||||
"searchable": 1,
|
||||
"quickSearch": 1,
|
||||
"api": self._build_api(f_path)
|
||||
})
|
||||
|
||||
# 确保保存目录存在
|
||||
save_dir = os.path.dirname(self.SAVE_PATH)
|
||||
if save_dir and not os.path.exists(save_dir):
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
with open(self.SAVE_PATH, "w", encoding="utf-8") as fp:
|
||||
json.dump(config, fp, ensure_ascii=False, indent=2)
|
||||
|
||||
# ==========================================================================
|
||||
# 📺 【TVBox 标准接口】
|
||||
# ==========================================================================
|
||||
def homeContent(self, filter):
|
||||
"""返回首页频道列表"""
|
||||
return {"class": self.cache["categories"]}
|
||||
|
||||
def categoryContent(self, tid, pg, filter, ext):
|
||||
"""返回指定频道下的条目列表"""
|
||||
if str(pg) != "1":
|
||||
return {"list": []}
|
||||
|
||||
f_path = self.cache["file_index"].get(tid, "")
|
||||
if not f_path or not os.path.exists(f_path):
|
||||
return {"list": []}
|
||||
|
||||
f_base = os.path.basename(f_path).rsplit('.', 1)[0]
|
||||
v_id = base64.b64encode(f"PY|{f_path}".encode()).decode()
|
||||
|
||||
return {"list": [{
|
||||
"vod_id": v_id,
|
||||
"vod_name": f_base,
|
||||
"vod_pic": "",
|
||||
"vod_remarks": self._build_api(f_path)
|
||||
}]}
|
||||
|
||||
def detailContent(self, array):
|
||||
"""返回详情页,展示爬虫配置信息"""
|
||||
try:
|
||||
v_id_raw = str(array[0])
|
||||
# Base64 补齐
|
||||
v_id_raw += "=" * ((4 - len(v_id_raw) % 4) % 4)
|
||||
raw_decoded = base64.b64decode(v_id_raw).decode('utf-8', errors='ignore')
|
||||
|
||||
# 解析 ID: 格式为 "PY|文件路径"
|
||||
if raw_decoded.startswith("PY|"):
|
||||
f_path = raw_decoded[3:]
|
||||
else:
|
||||
f_path = raw_decoded
|
||||
|
||||
if not os.path.exists(f_path):
|
||||
return {"list": [{"vod_name": "文件不存在", "vod_content": f"路径不存在: {f_path}"}]}
|
||||
|
||||
f_base = os.path.basename(f_path).rsplit('.', 1)[0]
|
||||
|
||||
# 构建 TVBox 站点配置信息
|
||||
site_info = {
|
||||
"key": f"py_{f_base}",
|
||||
"name": f_base,
|
||||
"type": 3,
|
||||
"searchable": 1,
|
||||
"quickSearch": 1,
|
||||
"api": self._build_api(f_path)
|
||||
}
|
||||
info_text = json.dumps(site_info, ensure_ascii=False, indent=2)
|
||||
|
||||
# 再次保存 JSON 配置(确保最新)
|
||||
self._save_config_json()
|
||||
|
||||
return {"list": [{
|
||||
"vod_name": f_base,
|
||||
"vod_pic": "",
|
||||
"vod_play_from": "配置信息",
|
||||
"vod_play_url": f"查看配置${f_path}",
|
||||
"vod_content": (
|
||||
f"✅ 配置已自动保存到: {self.SAVE_PATH}\n\n"
|
||||
f"站点配置详情:\n{info_text}\n\n"
|
||||
f"文件路径: {f_path}"
|
||||
)
|
||||
}]}
|
||||
except Exception as e:
|
||||
return {"list": [{"vod_name": "解析错误", "vod_content": f"错误详情: {str(e)}"}]}
|
||||
|
||||
def searchContent(self, key, quick):
|
||||
"""本地搜索 py 文件"""
|
||||
res = []
|
||||
for tid, f_path in self.cache["file_index"].items():
|
||||
f_base = os.path.basename(f_path).rsplit('.', 1)[0]
|
||||
if key.lower() in f_base.lower():
|
||||
v_id = base64.b64encode(f"PY|{f_path}".encode()).decode()
|
||||
res.append({
|
||||
"vod_id": v_id,
|
||||
"vod_name": f_base,
|
||||
"vod_pic": "",
|
||||
"vod_remarks": self._build_api(f_path)
|
||||
})
|
||||
return {"list": res}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
"""播放接口(本源无实际播放内容,仅返回路径)"""
|
||||
url = id.split('$')[-1] if '$' in id else id
|
||||
return {"url": url, "header": {}, "parse": 0}
|
||||
|
||||
def destroy(self):
|
||||
"""资源释放"""
|
||||
return "destroy"
|
||||
Reference in New Issue
Block a user