# 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