import sys import re import json import requests from urllib.parse import unquote, urljoin, quote from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from base.spider import Spider requests.packages.urllib3.disable_warnings() class Spider(Spider): def getName(self): return "PPNIX影视" def init(self, extend=""): super().init(extend) self.site_url = "https://www.ppnix.com" self.headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": self.site_url } self.sess = requests.Session() self.sess.verify = False def fetch(self, url, headers=None): try: h = headers or self.headers resp = self.sess.get(url, headers=h, timeout=15) resp.encoding = 'utf-8' return resp.text if resp.status_code == 200 else "" except Exception as e: return "" def homeContent(self, filter): return {"class": [ {"type_name": "电影", "type_id": "cn/movie"}, {"type_name": "电视剧", "type_id": "cn/tv"} ]} def homeVideoContent(self): html = self.fetch(self.site_url + "/cn/") return {"list": self.parse_list(html)} def categoryContent(self, tid, pg, filter, extend): if int(pg) <= 1: url = f"{self.site_url}/{tid}/" else: url = f"{self.site_url}/{tid}/page/{pg}.html" html = self.fetch(url) return { "list": self.parse_list(html), "page": pg, "pagecount": 100, "limit": 20 } def detailContent(self, ids): vod_id = ids[0] html = self.fetch(self.site_url + vod_id) if not html: return {"list": []} # 1. 提取标题 title_match = re.search(r']*>([^<]+)', html) title = title_match.group(1).strip() if title_match else "未知" title = re.sub(r'\s*\(?\d{4}\)?.*', '', title).strip() # 2. 提取图片 pic_match = re.search(r']*>.*?]+src=["\']([^"\']+)["\']', html, re.S) pic = pic_match.group(1) if pic_match else "" if pic.startswith('//'): pic = 'https:' + pic # 3. 提取简介 desc_match = re.search(r'简介:\s*(.*?)', html, re.S) desc = desc_match.group(1).strip() if desc_match else "" # 4. 提取 classid 和 infoid classid_match = re.search(r'classid\s*=\s*(\d+)', html) infoid_match = re.search(r'infoid\s*=\s*(\d+)', html) classid = classid_match.group(1) if classid_match else "1" infoid = infoid_match.group(1) if infoid_match else "" if not infoid: id_nums = re.findall(r'\d+', vod_id) infoid = id_nums[-1] if id_nums else "" # 5. 判断类型并生成播放链接 is_movie = '/movie/' in vod_id lang_path = "/cn" if "/cn/" in vod_id else "" # 提取 m3u8 数组(画质选项) m3u8_match = re.search(r'm3u8\s*=\s*(\[[^\]]+\])', html) play_list = [] if m3u8_match: try: m3u8_data = eval(m3u8_match.group(1)) if isinstance(m3u8_data, list): if len(m3u8_data) > 1 or not is_movie: # 多集电视剧 for i, quality in enumerate(m3u8_data, 1): ep_name = f"第{i}集" if not is_movie else f"{quality}版" play_list.append(f"{ep_name}${lang_path}/play/{infoid}-{classid}-{i}.html") else: # 单集电影 play_list.append(f"正片${lang_path}/play/{infoid}-{classid}-1.html") except: play_list.append(f"正片${lang_path}/play/{infoid}-{classid}-1.html") else: play_list.append(f"正片${lang_path}/play/{infoid}-{classid}-1.html") play_url = "#".join(play_list) if play_list else "" return {"list": [{ "vod_id": vod_id, "vod_name": title, "vod_pic": pic, "vod_content": desc, "vod_play_from": "PPNIX", "vod_play_url": play_url }]} def playerContent(self, flag, id, vipFlags): """ 获取播放地址 id格式: /cn/play/6276-1-1.html 其中: infoid=6276, classid=1, episode=1 """ # 1. 解析参数 match = re.search(r'/(\d+)-(\d+)-(\d+)', id) if not match: return {"parse": 1, "url": self.site_url + id if id.startswith('/') else id} infoid = match.group(1) classid = match.group(2) episode = match.group(3) # 2. 构造请求头 player_headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": self.site_url + id, "Origin": self.site_url, "X-Requested-With": "XMLHttpRequest", "Connection": "keep-alive" } # 3. 尝试API接口获取真实播放地址 api_url = f"https://www.ppnix.com/index.php/api/player?type={classid}&id={infoid}&ep={episode}" try: print(f"🎬 请求API: {api_url}") resp = self.sess.get(api_url, headers=player_headers, timeout=10) if resp.status_code == 200: content = resp.text.strip() print(f"📦 API返回: {content[:200] if content else '空'}") # 检查是否是m3u8内容 if content and '#EXTM3U' in content: print(f"✅ 直接获取到m3u8: {api_url}") return {"parse": 0, "url": api_url, "header": player_headers} # 检查是否是JSON if content.startswith('{') or content.startswith('['): try: data = json.loads(content) # 提取视频URL video_url = None if isinstance(data, dict): if 'url' in data and data['url']: video_url = data['url'] elif 'data' in data and isinstance(data['data'], dict) and 'url' in data['data']: video_url = data['data']['url'] elif 'video' in data and data['video']: video_url = data['video'] if video_url: print(f"✅ 从JSON提取到视频地址: {video_url}") return {"parse": 0, "url": video_url, "header": player_headers} except json.JSONDecodeError: pass except Exception as e: print(f"⚠️ API请求异常: {e}") # 4. 尝试备用API(info/m3u8接口) backup_apis = [ f"https://www.ppnix.com/info/m3u8/{infoid}/{episode}.m3u8", f"https://www.ppnix.com/info/m3u8/{infoid}/1080P.m3u8", f"https://www.ppnix.com/m3u8/{infoid}/{episode}.m3u8", ] for api in backup_apis: try: resp = self.sess.get(api, headers=player_headers, timeout=8) if resp.status_code == 200 and resp.text and '#EXTM3U' in resp.text: print(f"✅ 从备用API获取到m3u8: {api}") return {"parse": 0, "url": api, "header": player_headers} except: continue # 5. 尝试从播放页源码提取 play_page_url = self.site_url + id print(f"🔄 尝试从播放页提取: {play_page_url}") play_html = self.fetch(play_page_url, headers=self.headers) if play_html: # 查找video标签或配置中的url patterns = [ r']+src=["\']([^"\']+\.m3u8[^"\']*)["\']', r'source\s+src=["\']([^"\']+\.m3u8[^"\']*)["\']', r'file:\s*["\']([^"\']+\.m3u8[^"\']*)["\']', r'url:\s*["\']([^"\']+\.m3u8[^"\']*)["\']', r'data-video-url=["\']([^"\']+\.m3u8[^"\']*)["\']', r'(https?://[^\s\'"<>]+\.m3u8[^\s\'"<>]*)', ] for pattern in patterns: matches = re.findall(pattern, play_html, re.I) for match_url in matches: if match_url and (match_url.endswith('.m3u8') or '.m3u8?' in match_url): if match_url.startswith('//'): match_url = 'https:' + match_url elif match_url.startswith('/'): match_url = self.site_url + match_url print(f"✅ 从HTML提取到m3u8: {match_url}") return {"parse": 0, "url": match_url, "header": player_headers} # 6. 最后的fallback - 返回播放页让外部解析器处理 print(f"❌ 所有方法失败,返回播放页: {play_page_url}") return {"parse": 1, "url": play_page_url} def parse_list(self, html): video_list = [] if not html: return video_list # 兼容多种列表布局 patterns = [ r'
  • .*?]+src="([^"]+)".*?alt="([^"]+)"', ] for pattern in patterns: matches = re.finditer(pattern, html, re.S) for m in matches: vod_id = m.group(1) pic = m.group(2) name = m.group(3).strip() if not vod_id or not name: continue if pic.startswith('//'): pic = 'https:' + pic elif pic.startswith('/'): pic = self.site_url + pic video_list.append({ "vod_id": vod_id, "vod_name": name, "vod_pic": pic, "vod_remarks": "" }) if video_list: break return video_list def searchContent(self, key, quick, pg=1): url = f"{self.site_url}/cn/index.php/vodsearch/wd/{quote(key)}.html" if int(pg) > 1: url = f"{self.site_url}/cn/index.php/vodsearch/wd/{quote(key)}/page/{pg}.html" html = self.fetch(url) return {"list": self.parse_list(html)}