# coding=utf-8 #!/usr/bin/python """ 香蕉短剧(xiangjiaoai.ai)TVBox / FongMi 影视源爬虫 站点结构(Next.js + REST API,实测 2026-10): 剧场列表 GET /api/theater?limit=&cursor=&category_id=&serial_status= 分类列表 GET /api/categories 搜索 GET /api/search?q=&sort=relevance&limit= 剧集详情 GET /api/dramas/{drama_id} 分集列表 GET /api/dramas/{drama_id}/episodes 创建游客会话 POST /api/guest-sessions body: {"device_id":""} 创建播放会话 POST /api/playback/sessions body: {"episode_id":""} Header: Authorization: Bearer -> data.media.manifest_url 即 m3u8 播放直链(直连 CDN,无需代理) 播放链路:详情页拿分集 -> 分集 id 换播放会话 -> manifest_url 直接交给播放器。 """ import json import re import sys import time import uuid import requests from urllib3.util.retry import Retry from base.spider import Spider sys.path.append("..") class Spider(Spider): def init(self, extend="{}"): # 站点配置 self.site = "https://xiangjiaoai.ai" # 备用域名(如主域名变更,可在此追加) self.hosts = [ "https://xiangjiaoai.ai", ] self.host = self.hosts[0] 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" ) self.headers = { "User-Agent": user_agent, "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Origin": self.host, "Referer": f"{self.host}/", } # 每页条数 self.page_size = 50 # 游客会话缓存 self.device_id = str(uuid.uuid4()) self.access_token = "" self.token_expire_at = 0 # 分类 id -> 名称 缓存(供详情页展示) self._category_map = {} # 当前剧集被记录用于详情刷新(沿用通用约定) self.cur_drama_id = "" self.create_session_with_retry() # ------------------------------------------------------------------ # # 基础请求 # ------------------------------------------------------------------ # 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 _headers(self, extra=None): h = dict(self.headers) if extra: h.update(extra) return h def _get_json(self, path, params=None, timeout=(5, 12)): """GET 请求,自动在多个备用域名间重试""" last_err = None for host in self.hosts: try: url = f"{host}{path}" r = self.session.get( url, params=params, headers=self._headers(), timeout=timeout ) if r.status_code == 200: if host != self.host: self.log(f"[HOST] 切换域名为: {host}") self.host = host self.headers["Origin"] = host self.headers["Referer"] = f"{host}/" return r.json() self.log(f"[API] {path} 状态码 {r.status_code}") except Exception as e: last_err = e self.log(f"[API] {host}{path} 异常: {e}") continue if last_err: self.log(f"[API] 全部域名请求失败: {last_err}") return {} def _post_json(self, path, payload, headers=None, timeout=(5, 15)): url = f"{self.host}{path}" r = self.session.post( url, json=payload, headers=self._headers(headers or {"Content-Type": "application/json"}), timeout=timeout, ) return r # ------------------------------------------------------------------ # # 游客会话 / 播放授权 # ------------------------------------------------------------------ # def _ensure_token(self): """获取(或复用)游客 access_token""" if self.access_token and time.time() < self.token_expire_at - 60: return self.access_token try: r = self._post_json("/api/guest-sessions", {"device_id": self.device_id}) if r.status_code in (200, 201): data = (r.json() or {}).get("data", {}) token = data.get("access_token", "") if token: self.access_token = token # 有效期保守按 1 小时刷新 self.token_expire_at = time.time() + 3600 self.log("游客会话已创建") return token self.log(f"游客会话创建失败: {r.status_code} {r.text[:120]}") except Exception as e: self.log(f"游客会话异常: {e}") return "" def _get_play_url(self, episode_id): """用分集 id 换取 m3u8 播放直链""" token = self._ensure_token() if not token: return "" try: r = self._post_json( "/api/playback/sessions", {"episode_id": episode_id}, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", }, ) if r.status_code in (200, 201): media = (r.json() or {}).get("data", {}).get("media", {}) or {} return media.get("manifest_url", "") or media.get("url", "") self.log(f"播放会话失败: {r.status_code} {r.text[:120]}") # token 可能过期,清空后重试一次 if r.status_code == 401: self.access_token = "" self.token_expire_at = 0 except Exception as e: self.log(f"播放会话异常: {e}") return "" # ------------------------------------------------------------------ # # 通用工具 # ------------------------------------------------------------------ # @staticmethod def _fix_poster(url): """封面图地址规范化""" return str(url or "") def _category_names(self, item): names = [] for c in item.get("categories") or []: if isinstance(c, dict) and c.get("name"): names.append(c["name"]) if not names: for t in item.get("tags") or []: if isinstance(t, dict) and t.get("name"): names.append(t["name"]) return names def _build_remark(self, item): """组装备注:集数 + 状态""" parts = [] ep = item.get("episode_count") or item.get("latest_episode_number") or 0 if ep: parts.append(f"共{ep}集") status = str(item.get("serial_status") or "") if status == "serializing": parts.append("连载中") elif status == "completed": parts.append("已完结") cats = self._category_names(item) if cats: parts.append(cats[0]) return " | ".join(parts) if parts else "" def _to_video(self, item): """把 API 的剧集对象转成 TVBox 列表项""" return { "vod_id": str(item.get("id", "")), "vod_name": str(item.get("title", "")), "vod_pic": str(item.get("cover_url") or item.get("poster") or ""), "vod_remarks": self._build_remark(item), "style": {"type": "rect", "ratio": 1.78}, } # ------------------------------------------------------------------ # # 标准接口 # ------------------------------------------------------------------ # def getName(self): return "香蕉短剧" def isVideoFormat(self, url): pass def manualVideoCheck(self): pass def destroy(self): pass def homeVideoContent(self): """首页推荐:热门榜(items 元素形如 {"rank":1,"score":..,"drama":{...}})""" try: rsp = self._get_json("/api/rankings/hot", {"limit": 20}) data = rsp.get("data") or {} items = data.get("items") if isinstance(data, dict) else data if not isinstance(items, list): items = [] videos = [] for it in items: # 兼容两种结构:直接是剧集对象,或包在 drama 字段里 obj = (it.get("drama") or it) if isinstance(it, dict) else {} if obj.get("id"): videos.append(self._to_video(obj)) return {"list": videos} except Exception as e: self.log(f"获取首页推荐失败: {e}") return {"list": []} def homeContent(self, filter): """分类导航 + 筛选器""" classes = [ {"type_name": "剧场", "type_id": "theater:all"}, {"type_name": "连载中", "type_id": "theater:serializing"}, {"type_name": "已完结", "type_id": "theater:completed"}, ] # 动态拉取分类标签 try: rsp = self._get_json("/api/categories") cats = rsp.get("data") or [] for c in cats: cid = c.get("id") name = c.get("name") if cid and name: self._category_map[cid] = name classes.append({"type_name": name, "type_id": f"cat:{cid}"}) except Exception as e: self.log(f"获取分类失败: {e}") filters = {} return {"class": classes, "filters": filters} def categoryContent(self, tid, pg, filter, extend): """分类/搜索内容。tid 约定: theater:all 全部 theater:serializing 连载中 theater:completed 已完结 cat: 某分类 search: 搜索(分页由 pg 控制,内部用游标翻页) """ try: page = max(1, int(str(pg))) except Exception: page = 1 params = {"limit": self.page_size} if tid.startswith("search:"): key = tid.split(":", 1)[1] # 搜索接口按游标翻页;page>1 时先翻到对应页 cursor = "" for _ in range(page - 1): rsp = self._get_json( "/api/search", {"q": key, "sort": "relevance", "limit": self.page_size, "cursor": cursor}, ) data = rsp.get("data") or {} cursor = data.get("next_cursor") or "" if not cursor: return self._empty(pg) rsp = self._get_json( "/api/search", {"q": key, "sort": "relevance", "limit": self.page_size, "cursor": cursor}, ) data = rsp.get("data") or {} videos = [self._to_video(it) for it in (data.get("items") or [])] total = int(data.get("total") or 0) return self._result(videos, pg, total, has_next=bool(data.get("next_cursor"))) # 分类 / 剧场 if tid.startswith("theater:"): mode = tid.split(":", 1)[1] if mode in ("serializing", "completed"): params["serial_status"] = mode elif tid.startswith("cat:"): params["category_id"] = tid.split(":", 1)[1] # 游标翻页 cursor = "" for _ in range(page - 1): rsp = self._get_json("/api/theater", dict(params, cursor=cursor)) data = rsp.get("data") or {} cursor = data.get("next_cursor") or "" if not cursor: return self._empty(pg) rsp = self._get_json("/api/theater", dict(params, cursor=cursor)) data = rsp.get("data") or {} videos = [self._to_video(it) for it in (data.get("items") or [])] total = int(data.get("total") or 0) return self._result(videos, pg, total, has_next=bool(data.get("next_cursor"))) def _result(self, videos, pg, total, has_next=False): limit = self.page_size if total > 0: pagecount = (total + limit - 1) // limit else: pagecount = int(pg) + (1 if has_next else 0) return { "list": videos, "page": str(pg), "pagecount": str(max(pagecount, int(pg))), "limit": str(limit), "total": str(total), } def _empty(self, pg): return { "list": [], "page": str(pg), "pagecount": "1", "limit": str(self.page_size), "total": "0", } def searchContent(self, key, quick, pg="1"): try: page = max(1, int(str(pg))) except Exception: page = 1 rsp = self._get_json( "/api/search", {"q": key, "sort": "relevance", "limit": self.page_size} ) data = rsp.get("data") or {} videos = [self._to_video(it) for it in (data.get("items") or [])] total = int(data.get("total") or 0) return { "list": videos, "page": str(page), "pagecount": str(max(1, (total + self.page_size - 1) // self.page_size)) if total else "1", "limit": str(self.page_size), "total": str(total), } def detailContent(self, array): try: drama_id = str(array[0]) info_rsp = self._get_json(f"/api/dramas/{drama_id}") info = (info_rsp.get("data") or {}) if isinstance(info_rsp, dict) else {} eps_rsp = self._get_json(f"/api/dramas/{drama_id}/episodes") episodes = (eps_rsp.get("data") or []) if isinstance(eps_rsp, dict) else [] if not isinstance(episodes, list): episodes = [] title = str(info.get("title") or "") desc_parts = [] if info.get("description"): desc_parts.append(str(info["description"])) cats = self._category_names(info) if cats: desc_parts.append("分类:" + " / ".join(cats)) if info.get("episode_count"): desc_parts.append(f"总集数:{info['episode_count']}") if info.get("source_updated_at"): desc_parts.append(f"更新时间:{str(info['source_updated_at'])[:19]}") # 播放列表:按集数排序,格式 "第N集$|" # 为了便于播放时定位,把 drama_id 与 episode_id 一起编码进行号 play_list = [] for ep in sorted( episodes, key=lambda x: (x.get("season_number") or 1, x.get("episode_number") or 0) ): ep_id = ep.get("id") if not ep_id: continue num = ep.get("episode_number") or 0 name = f"第{num}集" if num else str(ep.get("title") or "正片") play_list.append(f"{name}${drama_id}@{ep_id}") if not play_list: # 没有分集时退化为直接播放剧集 play_list = [f"正片${drama_id}@"] self.cur_drama_id = drama_id return { "list": [{ "vod_id": drama_id, "vod_name": title, "vod_pic": str(info.get("cover_url") or info.get("poster") or ""), "vod_year": str(info.get("source_updated_at") or "")[:4], "vod_area": "中国", "vod_remarks": self._build_remark(info), "vod_content": "\n".join(desc_parts), "vod_play_from": "香蕉短剧", "vod_play_url": "#".join(play_list), }] } except Exception as e: self.log(f"获取详情失败: {e}") return {"list": []} def playerContent(self, flag, id, vipFlags): """id 形如 "@";有 episode_id 时换取播放会话直链""" try: raw = str(id) episode_id = "" if "@" in raw: _, episode_id = raw.split("@", 1) if not episode_id: # 兜底:按剧集重新拉一次分集 drama_id = raw.split("@", 1)[0] eps_rsp = self._get_json(f"/api/dramas/{drama_id}/episodes") episodes = (eps_rsp.get("data") or []) if isinstance(eps_rsp, dict) else [] if isinstance(episodes, list) and episodes: episodes = sorted( episodes, key=lambda x: (x.get("season_number") or 1, x.get("episode_number") or 0), ) episode_id = episodes[0].get("id", "") if not episode_id: self.log(f"未找到分集 id: {id}") return {"url": "", "parse": 0} manifest = self._get_play_url(str(episode_id)) if not manifest: return {"url": "", "parse": 0} headers = { "User-Agent": self.headers["User-Agent"], "Referer": f"{self.host}/", "Origin": self.host, } return { "url": manifest, "parse": "0", "position": "0", "header": headers, } except Exception as e: self.log(f"播放失败 {id}: {e}") return {"url": "", "parse": 0}