# -*- coding: utf-8 -*- import re import socket import threading import time import requests import html as _html from base.spider import Spider from Crypto.Cipher import AES from urllib3 import disable_warnings from http.server import BaseHTTPRequestHandler, HTTPServer disable_warnings() HOST = "https://www.ymvid.com" # 全局变量 _GLOBAL_M3U8_DATA = "" _LOCAL_PORT = 0 _TS_BASE_URL = "" # 🔥 新增:用于动态记录当前视频切片的基础域名/路径 # 心跳全局配置管理 _HEARTBEAT_CONFIG = { "active": False, "seriesId": "", "playTime": "", "cookie": "", "ua": "", "referer": "", "browser_code": "" } # ==================== 后台守护线程:伪造持续心跳 ==================== def heartbeat_worker(): """ 每隔 60 秒自动帮播放器上报一次状态,防止服务端拉黑或污染切片 """ global _HEARTBEAT_CONFIG while True: if _HEARTBEAT_CONFIG["active"]: try: url = f"{HOST}/allocate/check_play_status" headers = { 'Accept': 'application/json, text/javascript, */*; q=0.01', 'X-Client-Type': 'web', 'Browser-Code': _HEARTBEAT_CONFIG["browser_code"], 'Content-Type': 'application/json;charset=UTF-8', 'User-Agent': _HEARTBEAT_CONFIG["ua"], 'Referer': _HEARTBEAT_CONFIG["referer"], 'Cookie': _HEARTBEAT_CONFIG["cookie"], 'Origin': HOST } payload = { "playTime": _HEARTBEAT_CONFIG["playTime"], "seriesId": _HEARTBEAT_CONFIG["seriesId"] } requests.post(url, json=payload, headers=headers, verify=False, timeout=5) except Exception: pass time.sleep(60) # ==================== 本地精准分流 M3U8 服务器 ==================== class M3U8Handler(BaseHTTPRequestHandler): def do_GET(self): global _GLOBAL_M3U8_DATA, _TS_BASE_URL # 🔥 修复核心:只有显式请求 play.m3u8 时才吐出索引文本 if "play.m3u8" in self.path: if _GLOBAL_M3U8_DATA: self.send_response(200) self.send_header("Content-Type", "application/vnd.apple.mpegurl") self.send_header("Connection", "close") self.send_header("Access-Control-Allow-Origin", "*") encoded = _GLOBAL_M3U8_DATA.encode("utf-8") self.send_header("Content-Length", str(len(encoded))) self.end_headers() self.wfile.write(encoded) else: self.send_response(404) self.end_headers() # 🔥 修复核心:播放器请求真实的视频切片时,将其 302 重定向到远程真实视频服务器 else: # 清洗出切片文件名 ts_file = self.path.split('?')[0].lstrip('/') if ts_file and _TS_BASE_URL: # 拼接成真实的远程 TS 切片完整地址 remote_ts_url = f"{_TS_BASE_URL}/{ts_file}" self.send_response(302) self.send_header("Location", remote_ts_url) self.end_headers() else: self.send_response(404) self.end_headers() def log_message(self, format, *args): pass def get_free_port(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(('127.0.0.1', 0)) port = s.getsockname()[1] s.close() return port def start_local_server(): global _LOCAL_PORT _LOCAL_PORT = get_free_port() server = HTTPServer(('127.0.0.1', _LOCAL_PORT), M3U8Handler) t = threading.Thread(target=server.serve_forever, daemon=True) t.start() t_heartbeat = threading.Thread(target=heartbeat_worker, daemon=True) t_heartbeat.start() start_local_server() class Spider(Spider): def getName(self): return "粤漫之家" def init(self, extend=""): pass # ==================== 分类 ==================== def homeContent(self, filter): classes = [ {"type_id": "c0-s0-v0-l0-t0-y0/time_desc", "type_name": "全部(国/粤)"}, {"type_id": "c1-s0-v0-l0-t0-y0/time_desc", "type_name": "粤语"}, {"type_id": "c2-s0-v0-l0-t0-y0/time_desc", "type_name": "国语"}, {"type_id": "c1-s0-v2-l0-t0-y0/time_desc", "type_name": "粤语·剧场版"}, ] return {"class": classes} # ==================== 列表 ==================== def categoryContent(self, tid, pg, filter, extend): curr_pg = int(pg) url = f"{HOST}/list/{curr_pg}/{tid}" if curr_pg > 1 else f"{HOST}/list/1/{tid}" res = requests.get(url, verify=False, timeout=10, headers={ "User-Agent": "Mozilla/5.0" }) html = res.text pattern = re.compile( r']*el-col-adapt[^>]*>.*?' r'href="[^"]*/play/(\d+)"[^>]*>.*?' r']*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*>.*?' r'(?:]*tips[^>]*>([^<]*))?.*?' r'\s*\s*', re.S ) vod_list = [] seen = set() for m in pattern.finditer(html): vid = m.group(1) pic = m.group(2) name = m.group(3) remark = m.group(4) if vid in seen: continue seen.add(vid) vod_list.append({ "vod_id": vid, "vod_name": name.strip() if name else "未知", "vod_pic": pic.strip() if pic else "", "vod_remarks": remark.strip() if remark else "" }) has_next = bool(re.search(r'class="next"|下一页', html)) return { "page": curr_pg, "pagecount": curr_pg + (1 if has_next else 0), "limit": 18, "total": 999, "list": vod_list } # ==================== 详情 ==================== def detailContent(self, ids): vid = ids[0] url = f"{HOST}/play/{vid}" res = requests.get(url, verify=False, timeout=10, headers={ "User-Agent": "Mozilla/5.0", "Referer": HOST }) html = res.text name_match = re.search(r']*>([^<]+)', html) vod_name = name_match.group(1).strip() if name_match else "未知影片" content_match = re.search(r'class="intro-detailh"[^>]*>(.*?)', html, re.S) vod_content = re.sub(r'<[^>]+>', '', content_match.group(1)).strip() if content_match else "暂无简介" ep_pattern = re.compile( r']*href="[^"]*/play/(\d+)/(\d+)"[^>]*>.*?]*>([^<]+)', re.S ) play_urls = [] seen = set() for m in ep_pattern.finditer(html): vid = m.group(1) sid = m.group(2) ep_name = m.group(3).strip() if sid in seen: continue seen.add(sid) play_urls.append(f"{ep_name}$/{vid}/{sid}") return { "list": [{ "vod_id": vid, "vod_name": vod_name, "vod_pic": "", "vod_play_from": "默认线路", "vod_play_url": "#".join(play_urls), "vod_content": vod_content }] } # ==================== 播放 ==================== def playerContent(self, flag, id, vipFlags): global _GLOBAL_M3U8_DATA, _LOCAL_PORT, _HEARTBEAT_CONFIG, _TS_BASE_URL parts = id.strip("/").split("/") if len(parts) != 2: return {"parse": 0, "url": "", "header": {}, "msg": f"id格式错误: {id}"} vid, sid = parts[0], parts[1] page_url = f"{HOST}/play/{vid}/{sid}" UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" BROWSER_CODE = "23eba08b88d695ab91292e6358b7db400db021e4ed10799cdd6aafae222c1d6f23b80da12bc0871aa114baef1ac684e6" MAGIC_COOKIE = f"lang=zh-CN; browser-code={BROWSER_CODE}" sess = requests.Session() try: r1 = sess.get(page_url, verify=False, timeout=10, headers={"User-Agent": UA, "Cookie": MAGIC_COOKIE}) html = r1.text except Exception as e: return {"parse": 0, "url": "", "header": {}, "msg": f"播放页请求失败: {e}"} play_time_match = re.search(r'playTime\s*[:=]\s*["\']([^"\']+)["\']', html) detected_play_time = play_time_match.group(1) if play_time_match else "d6dd31bfdcd3466f832c506db420a0e3" enc_match = re.search(r'value\s*=\s*"([^"]+)"', html) if not enc_match: return {"parse": 0, "url": "", "header": {}, "msg": "enc未找到"} enc = enc_match.group(1).strip() try: key_bytes = "AVSI6788^765idue".encode("utf-8") cipher = AES.new(key_bytes, AES.MODE_ECB) ct_bytes = bytes.fromhex(enc) pt_bytes = cipher.decrypt(ct_bytes) pad_len = pt_bytes[-1] if 1 <= pad_len <= 16 and all(x == pad_len for x in pt_bytes[-pad_len:]): pt_bytes = pt_bytes[:-pad_len] if b"?t=" in pt_bytes: p_parts = pt_bytes.split(b"?t=", 1) base_path = p_parts[0].decode("utf-8", errors="ignore").strip().rstrip("/") t_raw_str = p_parts[1].decode("utf-8", errors="ignore").strip() t_val = "".join(c for c in t_raw_str if c.isalnum()) else: dec_text = pt_bytes.decode("utf-8", errors="ignore").strip() p = dec_text.split("?t=") base_path = p[0].strip().rstrip("/") t_val = "".join(c for c in p[1] if c.isalnum()) if len(p) > 1 else "" except Exception as e: return {"parse": 0, "url": "", "header": {}, "msg": f"解密环节底层异常: {e}"} if not base_path.startswith("/"): base_path = "/" + base_path alloc = f"{HOST}{base_path}/{sid}?t={t_val}&vId={vid}" headers = { "User-Agent": UA, "Referer": page_url, "Origin": HOST, "Cookie": MAGIC_COOKIE } try: r2 = sess.get(alloc, headers=headers, verify=False, timeout=10) body = r2.text.strip() except Exception as e: return {"parse": 0, "url": "", "header": {}, "msg": f"分配接口请求失败: {e}"} def decrypt_second_layer(hex_data): try: cipher_inner = AES.new(key_bytes, AES.MODE_ECB) ct_inner = bytes.fromhex(hex_data.strip()) pt_inner = cipher_inner.decrypt(ct_inner) pad = pt_inner[-1] if 1 <= pad <= 16: pt_inner = pt_inner[:-pad] return pt_inner.decode("utf-8", errors="ignore").strip() except Exception: return "" m3u8_content = "" if body.startswith("#EXTM3U"): m3u8_content = body elif re.match(r"^[a-fA-F0-9]+$", body): m3u8_content = decrypt_second_layer(body) if not m3u8_content.startswith("#EXTM3U"): return {"parse": 0, "url": "", "header": {}, "msg": f"未成功解析出M3U8列表结构"} # 🔥 新增:动态捕获当前流媒体切片的 Base 域名基础路径,供本地重定向使用 # 针对不带域名的相对路径切片,补齐远程服务器前缀 ts_url_match = re.search(r'(https?://[^/\s]+(?:/[^/\s]+)*)/[^/\s]+\.ts', m3u8_content) if ts_url_match: _TS_BASE_URL = ts_url_match.group(1) else: # 如果 M3U8 里本身全是相对路径,则默认以分配接口所在目录为基准 _TS_BASE_URL = alloc.rsplit('/', 1)[0] # 激活并注入心跳 _HEARTBEAT_CONFIG["seriesId"] = str(sid) _HEARTBEAT_CONFIG["playTime"] = detected_play_time _HEARTBEAT_CONFIG["cookie"] = MAGIC_COOKIE _HEARTBEAT_CONFIG["ua"] = UA _HEARTBEAT_CONFIG["referer"] = page_url _HEARTBEAT_CONFIG["browser_code"] = BROWSER_CODE _HEARTBEAT_CONFIG["active"] = True _GLOBAL_M3U8_DATA = m3u8_content local_play_url = f"http://127.0.0.1:{_LOCAL_PORT}/play.m3u8" return { "parse": 0, "play": 1, "url": local_play_url, "header": { "User-Agent": UA, "Referer": HOST } } # ==================== 搜索 ==================== def searchContent(self, key, quick, pg=1): curr_pg = int(pg) url = f"{HOST}/search?p={curr_pg}&keyword={key}" if curr_pg > 1 else f"{HOST}/search?keyword={key}" res = requests.get(url, verify=False, timeout=10, headers={ "User-Agent": "Mozilla/5.0", "Referer": HOST }) html = res.text pattern = re.compile( r']*item-row[^>]*>.*?' r']*href="[^"]*/play/(\d+)"[^>]*>.*?' r']*src="([^"]+)"[^>]*alt="([^"]*)".*?>.*?' r']*item-title[^>]*>.*?]*>([^<]*)', re.S ) vod_list = [] seen = set() for m in pattern.finditer(html): vid, pic, alt, title = m.groups() if vid in seen: continue seen.add(vid) # ✅ 关键修复:解码 HTML 实体 + 去标签 raw_name = alt or title or '' raw_name = _html.unescape(raw_name) # < → < raw_name = re.sub(r'<[^>]+>', '', raw_name) # 去 name = raw_name.strip() or '未知' vod_list.append({ "vod_id": vid, "vod_name": name, "vod_pic": pic.strip(), "vod_remarks": "" }) has_next = bool(re.search(r'下一页|class="next"|class="page-next"', html)) return { "page": curr_pg, "pagecount": curr_pg + (1 if has_next else 0), "limit": len(vod_list), "total": 999, "list": vod_list }