529 lines
18 KiB
Python
529 lines
18 KiB
Python
#coding=utf-8
|
|
#!/usr/bin/python
|
|
"""
|
|
影视仓 (CatVod) Python 爬虫源 - 58影视 (58hu.com)
|
|
===================================================
|
|
|
|
|
|
网站结构 (苹果CMS):
|
|
分类页: /index.php/vod/type/id/{id}.html
|
|
筛选: /index.php/vod/show/class/{类型}/id/{id}/year/{年份}/page/{页码}.html
|
|
详情页: /index.php/vod/detail/id/{id}.html
|
|
播放页: /index.php/vod/play/id/{id}/sid/{sid}/nid/{nid}.html
|
|
搜索: /index.php/vod/search.html?wd={keyword}
|
|
搜索分页: /index.php/vod/search/page/{page}/wd/{keyword}.html
|
|
|
|
播放解析:
|
|
player_data.url -> 第三方链接 (腾讯/优酷/爱奇艺等)
|
|
通过 jxcb.58hu.com 解析接口获取真实播放地址
|
|
playerconfig.js 中定义了所有线路的解析地址
|
|
|
|
分类ID:
|
|
1=电影, 2=连续剧, 3=综艺, 4=动漫, 5=短剧
|
|
6=动作片, 7=喜剧片, 8=爱情片, 9=科幻片, 10=恐怖片, 11=剧情片, 12=战争片
|
|
13=国产剧, 14=港台剧, 15=日韩剧, 16=欧美剧, 17=海外剧
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
import json
|
|
import time
|
|
import random
|
|
from urllib.parse import quote, urljoin
|
|
|
|
sys.path.append("..")
|
|
|
|
try:
|
|
from pyquery import PyQuery as pq
|
|
except ImportError:
|
|
pass
|
|
|
|
from base.spider import Spider
|
|
|
|
|
|
class Spider(Spider):
|
|
|
|
host = "https://www.58hu.com"
|
|
|
|
headers = {
|
|
"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",
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/125.0.0.0 Safari/537.36"
|
|
),
|
|
"Referer": host,
|
|
}
|
|
|
|
# 播放器配置 (来自 playerconfig.js)
|
|
# 所有线路的解析接口
|
|
parse_url = "https://jxcb.58hu.com/?url="
|
|
parse_url2 = "https://qcbl.xundog.com/?url="
|
|
|
|
# 电影子分类
|
|
movie_sub = [
|
|
{"n": "全部", "v": ""},
|
|
{"n": "动作片", "v": "6"},
|
|
{"n": "喜剧片", "v": "7"},
|
|
{"n": "爱情片", "v": "8"},
|
|
{"n": "科幻片", "v": "9"},
|
|
{"n": "恐怖片", "v": "10"},
|
|
{"n": "剧情片", "v": "11"},
|
|
{"n": "战争片", "v": "12"},
|
|
{"n": "纪录片", "v": "22"},
|
|
]
|
|
|
|
# 连续剧子分类
|
|
tv_sub = [
|
|
{"n": "全部", "v": ""},
|
|
{"n": "国产剧", "v": "13"},
|
|
{"n": "港台剧", "v": "14"},
|
|
{"n": "日韩剧", "v": "15"},
|
|
{"n": "欧美剧", "v": "16"},
|
|
{"n": "海外剧", "v": "17"},
|
|
]
|
|
|
|
# 年份选项
|
|
years = [{"n": "全部", "v": ""}]
|
|
for _y in range(2026, 2014, -1):
|
|
years.append({"n": str(_y), "v": str(_y)})
|
|
|
|
# 类型标签 (用于 class 筛选)
|
|
tags = [
|
|
{"n": "全部", "v": ""},
|
|
{"n": "喜剧", "v": "喜剧"},
|
|
{"n": "爱情", "v": "爱情"},
|
|
{"n": "恐怖", "v": "恐怖"},
|
|
{"n": "动作", "v": "动作"},
|
|
{"n": "科幻", "v": "科幻"},
|
|
{"n": "剧情", "v": "剧情"},
|
|
{"n": "战争", "v": "战争"},
|
|
{"n": "犯罪", "v": "犯罪"},
|
|
{"n": "奇幻", "v": "奇幻"},
|
|
{"n": "悬疑", "v": "悬疑"},
|
|
{"n": "动画", "v": "动画"},
|
|
]
|
|
|
|
def init(self, extend=""):
|
|
pass
|
|
|
|
def getName(self):
|
|
return "58影视"
|
|
|
|
def destroy(self):
|
|
pass
|
|
|
|
def action(self, action):
|
|
return None
|
|
|
|
def isVideoFormat(self, url):
|
|
return bool(re.search(r"\.(m3u8|mp4|flv|avi)(\?|$)", url))
|
|
|
|
def manualVideoCheck(self):
|
|
return False
|
|
|
|
# --------------------------------------------------------
|
|
# 首页分类 + 筛选配置
|
|
# --------------------------------------------------------
|
|
def homeContent(self, filter):
|
|
html = self.fetch(self.host, headers=self.headers).text
|
|
data = self.getpq(html)
|
|
|
|
classes = []
|
|
# 从导航栏提取分类
|
|
for a in data("a[href*='/vod/type/']").items():
|
|
href = a.attr("href")
|
|
if href:
|
|
m = re.search(r"id/(\d+)", href)
|
|
if m:
|
|
classes.append({
|
|
"type_name": a.text().strip(),
|
|
"type_id": m.group(1)
|
|
})
|
|
|
|
# 去重
|
|
seen = set()
|
|
unique_classes = []
|
|
for c in classes:
|
|
if c["type_id"] not in seen and c["type_name"]:
|
|
seen.add(c["type_id"])
|
|
unique_classes.append(c)
|
|
|
|
result = {"class": unique_classes[:8]}
|
|
|
|
# 筛选配置
|
|
if filter:
|
|
result["filters"] = {}
|
|
# 电影: 子分类 + 类型标签 + 年份
|
|
result["filters"]["1"] = [
|
|
{"key": "cateId", "name": "分类", "value": self.movie_sub},
|
|
{"key": "class", "name": "类型", "value": self.tags},
|
|
{"key": "year", "name": "年份", "value": self.years},
|
|
]
|
|
# 连续剧: 子分类 + 年份
|
|
result["filters"]["2"] = [
|
|
{"key": "cateId", "name": "分类", "value": self.tv_sub},
|
|
{"key": "year", "name": "年份", "value": self.years},
|
|
]
|
|
# 综艺: 年份
|
|
result["filters"]["3"] = [
|
|
{"key": "year", "name": "年份", "value": self.years},
|
|
]
|
|
# 动漫: 年份
|
|
result["filters"]["4"] = [
|
|
{"key": "year", "name": "年份", "value": self.years},
|
|
]
|
|
# 短剧: 年份
|
|
result["filters"]["5"] = [
|
|
{"key": "year", "name": "年份", "value": self.years},
|
|
]
|
|
|
|
return result
|
|
|
|
# --------------------------------------------------------
|
|
# 首页推荐
|
|
# --------------------------------------------------------
|
|
def homeVideoContent(self):
|
|
html = self.fetch(self.host, headers=self.headers).text
|
|
data = self.getpq(html)
|
|
videos = []
|
|
# 首页结构: div.box-title (标题) + 兄弟 div.layout-box (影片列表)
|
|
for bt in data(".box-title").items():
|
|
h2 = bt.find("h2")
|
|
title = h2.text().strip() if h2 else ""
|
|
if "热播榜" in title:
|
|
continue
|
|
# 找紧跟的兄弟 div.layout-box
|
|
for sib in bt.next_all().items():
|
|
if sib.is_("div") and sib.hasClass("layout-box"):
|
|
for item in sib.find("li.pic-list-hover").items():
|
|
v = self.parse_card(item)
|
|
if v:
|
|
videos.append(v)
|
|
break
|
|
return {"list": videos}
|
|
|
|
# --------------------------------------------------------
|
|
# 分类列表 (支持筛选)
|
|
# --------------------------------------------------------
|
|
def categoryContent(self, tid, pg, filter, extend):
|
|
# 构建筛选URL
|
|
# 格式: /index.php/vod/show/class/{类型}/id/{id}/year/{年份}/page/{页码}.html
|
|
cate_id = tid
|
|
cls = ""
|
|
year = ""
|
|
|
|
if extend:
|
|
if extend.get("cateId"):
|
|
cate_id = extend["cateId"]
|
|
if extend.get("class"):
|
|
cls = extend["class"]
|
|
if extend.get("year"):
|
|
year = extend["year"]
|
|
|
|
url_parts = [f"{self.host}/index.php/vod/show"]
|
|
if cls:
|
|
url_parts.append(f"class/{quote(cls)}")
|
|
url_parts.append(f"id/{cate_id}")
|
|
if year:
|
|
url_parts.append(f"year/{year}")
|
|
url_parts.append(f"page/{pg}.html")
|
|
url = "/".join(url_parts)
|
|
|
|
html = self.fetch(url, headers=self.headers).text
|
|
data = self.getpq(html)
|
|
|
|
videos = self.getlist(data(".border-box, .public-r, .box"))
|
|
|
|
# 解析分页
|
|
pagecount = 9999
|
|
page_text = data(".page").text()
|
|
m = re.search(r"(\d+)/(\d+)", page_text)
|
|
if m:
|
|
pagecount = int(m.group(2))
|
|
|
|
return {
|
|
"list": videos,
|
|
"page": int(pg),
|
|
"pagecount": pagecount,
|
|
"limit": 90,
|
|
"total": 999999
|
|
}
|
|
|
|
# --------------------------------------------------------
|
|
# 详情
|
|
# --------------------------------------------------------
|
|
def detailContent(self, ids):
|
|
url = f"{self.host}/index.php/vod/detail/id/{ids[0]}.html"
|
|
html = self.fetch(url, headers=self.headers).text
|
|
data = self.getpq(html)
|
|
|
|
vod = {
|
|
"vod_id": ids[0],
|
|
"vod_name": data("h1").text().strip() or data("title").text().split("-")[0].strip(),
|
|
"vod_pic": data("img[data-original]").attr("data-original") or data("img.lazyload").attr("data-original") or "",
|
|
"type_name": "",
|
|
"vod_year": "",
|
|
"vod_area": "",
|
|
"vod_remarks": "",
|
|
"vod_actor": "",
|
|
"vod_director": "",
|
|
"vod_content": ""
|
|
}
|
|
|
|
# 解析详情信息
|
|
info_div = data(".video-info")
|
|
if info_div:
|
|
for div in info_div("div").items():
|
|
text = div.text().strip()
|
|
if text.startswith("类型:"):
|
|
vod["type_name"] = text.replace("类型:", "").strip()
|
|
elif text.startswith("年代:"):
|
|
vod["vod_year"] = text.replace("年代:", "").strip()
|
|
elif text.startswith("国家地区:"):
|
|
vod["vod_area"] = text.replace("国家地区:", "").strip()
|
|
elif text.startswith("状态:"):
|
|
vod["vod_remarks"] = text.replace("状态:", "").strip()
|
|
elif text.startswith("主演:"):
|
|
vod["vod_actor"] = text.replace("主演:", "").strip()
|
|
elif text.startswith("导演:"):
|
|
vod["vod_director"] = text.replace("导演:", "").strip()
|
|
elif text.startswith("简介:"):
|
|
vod["vod_content"] = text.replace("简介:", "").strip()
|
|
|
|
# 解析播放源
|
|
tab_links = data("a.gico")
|
|
tab_names = [a.text().strip() for a in tab_links.items() if a.text().strip()]
|
|
|
|
playlists = data("ul.fade-in")
|
|
if not playlists:
|
|
playlists = data("ul.playlist")
|
|
|
|
play_from = []
|
|
play_url = []
|
|
|
|
for i in range(len(playlists)):
|
|
name = f"线路{i + 1}"
|
|
if i < len(tab_names):
|
|
name = tab_names[i]
|
|
play_from.append(name)
|
|
|
|
episodes = []
|
|
ul = playlists.eq(i)
|
|
for li in ul("li a").items():
|
|
title = li.text().strip()
|
|
href = li.attr("href")
|
|
if title and href:
|
|
m = re.search(r"sid/(\d+)/nid/(\d+)", href)
|
|
if m:
|
|
sid, nid = m.group(1), m.group(2)
|
|
episodes.append(f"{title}${ids[0]}/{sid}/{nid}")
|
|
|
|
play_url.append("#".join(episodes))
|
|
|
|
vod["vod_play_from"] = "$$$".join(play_from)
|
|
vod["vod_play_url"] = "$$$".join(play_url)
|
|
|
|
return {"list": [vod]}
|
|
|
|
# --------------------------------------------------------
|
|
# 搜索
|
|
# --------------------------------------------------------
|
|
def searchContent(self, key, quick, pg="1"):
|
|
if str(pg) == "1":
|
|
url = f"{self.host}/index.php/vod/search.html?wd={quote(key)}"
|
|
else:
|
|
url = f"{self.host}/index.php/vod/search/page/{pg}/wd/{quote(key)}.html"
|
|
|
|
html = self.fetch(url, headers=self.headers).text
|
|
data = self.getpq(html)
|
|
videos = []
|
|
|
|
# 搜索结果在 h2.font16 a 中 (ul.pic-list.box-news-list > li > h2 > a)
|
|
for h2 in data("h2.font16").items():
|
|
a = h2.find("a[href*='/vod/detail/']")
|
|
if not a:
|
|
continue
|
|
href = a.attr("href")
|
|
if not href:
|
|
continue
|
|
|
|
m = re.search(r"id/(\d+)", href)
|
|
if not m:
|
|
continue
|
|
|
|
vid = m.group(1)
|
|
title = a.text().strip()
|
|
if not title:
|
|
continue
|
|
|
|
# 图片在 li 层级 (h2 的祖父元素)
|
|
pic = ""
|
|
li = h2.closest("li")
|
|
if li:
|
|
img = li.find("img")
|
|
if img:
|
|
pic = img.attr("data-original") or img.attr("src") or ""
|
|
|
|
# 提取状态信息 (li > div > p 第一个)
|
|
remarks = ""
|
|
if li:
|
|
p = li.find("p.text-muted")
|
|
if p:
|
|
remarks = p.text().strip()
|
|
|
|
videos.append({
|
|
"vod_id": vid,
|
|
"vod_name": title,
|
|
"vod_pic": pic,
|
|
"vod_remarks": remarks
|
|
})
|
|
|
|
return {"list": videos, "page": pg}
|
|
|
|
# --------------------------------------------------------
|
|
# 播放解析
|
|
# --------------------------------------------------------
|
|
def playerContent(self, flag, id, vipFlags):
|
|
"""
|
|
flag: 播放源名称
|
|
id: videoId/sid/nid
|
|
"""
|
|
parts = str(id).split("/")
|
|
if len(parts) != 3:
|
|
return {"parse": 1, "url": "", "header": self.headers}
|
|
|
|
video_id, sid, nid = parts
|
|
url = f"{self.host}/index.php/vod/play/id/{video_id}/sid/{sid}/nid/{nid}.html"
|
|
|
|
html = self.fetch(url, headers=self.headers).text
|
|
|
|
play_url = ""
|
|
source_from = ""
|
|
|
|
# 从 player_data JSON 提取
|
|
m = re.search(r'var\s+player_data\s*=\s*(\{[^<]+\})', html)
|
|
if m:
|
|
try:
|
|
pd = json.loads(m.group(1))
|
|
play_url = pd.get("url", "").replace("\\/", "/")
|
|
source_from = pd.get("from", "")
|
|
except (json.JSONDecodeError, KeyError):
|
|
pass
|
|
|
|
# 备用: 从 iframe 提取
|
|
if not play_url:
|
|
m = re.search(r'<iframe[^>]+src="([^"]+)"', html)
|
|
if m:
|
|
play_url = m.group(1).replace("\\/", "/")
|
|
|
|
if not play_url:
|
|
return {"parse": 1, "url": f"{self.host}{url}", "header": self.headers}
|
|
|
|
# 如果已经是直链格式
|
|
if self.isVideoFormat(play_url):
|
|
return {
|
|
"parse": 0,
|
|
"playUrl": "",
|
|
"url": play_url,
|
|
"header": self.headers
|
|
}
|
|
|
|
# 第三方平台链接 -> 通过解析接口获取真实地址
|
|
# 解析接口: jxcb.58hu.com/?url= (来自 playerconfig.js)
|
|
# 该接口返回 MizhiPlayerART 播放器页面,内含 AES 解密逻辑
|
|
# 影视仓无法直接执行 JS,所以返回 parse=1 让影视仓处理
|
|
jx_url = self.parse_url + play_url
|
|
|
|
return {
|
|
"parse": 1,
|
|
"playUrl": "",
|
|
"url": jx_url,
|
|
"header": {
|
|
"User-Agent": self.headers["User-Agent"],
|
|
"Referer": self.host,
|
|
}
|
|
}
|
|
|
|
# --------------------------------------------------------
|
|
# 本地代理
|
|
# --------------------------------------------------------
|
|
def localProxy(self, param):
|
|
return None
|
|
|
|
# --------------------------------------------------------
|
|
# 工具方法
|
|
# --------------------------------------------------------
|
|
def getlist(self, data):
|
|
"""从容器中提取视频列表"""
|
|
videos = []
|
|
|
|
# 方式1: box-title + layout-box (首页结构)
|
|
for bt in data(".box-title").items():
|
|
h2 = bt.find("h2")
|
|
title = h2.text().strip() if h2 else ""
|
|
if "热播榜" in title:
|
|
continue
|
|
for sib in bt.next_all().items():
|
|
if sib.is_("div") and sib.hasClass("layout-box"):
|
|
for item in sib.find("li.pic-list-hover").items():
|
|
v = self.parse_card(item)
|
|
if v:
|
|
videos.append(v)
|
|
break
|
|
|
|
# 方式2: 直接在容器中查找 (分类列表页)
|
|
if not videos:
|
|
for item in data.find("li.pic-list-hover").items():
|
|
v = self.parse_card(item)
|
|
if v:
|
|
videos.append(v)
|
|
|
|
return videos
|
|
|
|
def parse_card(self, item):
|
|
"""解析单个影片卡片"""
|
|
try:
|
|
a = item.find("a[href*='/vod/detail/']")
|
|
if not a:
|
|
return None
|
|
|
|
href = a.attr("href")
|
|
title = a.attr("title") or a.text().strip()
|
|
|
|
if not href or not title:
|
|
return None
|
|
|
|
m = re.search(r"id/(\d+)", href)
|
|
if not m:
|
|
return None
|
|
|
|
vid = m.group(1)
|
|
|
|
img = item.find("img")
|
|
pic = img.attr("data-original") or img.attr("src") or ""
|
|
|
|
score = item.find("span.score").text().strip()
|
|
status = item.find("span.titles").text().strip()
|
|
|
|
remarks = score
|
|
if status:
|
|
remarks = f"{score} {status}".strip() if score else status
|
|
|
|
return {
|
|
"vod_id": vid,
|
|
"vod_name": title,
|
|
"vod_pic": pic,
|
|
"vod_remarks": remarks
|
|
}
|
|
except Exception:
|
|
return None
|
|
|
|
def getpq(self, data):
|
|
"""创建 PyQuery 对象"""
|
|
try:
|
|
return pq(data)
|
|
except Exception:
|
|
return pq(data.encode("utf-8"))
|