#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ztzssz.com 爬虫 - TVBox/影视仓 Spider 插件 支持分类浏览、筛选(类型/地区/语言/年份/字母)、搜索、详情获取、播放链接解析 选集正序排列,海报封面补充 """ import re import json import logging import urllib.parse import os import sys import requests from bs4 import BeautifulSoup sys.path.append(os.path.dirname(os.path.abspath(__file__))) try: from base.spider import Spider as BaseSpider except ImportError: BaseSpider = object logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) class Spider(BaseSpider): """ztzssz.com 爬虫""" BASE_URL = "https://www.ztzssz.com" HEADERS = { "User-Agent": "Mozilla/5.0 (Linux; Android 12; SM-S908U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://www.ztzssz.com/", } # 分类:键为网站分类ID,与 /vodtype/{id}.html 对应 CATEGORY_MAP = { "1": {"name": "电影", "url": "/vodtype/1.html"}, "2": {"name": "电视剧", "url": "/vodtype/2.html"}, "3": {"name": "综艺", "url": "/vodtype/3.html"}, "4": {"name": "动漫", "url": "/vodtype/4.html"}, "20": {"name": "短剧", "url": "/vodtype/20.html"}, "35": {"name": "动画片", "url": "/vodtype/35.html"}, "36": {"name": "4K电影", "url": "/vodtype/36.html"}, "37": {"name": "Netflix作品", "url": "/vodtype/37.html"}, } def __init__(self): try: super().__init__() except Exception: pass self.session = requests.Session() self.session.verify = False self.session.headers.update(self.HEADERS) def init(self, extend): pass def getName(self): return "ztzssz影视" def _parse_ext(self, ext): """解析ext参数,兼容dict和JSON字符串""" if not ext: return {} if isinstance(ext, dict): return ext if isinstance(ext, str): try: return json.loads(ext) except Exception: return {} return {} def _get(self, url): try: resp = self.session.get(url, timeout=30) resp.encoding = "utf-8" return resp except Exception as e: logger.error(f"请求失败 {url}: {e}") return None # ==================== 首页 ==================== def homeContent(self, filter=False): try: url = f"{self.BASE_URL}/" resp = self._get(url) if not resp: return {} classes = [{"type_id": cid, "type_name": info["name"]} for cid, info in self.CATEGORY_MAP.items()] home_list = self._parse_video_list(resp.text) return { "class": classes, "filters": self._get_filters(), "list": home_list, } except Exception as e: logger.error(f"获取首页失败: {e}") return {} def homeVideoContent(self): home = self.homeContent() return {"list": home.get("list", [])} # ==================== 筛选 ==================== def _get_filters(self): """筛选配置:类型/地区/语言/年份/字母 网站筛选URL为中文值(如 喜剧/大陆/国语),故筛选value直接用中文。 """ filters = {} # 类型:按分类区分(电影/电视剧/动漫等类型不同),这里取较通用的集合 type_values_common = [ {"n": "全部", "v": ""}, {"n": "动作", "v": "动作"}, {"n": "喜剧", "v": "喜剧"}, {"n": "爱情", "v": "爱情"}, {"n": "科幻", "v": "科幻"}, {"n": "恐怖", "v": "恐怖"}, {"n": "剧情", "v": "剧情"}, {"n": "战争", "v": "战争"}, {"n": "警匪", "v": "警匪"}, {"n": "犯罪", "v": "犯罪"}, {"n": "动画", "v": "动画"}, {"n": "奇幻", "v": "奇幻"}, {"n": "武侠", "v": "武侠"}, {"n": "冒险", "v": "冒险"}, {"n": "枪战", "v": "枪战"}, {"n": "悬疑", "v": "悬疑"}, {"n": "惊悚", "v": "惊悚"}, {"n": "经典", "v": "经典"}, {"n": "青春", "v": "青春"}, {"n": "文艺", "v": "文艺"}, {"n": "古装", "v": "古装"}, {"n": "历史", "v": "历史"}, {"n": "运动", "v": "运动"}, {"n": "农村", "v": "农村"}, {"n": "儿童", "v": "儿童"}, {"n": "网络电影", "v": "网络电影"}, ] # 电视剧/综艺/动漫常用类型 type_values_series = [ {"n": "全部", "v": ""}, {"n": "古装", "v": "古装"}, {"n": "战争", "v": "战争"}, {"n": "青春偶像", "v": "青春偶像"}, {"n": "喜剧", "v": "喜剧"}, {"n": "家庭", "v": "家庭"}, {"n": "犯罪", "v": "犯罪"}, {"n": "动作", "v": "动作"}, {"n": "奇幻", "v": "奇幻"}, {"n": "剧情", "v": "剧情"}, {"n": "历史", "v": "历史"}, {"n": "经典", "v": "经典"}, {"n": "乡村", "v": "乡村"}, {"n": "情景", "v": "情景"}, {"n": "商战", "v": "商战"}, {"n": "网剧", "v": "网剧"}, {"n": "其他", "v": "其他"}, ] area_values = [ {"n": "全部", "v": ""}, {"n": "大陆", "v": "大陆"}, {"n": "香港", "v": "香港"}, {"n": "台湾", "v": "台湾"}, {"n": "美国", "v": "美国"}, {"n": "法国", "v": "法国"}, {"n": "英国", "v": "英国"}, {"n": "日本", "v": "日本"}, {"n": "韩国", "v": "韩国"}, {"n": "德国", "v": "德国"}, {"n": "泰国", "v": "泰国"}, {"n": "印度", "v": "印度"}, {"n": "意大利", "v": "意大利"}, {"n": "西班牙", "v": "西班牙"}, {"n": "加拿大", "v": "加拿大"}, {"n": "其他", "v": "其他"}, ] lang_values = [ {"n": "全部", "v": ""}, {"n": "国语", "v": "国语"}, {"n": "英语", "v": "英语"}, {"n": "粤语", "v": "粤语"}, {"n": "闽南语", "v": "闽南语"}, {"n": "韩语", "v": "韩语"}, {"n": "日语", "v": "日语"}, {"n": "法语", "v": "法语"}, {"n": "德语", "v": "德语"}, {"n": "其它", "v": "其它"}, ] year_values = [{"n": "全部", "v": ""}] for y in range(2026, 1999, -1): year_values.append({"n": str(y), "v": str(y)}) letter_values = [{"n": "全部", "v": ""}] for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": letter_values.append({"n": letter, "v": letter}) letter_values.append({"n": "其他", "v": "0"}) for cate_id in self.CATEGORY_MAP: if cate_id in ("2", "3", "4", "20"): tv = type_values_series else: tv = type_values_common filters[cate_id] = [ {"key": "type", "name": "类型", "value": tv}, {"key": "area", "name": "地区", "value": area_values}, {"key": "lang", "name": "语言", "value": lang_values}, {"key": "year", "name": "年份", "value": year_values}, {"key": "letter", "name": "字母", "value": letter_values}, ] return filters # ==================== 分类 ==================== def categoryContent(self, tid, pg, filter, ext): try: page = int(pg) if pg else 1 type_id = str(tid) cate_info = self.CATEGORY_MAP.get(type_id) if not cate_info: return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0} ext_dict = self._parse_ext(ext) type_filter = ext_dict.get('type', '') lang_filter = ext_dict.get('lang', '') year_filter = ext_dict.get('year', '') letter_filter = ext_dict.get('letter', '') area_filter = ext_dict.get('area', '') has_filter = any([type_filter, lang_filter, year_filter, letter_filter, area_filter]) if has_filter: # 筛选URL: /vodshow/{cate_id}-{area}--{type}-{lang}-{letter}---{page}---{year}.html # 共12段: [cate_id, area, '', type, lang, letter, '', '', page, '', '', year] seg_page = str(page) if page > 1 else '' segs = [ type_id, area_filter, '', type_filter, lang_filter, letter_filter, '', '', seg_page, '', '', year_filter ] url = f"{self.BASE_URL}/vodshow/{'-'.join(segs)}.html" else: # 无筛选: /vodtype/{id}.html,分页 /vodtype/{id}-{page}.html url = self.BASE_URL + cate_info["url"] if page > 1: url = url.replace('.html', f'-{page}.html') resp = self._get(url) if not resp: return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0} videos = self._parse_video_list(resp.text) pagecount = self._parse_total_pages(resp.text) return { "list": videos, "page": page, "pagecount": pagecount, "limit": 20, "total": len(videos) * pagecount if pagecount else len(videos), } except Exception as e: logger.error(f"获取分类内容失败: {e}") return {"list": [], "page": 1, "pagecount": 1, "limit": 20, "total": 0} def _parse_total_pages(self, html): """解析总页数,格式: 1/1213""" patterns = [ r'class="num"[^>]*>\s*(\d+)\s*/\s*(\d+)', r'(\d+)\s*/\s*(\d+)\s*', r'/vodtype/\d+-(\d+)\.html["\'][^>]*>尾页', r'/vodshow/[^"\'-]+-(\d+)---\.html["\'][^>]*>尾页', ] for pattern in patterns: match = re.search(pattern, html) if match: return int(match.group(2) if match.lastindex and match.lastindex >= 2 else match.group(1)) return 1 def _parse_video_list(self, html): """解析视频列表(首页/分类/筛选/搜索通用) 卡片: a.ewave-vodlist__thumb (含 data-original, title, href) 或 div.ewave-vodlist__thumb > a.thumb-link """ videos = [] soup = BeautifulSoup(html, 'html.parser') # 兼容两种结构:a.vodlist__thumb 自带链接,或 div.vodlist__thumb 内含 a.thumb-link items = soup.find_all('a', class_=re.compile(r'vodlist__thumb')) if not items: items = soup.find_all('div', class_=re.compile(r'vodlist__thumb')) seen_ids = set() for item in items: href = item.get('href', '') if not href: a_inner = item.find('a', href=True) href = a_inner.get('href', '') if a_inner else '' vid_match = re.search(r'/voddetail/(\d+)\.html', href) if not vid_match: continue vid_id = vid_match.group(1) if vid_id in seen_ids: continue seen_ids.add(vid_id) title = item.get('title', '') poster = item.get('data-original', '') if not poster: img = item.find('img') if img: poster = img.get('data-original', '') or img.get('src', '') if not title: img = item.find('img') if img: title = img.get('alt', '') or item.get('title', '') remark_tag = item.find(class_=re.compile(r'pic-text|pic_tag')) remarks = remark_tag.get_text(strip=True) if remark_tag else '' if title: videos.append({ "vod_id": vid_id, "vod_name": title, "vod_pic": poster, "vod_remarks": remarks, }) return videos # ==================== 详情 ==================== def detailContent(self, ids): try: vod_id = ids[0] if isinstance(ids, list) else str(ids) url = f"{self.BASE_URL}/voddetail/{vod_id}.html" resp = self._get(url) if not resp: return {"list": []} html = resp.text soup = BeautifulSoup(html, 'html.parser') # 标题: h1.title 内第一个 span(去除评分) title = '' h1 = soup.find('h1', class_=re.compile(r'title')) if h1: span = h1.find('span') title = span.get_text(strip=True) if span else h1.get_text(strip=True) if not title: h1 = soup.find('h1') if h1: title = h1.get_text(strip=True) # 海报: div.ewave-content__thumb 内 img[data-original] poster = '' thumb_box = soup.find(class_=re.compile(r'content__thumb|vodlist__thumb')) if thumb_box: img = thumb_box.find('img') if img: poster = img.get('data-original', '') or img.get('src', '') # 信息: p.data 列表,一个p内可能含多个 label(类型/地区/年份用 span.text-muted 分隔) year = area = type_name = actor = director = content = remarks = '' data_ps = soup.find_all('p', class_=re.compile(r'data')) for p in data_ps: # 收集该p下所有 label span 及其后续文本,按 label 分组 labels = p.find_all('span', class_=re.compile(r'text-muted|left')) for idx, label_tag in enumerate(labels): label = label_tag.get_text(strip=True) # 该label之后、下一个label之前的所有文本 nxt = labels[idx + 1] if idx + 1 < len(labels) else None val = '' for sib in label_tag.next_siblings: if sib is nxt: break if hasattr(sib, 'get_text'): t = sib.get_text(strip=True) else: t = str(sib).strip() if t: val += t val = val.strip() if label.startswith('类型'): type_name = val elif label.startswith('地区'): area = val elif label.startswith('年份'): year_match = re.search(r'(\d{4})', val) year = year_match.group(1) if year_match else val elif label.startswith('主演'): actor = val elif label.startswith('导演'): director = val elif label.startswith('更新'): remarks = val # 简介: p.desc desc_tag = soup.find('p', class_=re.compile(r'desc|content__desc')) if desc_tag: content = desc_tag.get_text(strip=True) content = re.sub(r'详情\s*$', '', content).strip() # 播放源和集数(正序) play_from_list, play_url_list = self._parse_play_sources(html, vod_id) vod_item = { "vod_id": vod_id, "vod_name": title, "vod_pic": poster, "type_name": type_name, "vod_year": year, "vod_area": area, "vod_remarks": remarks, "vod_actor": actor, "vod_director": director, "vod_content": content, "vod_play_from": '$$$'.join(play_from_list), "vod_play_url": '$$$'.join(play_url_list), } return {"list": [vod_item]} except Exception as e: logger.error(f"获取详情失败: {e}") return {"list": []} def _parse_play_sources(self, html, vod_id): """解析播放源和集数 - 正序排列 结构: ul.nav-tabs > li > a[href="#playlist{sid}"] (源名) div.tab-pane#playlist{sid} > ul > a[href="/vodplay/{vid}-{sid}-{nid}.html"] (集数) """ play_from_list = [] play_url_list = [] soup = BeautifulSoup(html, 'html.parser') # 源名映射: {sid: 源名} source_names = {} nav = soup.find('ul', class_=re.compile(r'nav-tabs')) if nav: for a in nav.find_all('a', href=True): m = re.search(r'#playlist(\w+)', a.get('href', '')) if m: source_names[m.group(1)] = a.get_text(strip=True) # 每个 tab-pane 为一个源 panes = soup.find_all('div', class_=re.compile(r'tab-pane')) if not panes: # 兜底:直接按 play 链接分组 return self._parse_play_sources_fallback(html, vod_id) for pane in panes: pane_id = pane.get('id', '') sid_match = re.search(r'playlist(\w+)', pane_id) if not sid_match: continue sid = sid_match.group(1) from_name = source_names.get(sid, f"线路{sid}") ul = pane.find('ul') if not ul: continue episodes = [] seen_nid = set() for a in ul.find_all('a', href=re.compile(r'/vodplay/')): href = a.get('href', '') m = re.search(r'/vodplay/(\d+)-(\w+)-(\d+)\.html', href) if not m: continue nid = int(m.group(3)) if nid in seen_nid: continue seen_nid.add(nid) ep_name = a.get_text(strip=True) or f"第{nid}集" full_url = self.BASE_URL + href episodes.append((nid, ep_name, full_url)) if not episodes: continue # 正序排列 episodes = sorted(episodes, key=lambda x: x[0]) play_from_list.append(from_name) urls = [f"{name}${u}" for _, name, u in episodes] play_url_list.append('#'.join(urls)) if not play_from_list: return self._parse_play_sources_fallback(html, vod_id) return play_from_list, play_url_list def _parse_play_sources_fallback(self, html, vod_id): """兜底:从所有 play 链接按 sid 分组""" play_from_list = [] play_url_list = [] links = re.findall(r'/vodplay/\d+-\w+-\d+\.html', html) sources = {} for link in links: m = re.match(r'/vodplay/(\d+)-(\w+)-(\d+)\.html', link) if not m: continue sid = m.group(2) nid = int(m.group(3)) sources.setdefault(sid, {})[nid] = link for sid in sorted(sources.keys()): eps = sources[sid] episodes = sorted(eps.items()) play_from_list.append(f"线路{sid}") urls = [f"第{nid}集${self.BASE_URL}{link}" for nid, link in episodes] play_url_list.append('#'.join(urls)) return play_from_list, play_url_list # ==================== 播放 ==================== def playerContent(self, flag, id, vipFlags): """解析播放页 m3u8 链接""" try: url = id # 兼容相对路径 if url.startswith('/'): url = self.BASE_URL + url elif not url.startswith('http'): url = self.BASE_URL + '/vodplay/' + url resp = self._get(url) if not resp: return {} m3u8_url = self._extract_m3u8(resp.text) if not m3u8_url: return {} return { "parse": 0, "playUrl": "", "url": m3u8_url, "header": "", } except Exception as e: logger.error(f"解析播放失败: {e}") return {} def _extract_m3u8(self, html): """从播放页提取 m3u8 链接""" match = re.search(r'player_aaaa\s*=\s*({[^<]+})', html) if match: try: data = json.loads(match.group(1)) m3u8_url = data.get('url', '') if m3u8_url: return m3u8_url except Exception: pass # 兜底:直接匹配 m3u8 m = re.search(r'(https?://[^"\'\\s]+\.m3u8[^"\'\\s]*)', html) return m.group(1) if m else '' # ==================== 搜索 ==================== def searchContent(self, key, quick, pg="1"): """搜索内容 - TVBox标准接口(key, quick, pg) 优先使用AJAX接口(JSON格式,速度快),失败则回退到HTML搜索页 """ try: page = int(pg) if pg else 1 encoded_key = urllib.parse.quote(key) # 优先尝试 AJAX 建议接口(返回JSON,数据干净) ajax_url = f"{self.BASE_URL}/index.php/ajax/suggest?mid=1&wd={encoded_key}" resp = self._get(ajax_url) if resp and resp.headers.get('content-type', '').find('json') >= 0: try: data = resp.json() if data.get('code') == 1 and data.get('list'): videos = [] for item in data['list']: videos.append({ "vod_id": str(item.get('id', '')), "vod_name": item.get('name', ''), "vod_pic": item.get('pic', ''), "vod_remarks": item.get('note', ''), }) return { "list": videos, "page": data.get('page', page), "pagecount": data.get('pagecount', 1), "limit": data.get('limit', 20), "total": data.get('total', len(videos)), } except Exception: pass # 回退到HTML搜索页 url = f"{self.BASE_URL}/vodsearch/-------------.html?wd={encoded_key}" if page > 1: url += f"&page={page}" resp = self._get(url) if not resp: return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0} if any(k in resp.text for k in ['验证码', '人机验证', '安全验证', 'just_a_test']): logger.warning("搜索被安全验证拦截") return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0} videos = self._parse_video_list(resp.text) pagecount = self._parse_total_pages(resp.text) return { "list": videos, "page": page, "pagecount": pagecount if pagecount > 1 else 1, "limit": 20, "total": len(videos) * pagecount if pagecount > 1 else len(videos), } except Exception as e: logger.error(f"搜索失败: {e}") return {"list": [], "page": 1, "pagecount": 1, "limit": 20, "total": 0}