# -*- coding: utf-8 -*- # by @Qist """ 网飞猫 (ncat25.com / ncat1.app) - Netflix/韩剧在线观看 cdndefend 反爬:SHA1 工作量证明,每域名 hash 不同,动态求解 """ import re import time import hashlib import requests from urllib.parse import quote from base.spider import Spider class Spider(Spider): def getName(self): return '网飞猫' def init(self, extend=""): # 多域名,按顺序尝试(CDN 可能封 IP) self.hosts = ['https://www.ncat25.com', 'https://www.ncat1.app'] self.header = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', 'Accept-Language': 'zh-CN,zh;q=0.9', } self.timeout = 20 self.session = requests.Session() self.session.headers.update(self.header) self._token = '' self.host = self.hosts[0] self._ensure_host() def isVideoFormat(self, url): pass def manualVideoCheck(self): pass def destroy(self): pass def _crack(self, host): """对指定域名解 cdndefend 挑战,成功返回 True""" try: r = self.session.get(host + '/', timeout=self.timeout, allow_redirects=False) except: return False m = re.search(r"a0_0x2a54=\['([0-9A-F]{40})'", r.text) if not m: # 没有挑战页,说明 cookie 已生效或无需挑战 return True h = m.group(1) n1 = int(h[0], 16) i = 0 while True: d = hashlib.sha1((h + str(i)).encode()).digest() if d[n1] == 0xb0 and d[n1 + 1] == 0x0b: break i += 1 if i > 20000000: return False domain = host.split('//')[1].split('/')[0] self.session.cookies.set('cdndefend_js_cookie', h + str(i), domain=domain) r2 = self.session.get(host + '/', timeout=self.timeout) return 'cdndefend' not in r2.text and 'a0_0x2a54' not in r2.text def _ensure_host(self): """选择第一个可用域名并解挑战""" for h in self.hosts: self.host = h self.header['Referer'] = h + '/' self.session.headers['Referer'] = h + '/' if self._crack(h): return def _safe_get(self, url, headers=None): try: resp = self.session.get(url, headers=headers, timeout=self.timeout) resp.encoding = 'utf-8' if resp.status_code == 200: return resp except: pass return None def _get_token(self): """搜索所需的 t token(从首页搜索表单提取)""" if self._token: return self._token resp = self._safe_get(self.host + '/') if resp: m = re.search(r'name="t" value="([^"]+)"', resp.text) if m: self._token = m.group(1) return self._token def _pic(self, src): if not src: return '' if src.startswith('http'): return src return self.host + src def _parse_videos(self, html): """解析 module-item 列表 / search-result-item 搜索项""" videos = [] seen = set() # 频道/首页列表 for m in re.finditer( r'
\s*]*>' r'.*?
\s*\s*([^<]*?)\s*.*?' r'
([^<]*)
', html, re.S): url, vid, remarks, name = m.group(1), m.group(2), m.group(3), m.group(4) name = name.strip() if not name or vid in seen: continue seen.add(vid) # 封面:取真实图(跳过 logo 占位) pic = '' pm = re.search(r'data-original="([^"]+)"', m.group(0)) while pm: p = pm.group(1) if 'logo_placeholder' not in p and 'vod_pc_static_ncat' not in p: pic = p break pm = re.search(r'data-original="([^"]+)"', m.group(0)[pm.end():]) videos.append({ 'vod_id': vid, 'vod_name': name, 'vod_pic': self._pic(pic), 'vod_remarks': remarks.strip(), }) # 搜索项 if not videos: for m in re.finditer( r'
' r'.*?]+data-original="([^"]+)"[^>]*>', html, re.S): url, vid, pic = m.group(1), m.group(2), m.group(3) if vid in seen: continue seen.add(vid) nm = re.search(r']+alt="([^"]+)"', m.group(0)) videos.append({ 'vod_id': vid, 'vod_name': nm.group(1) if nm else '', 'vod_pic': self._pic(pic), 'vod_remarks': '', }) return videos # ------------------------------------------------------------------ # # 首页 # ------------------------------------------------------------------ # def homeContent(self, filter): result = {'class': [], 'list': []} for tid, name in [('1', '电影'), ('2', '连续剧'), ('3', '动漫'), ('4', '综艺纪录'), ('6', '短剧')]: result['class'].append({'type_name': name, 'type_id': tid}) resp = self._safe_get(self.host + '/') if resp: result['list'] = self._parse_videos(resp.text)[:30] return result def homeVideoContent(self): return {} # ------------------------------------------------------------------ # # 分类(每频道固定 48 条,无分页) # ------------------------------------------------------------------ # def categoryContent(self, tid, pg, filter, extend): pg = int(pg) result = {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0} resp = self._safe_get(f'{self.host}/channel/{tid}.html') if not resp: return result videos = self._parse_videos(resp.text) result['list'] = videos result['limit'] = len(videos) result['total'] = len(videos) return result # ------------------------------------------------------------------ # # 详情 # ------------------------------------------------------------------ # def detailContent(self, ids): if not ids or not ids[0]: return {'list': []} vid = ids[0] resp = self._safe_get(f'{self.host}/detail/{vid}.html') if not resp: return {'list': []} html = resp.text # 标题(.detail-title 里的 strong,排除水印域名) name = '' dm = re.search(r'class="detail-title[^"]*"[^>]*>(.*?)
', html, re.S) if dm: for m in re.finditer(r']*>([^<]{2,60})', dm.group(1)): t = m.group(1).strip() # 水印用数学字母(U+1D400 起)伪装域名,排除之 if t and not any('\U0001D400' <= ch <= '\U0001D7FF' for ch in t) and not re.search(r'[a-zA-Z0-9]{2,}\.', t): name = t break # 封面 pic = '' m = re.search(r'data-original="(/vod1/vod/cover/[^"]+)"', html) if m: pic = m.group(1) # 详情信息 info = {} for m in re.finditer(r'class="detail-info-row-side">([^<]*)
\s*
(.*?)
', html, re.S): side = m.group(1).strip().rstrip('::') main = re.sub(r'\s+', ' ', re.sub(r'<[^>]+>', ' ', m.group(2))).strip() if side in ('导演', '演员', '首映', '备注'): info[side] = main vod = { 'vod_id': vid, 'vod_name': name, 'vod_pic': self._pic(pic), 'vod_remarks': info.get('备注', ''), 'vod_year': (info.get('首映') or '')[:4], 'vod_director': info.get('导演', ''), 'vod_actor': info.get('演员', ''), 'vod_content': '', } # 播放列表:source-item 线路名 + episode-list 集数 sources = re.findall(r'([^<]*)', html) box = re.search(r'
]*>(.*?)
\s*\s*', html, re.S) play_from, play_url = [], [] if box and sources: lists = re.findall(r'
]*>(.*?)
', box.group(1), re.S) for idx, src in enumerate(sources): if idx >= len(lists): break eps = re.findall(r'href="(/play/[^"]+)"[^>]*class="episode-item"[^>]*>\s*([^<]*)', lists[idx]) if not eps: continue play_from.append(src) play_url.append('#'.join(f'{ep}$' + self.host + href for href, ep in eps)) if not play_url: return {'list': []} vod['vod_play_from'] = '$$$'.join(play_from) vod['vod_play_url'] = '$$$'.join(play_url) return {'list': [vod]} # ------------------------------------------------------------------ # # 搜索(需首页 t token) # ------------------------------------------------------------------ # def searchContent(self, key, quick, pg="1"): result = {'list': []} token = self._get_token() if not token: return result try: url = f'{self.host}/search?k={quote(str(key))}&t={quote(token)}' resp = self.session.get(url, headers={'Referer': self.host + '/'}, timeout=self.timeout) resp.encoding = 'utf-8' result['list'] = self._parse_videos(resp.text) except: pass return result # ------------------------------------------------------------------ # # 播放(明文 m3u8 直接嵌在播放页 JS) # ------------------------------------------------------------------ # def playerContent(self, flag, id, vipFlags): h = {'User-Agent': self.header['User-Agent'], 'Referer': self.host + '/'} resp = self._safe_get(id, {'Referer': id}) if not resp: return {'parse': 1, 'url': id, 'header': h, 'playUrl': ''} html = resp.text m = re.search(r'playSource\s*=\s*\{\s*src:\s*"([^"]+)"', html) if m and m.group(1).startswith('http'): return {'parse': 0, 'url': m.group(1), 'header': h, 'playUrl': ''} return {'parse': 1, 'url': id, 'header': h, 'playUrl': ''} def fetch(self, url): try: resp = self.session.get(url, timeout=self.timeout) resp.encoding = 'utf-8' return resp.text if resp.status_code == 200 else None except: return None def localProxy(self, param): return None