754 lines
25 KiB
Python
754 lines
25 KiB
Python
# 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'},
|
|
{'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'},
|
|
{'v': 'fuckMachine', 'n': '炮机'},
|
|
{'n': '青年', 'v': 'ageTeen'},
|
|
{'n': 'VR', 'v': 'autoTagVr'},
|
|
{'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
|