# 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":"<uuid>"}
  创建播放会话 POST /api/playback/sessions body: {"episode_id":"<id>"}
               Header: Authorization: Bearer <access_token>
               -> 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:<category_id>   某分类
       search:<keyword>    搜索（分页由 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>"
      # 为了便于播放时定位，把 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 形如 "<drama_id>@<episode_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}
