Files
tvbox_collect/FGBLH/py/剧OK.py
T
2026-07-22 16:25:35 +00:00

508 lines
19 KiB
Python

# -*- coding: utf-8 -*-
"""
juok3.top (剧OK) 爬虫
- 首页: /api/filter API 获取推荐
- 分类: /api/filter?catId= /type= API
- 详情: /api/detail?cat=&id= API
- 搜索: /api/search?q=关键词
- 播放: 官源链接 + 解析线路, 需要WebView解析
"""
import sys
import re
import json
from urllib.parse import urljoin, quote
sys.path.append('..')
try:
from base.spider import Spider
except ImportError:
class Spider:
def fetch(self, url, headers=None, **kw):
import requests as rq
kw.pop('timeout', None)
r = rq.get(url, headers=headers, timeout=15, **kw)
r.encoding = 'utf-8'
return r
def _(x): return x
HOST = "https://juok3.top"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
# 分类映射: type_id -> (catId, type参数, 中文名)
CLASS_MAP = {
"movie": (1, None, "电影"),
"tv": (2, None, "电视剧"),
"variety": (3, None, "综艺"),
"anime": (4, None, "动漫"),
"short": (2, "短剧", "短剧"), # type=短剧, cat用2
}
# 外部解析线路 (拼接URL) - 从 mojiez.net 提取的38个解析接口
PARSE_APIS = [
{"name": "默认接口", "url": "https://jx.xmflv.com/?url="},
{"name": "789解析1", "url": "https://jiexi.789jiexi.icu:4433/?url="},
{"name": "789解析2", "url": "https://jiexi.789jiexi.com/?url="},
{"name": "我看VIP", "url": "https://a.wkvip.net/?url="},
{"name": "8090", "url": "https://www.8090g.cn/?url="},
{"name": "极速解析", "url": "https://jx.2s0.cn/player/?url="},
{"name": "super", "url": "https://super.playr.top/?url="},
{"name": "fongmi", "url": "https://json.fongmi.cc/web?url="},
{"name": "8090备用", "url": "https://www.8090g.cn/jiexi/?url="},
{"name": "M3U8解析", "url": "https://jx.m3u8.tv/jx/jx.php?url="},
{"name": "Jn1解析", "url": "https://yparse.jn1.cc/index.php?url="},
{"name": "CK解析", "url": "https://www.ckplayer.vip/jiexi/?url="},
{"name": "Player-JY", "url": "https://jx.playerjy.com/?url="},
{"name": "HLS解析", "url": "https://jx.hls.one/?url="},
{"name": "冰豆解析", "url": "https://bd.jx.cn/?url="},
{"name": "剖元解析", "url": "https://www.pouyun.com/?url="},
{"name": "973解析", "url": "https://jx.973973.xyz/?url="},
{"name": "七哥解析", "url": "https://jx.nnxv.cn/tv.php?url="},
{"name": "PlayM3U8", "url": "https://www.playm3u8.cn/jiexi.php?url="},
{"name": "937解析", "url": "https://bfq.937auth.vip?url="},
{"name": "芒果TV专线", "url": "https://video.isyour.love/player/getplayer?url="},
{"name": "M1907", "url": "https://im1907.top/?jx="},
{"name": "夜幕解析", "url": "https://www.yemu.xyz/?url="},
{"name": "盘古解析", "url": "https://www.pangujiexi.com/jiexi/?url="},
{"name": "Yparse", "url": "https://jx.yparse.com/index.php?url="},
{"name": "搜影片名称", "url": "https://z1.m1907.top/?jx="},
{"name": "超清VIP", "url": "https://jx.xmflv.cc/?url="},
{"name": "vip视频解析1", "url": "https://www.8090.la/8090/?url="},
{"name": "vip接口2", "url": "http://jiexi44.qmbo.cn/jiexi/?url="},
{"name": "vip视频解析3", "url": "https://jx.parwix.com:4433/player/?url="},
{"name": "全能VIP解析", "url": "http://api.apii.top/?v="},
{"name": "备用线路1", "url": "https://svip.bljiex.cc/?v="},
{"name": "备用线路2", "url": "https://yparse.ik9.cc/index.php?url="},
{"name": "爱豆", "url": "https://jx.aidouer.net/?url="},
{"name": "YT", "url": "https://jx.yangtu.top/?url="},
{"name": "qianqi", "url": "https://api.qianqi.net/vip/?url="},
{"name": "花旗解析", "url": "https://www.huaqi.live/?url="},
]
# 平台名称映射
PLATFORM_NAMES = {
"qiyi": "爱奇艺", "iqiyi": "爱奇艺",
"imgo": "芒果TV", "mgtv": "芒果TV",
"qq": "腾讯视频", "v.qq": "腾讯视频",
"youku": "优酷",
"leshi": "乐视", "le": "乐视",
"sohu": "搜狐",
"bilibili": "B站",
"1905": "1905",
"pptv": "PPTV",
"xigua": "西瓜视频",
"douyin": "抖音",
"kuaishou": "快手",
}
# 搜索缓存: 用于保存苹果CMS搜索结果的播放信息
_search_cache = {}
class Spider(Spider):
def init(self, extend=""):
self._load_config()
def _load_config(self):
"""从 /api/config 加载解析线路等配置 (已禁用, 使用固定配置)"""
pass
def getName(self):
return "juok3"
def isVideoFormat(self, url):
return ".m3u8" in url or ".mp4" in url or ".flv" in url
def manualVideoCheck(self):
return True
def _cover(self, raw):
"""统一封面URL"""
if not raw:
return ""
if raw.startswith("//"):
return "https:" + raw
return raw
def _year(self, pubdate):
"""从pubdate提取年份"""
if pubdate and len(pubdate) >= 4:
return pubdate[:4]
return ""
def _build_filter_url(self, tid, pg=1):
"""构建分类API URL"""
info = CLASS_MAP.get(tid)
if not info:
return None
cat_id, type_name, _ = info
params = []
if cat_id is not None:
params.append(f"catId={cat_id}")
if type_name:
params.append(f"type={quote(type_name)}")
params.append(f"page={pg}")
params.append("size=24")
return f"{HOST}/api/filter?{'&'.join(params)}"
def _items_from_filter(self, data, cat_id=None):
"""从 /api/filter 返回的JSON中提取影片列表"""
items = []
movies = data.get("movies", [])
for m in movies:
vid = f"detail:{cat_id or 2}:{m.get('id', '')}"
cover = self._cover(m.get("cdncover") or m.get("cover", ""))
# 构建备注信息
remarks = ""
if m.get("upinfo"):
remarks = str(m["upinfo"])
elif m.get("total"):
remarks = f"{m['total']}集"
# 电影等没有upinfo时, 用年份+地区
if not remarks:
year = self._year(m.get("pubdate", ""))
area = ""
if m.get("area"):
if isinstance(m["area"], list) and m["area"]:
area = m["area"][0]
else:
area = str(m["area"])
parts = [p for p in [year, area] if p]
if parts:
remarks = " ".join(parts)
items.append({
"vod_id": vid,
"vod_name": m.get("title", "").strip(),
"vod_pic": cover,
"vod_remarks": remarks,
"vod_year": self._year(m.get("pubdate", "")),
})
return items
def _extract_items_from_search_api(self, data):
"""从搜索API返回的JSON中提取影片列表"""
global _search_cache
items = []
results = data.get("results", [])
for item in results:
if "vod_name" in item:
# 苹果CMS格式 (外部源)
vod_id = str(item.get("vod_id", ""))
if not vod_id:
continue
source_key = item.get("sourceKey", "")
vid = f"search:{vod_id}:{source_key}"
pic = item.get("vod_pic", "")
if pic and pic.startswith("//"):
pic = "https:" + pic
# 缓存播放信息用于详情页
_search_cache[vid] = {
"vod_name": item.get("vod_name", "").strip(),
"vod_pic": pic,
"vod_remarks": item.get("vod_remarks", ""),
"vod_year": item.get("vod_year", ""),
"vod_play_url": item.get("vod_play_url", ""),
"vod_play_from": item.get("vod_play_from", ""),
}
items.append({
"vod_id": vid,
"vod_name": item.get("vod_name", "").strip(),
"vod_pic": pic,
"vod_remarks": item.get("vod_remarks", ""),
"vod_year": item.get("vod_year", ""),
})
elif "title" in item:
# 360kan格式
title = item.get("title", "").replace("<b>", "").replace("</b>", "").strip()
cat_id = item.get("cat_id", "")
en_id = item.get("en_id", "")
if not cat_id or not en_id:
continue
vid = f"detail:{cat_id}:{en_id}"
pic = item.get("cover", "")
if pic and pic.startswith("//"):
pic = "https:" + pic
items.append({
"vod_id": vid,
"vod_name": title,
"vod_pic": pic,
"vod_remarks": item.get("cat_name", ""),
})
return items
def homeContent(self, filter=False):
classes = []
for k, v in CLASS_MAP.items():
classes.append({"type_id": k, "type_name": v[2]})
return {"class": classes}
def homeVideoContent(self):
try:
# 首页推荐: 从多个分类各取一些, 凑够50部左右
all_items = []
# 电影20条
for cat_id, size in [(1, 20), (2, 15), (3, 8), (4, 7)]:
url = f"{HOST}/api/filter?catId={cat_id}&page=1&size={size}"
r = self.fetch(url, headers={"User-Agent": UA}, timeout=15000)
text = r.text if hasattr(r, 'text') else str(r)
data = json.loads(text)
items = self._items_from_filter(data, cat_id=cat_id)
all_items.extend(items)
return {"list": all_items}
except:
return {"list": []}
def categoryContent(self, tid, pg=1, filter=False, extend=None):
"""分类内容 - 调用 /api/filter API"""
try:
pn = 1
try:
pn = max(int(str(pg)), 1)
except:
pass
api_url = self._build_filter_url(tid, pn)
if not api_url:
return {"list": [], "page": pn, "pagecount": 1, "limit": 24, "total": 0}
r = self.fetch(api_url, headers={"User-Agent": UA}, timeout=30000)
text = r.text if hasattr(r, 'text') else str(r)
data = json.loads(text)
cat_id = CLASS_MAP.get(tid, (None, None, None))[0]
items = self._items_from_filter(data, cat_id=cat_id)
total = data.get("total", 0)
# 估算总页数
pagecount = (total // 24) + (1 if total % 24 else 0) if total else 1
return {
"list": items,
"page": pn,
"pagecount": max(pagecount, pn),
"limit": 24,
"total": total,
}
except Exception:
return {"list": [], "page": pg, "pagecount": 1, "limit": 24, "total": 0}
def detailContent(self, ids):
"""详情页 - 调用 /api/detail API"""
try:
vid = str(ids[0]) if ids else ""
if not vid:
return {"list": []}
# 苹果CMS搜索结果的直接播放
if vid.startswith("search:"):
return self._detail_from_search(vid)
if not vid.startswith("detail:"):
return {"list": []}
parts = vid.split(":", 2)
if len(parts) != 3:
return {"list": []}
cat_id, hash_id = parts[1], parts[2]
if not cat_id or not hash_id:
return {"list": []}
# 调用详情API
detail_url = f"{HOST}/api/detail?cat={cat_id}&id={hash_id}"
r = self.fetch(detail_url, headers={"User-Agent": UA, "Referer": HOST}, timeout=30000)
text = r.text if hasattr(r, 'text') else str(r)
data = json.loads(text)
if data.get("errno") != 0 or not data.get("data"):
return {"list": []}
d = data["data"]
# 基本信息
title = d.get("title", "")
cover = self._cover(d.get("cdncover") or d.get("cover", ""))
year = self._year(d.get("pubdate", ""))
area = ", ".join(d.get("area", [])) if isinstance(d.get("area"), list) else str(d.get("area", ""))
type_name = ", ".join(d.get("moviecategory", [])) if isinstance(d.get("moviecategory"), list) else str(d.get("moviecategory", ""))
director = ", ".join(d.get("director", [])) if isinstance(d.get("director"), list) else str(d.get("director", ""))
actor = ", ".join(d.get("actor", [])) if isinstance(d.get("actor"), list) else str(d.get("actor", ""))
content = d.get("description", "")
total = d.get("total", 0)
upinfo = d.get("upinfo", "")
remarks = str(upinfo) if upinfo else (f"{total}集" if total else "")
# 收集所有平台的播放信息
allepi = d.get("allepidetail", {})
pld = d.get("playlinksdetail", {})
# 平台名称映射辅助函数
def _platform_name(site):
return PLATFORM_NAMES.get(site, site)
# 处理多集剧集 (TV series) - 有allepidetail时取第一个平台
if allepi:
first_site = list(allepi.keys())[0]
episodes = allepi[first_site]
pf_list = []
pu_list = []
for api in PARSE_APIS:
ep_list = []
for ep in episodes:
ep_num = ep.get("playlink_num", "")
ep_url = ep.get("url", "")
if not ep_url:
continue
parse_url = api["url"] + ep_url
ep_list.append(f"第{ep_num}集${parse_url}")
if ep_list:
pf_list.append(api["name"])
pu_list.append("#".join(ep_list))
# 处理单集电影/短剧 - 列出所有平台的default_url
elif pld:
pf_list = []
pu_list = []
# 收集所有有default_url的平台
platforms = []
for site, info in pld.items():
default_url = info.get("default_url", "")
if default_url:
platforms.append((site, default_url))
if not platforms:
return {"list": []}
for api in PARSE_APIS:
ep_list = []
for site, url in platforms:
if not url:
continue
parse_url = api["url"] + url
ep_list.append(f"{_platform_name(site)}${parse_url}")
if ep_list:
pf_list.append(api["name"])
pu_list.append("#".join(ep_list))
else:
return {"list": []}
vod = {
"vod_id": vid,
"vod_name": title,
"vod_pic": cover,
"vod_year": year,
"vod_area": area,
"vod_class": type_name,
"vod_director": director,
"vod_actor": actor,
"vod_content": content,
"vod_remarks": remarks,
"vod_play_from": "$$$".join(pf_list),
"vod_play_url": "$$$".join(pu_list),
}
return {"list": [vod]}
except Exception:
return {"list": []}
def _detail_from_search(self, vid):
"""处理搜索结果的直接播放(苹果CMS格式)"""
global _search_cache
try:
cached = _search_cache.get(vid, {})
if not cached or not cached.get("vod_play_url"):
return {"list": []}
# 从缓存构造详情
return {
"list": [{
"vod_id": vid,
"vod_name": cached.get("vod_name", ""),
"vod_pic": cached.get("vod_pic", ""),
"vod_remarks": cached.get("vod_remarks", ""),
"vod_year": cached.get("vod_year", ""),
"vod_play_from": cached.get("vod_play_from", "播放"),
"vod_play_url": cached.get("vod_play_url", ""),
}]
}
except:
return {"list": []}
def searchContent(self, key, quick=False, pg=1):
"""搜索 - 调用API"""
try:
pn = 1
try:
pn = int(str(pg))
except:
pass
url = f"{HOST}/api/search?q={quote(key)}"
if pn > 1:
url += f"&page={pn}"
r = self.fetch(url, headers={
"User-Agent": UA,
"Accept": "application/json, text/plain, */*",
"Referer": HOST + "/",
}, timeout=30000)
text = r.text if hasattr(r, 'text') else str(r)
data = json.loads(text)
items = self._extract_items_from_search_api(data)
return {"list": items, "page": pn}
except:
return {"list": [], "page": 1}
def playerContent(self, flag, id, vipFlags=None):
"""播放 - 官源返回原始URL让壳子解析, 解析线路返回拼接URL"""
url = str(id) if id else str(flag)
if not url:
return {"url": ""}
# 去掉名称前缀
if "$" in url:
parts = url.split("$", 1)
url = parts[1]
# 如果是搜索结果的vod_id
if url.startswith("search:"):
return self._player_from_search(url)
# m3u8直链直接播
if url.startswith("http") and self.isVideoFormat(url):
return {"url": url}
# 视频站链接
if url.startswith("http"):
return {
"url": url,
"parse": 1,
"header": {"User-Agent": UA}
}
return {"url": url, "parse": 1, "header": {"User-Agent": UA}}
def _player_from_search(self, vid):
"""从搜索结果恢复播放链接"""
global _search_cache
try:
cached = _search_cache.get(vid, {})
play_url = cached.get("vod_play_url", "")
if not play_url:
return {"url": ""}
# vod_play_url 格式: 第1集$URL#第2集$URL
# playerContent 传入的 id 已经是单集URL (带$前缀)
# 如果 vid 中没有 $ , 返回第一集
if "$" in vid:
# 这种情况不应该发生, playerContent 已经处理了 $
return {"url": ""}
# 返回第一集URL
first_ep = play_url.split("#")[0]
if "$" in first_ep:
url = first_ep.split("$")[1]
else:
url = first_ep
if url.startswith("http"):
return {"url": url, "parse": 0 if self.isVideoFormat(url) else 1}
return {"url": url}
except:
return {"url": ""}
def localProxy(self, param):
pass