Sync all projects

This commit is contained in:
github-actions[bot]
2026-07-24 17:11:13 +00:00
parent 0a5410f0a1
commit f7f8d9e1bf
16 changed files with 4619 additions and 2108 deletions
+753
View File
@@ -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
+731 -110
View File
@@ -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
+132
View File
@@ -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
+753
View File
@@ -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"