549 lines
21 KiB
Python
549 lines
21 KiB
Python
# coding=utf-8
|
|
import json
|
|
import re
|
|
import sys
|
|
from urllib.parse import parse_qsl, quote, urljoin, urlsplit
|
|
|
|
from lxml import html as lxml_html
|
|
|
|
from base.spider import Spider as BaseSpider
|
|
|
|
sys.path.append("..")
|
|
|
|
|
|
class Spider(BaseSpider):
|
|
def __init__(self):
|
|
self.name = "4KVM"
|
|
self.host = "https://www.4kvm.org"
|
|
self.page_limit = 30
|
|
self.headers = {
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/134.0.0.0 Safari/537.36"
|
|
),
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
"Referer": self.host + "/",
|
|
"Cache-Control": "no-cache",
|
|
}
|
|
self.play_extensions = (".m3u8", ".mp4", ".flv", ".avi", ".mkv", ".ts")
|
|
|
|
def init(self, extend=""):
|
|
return None
|
|
|
|
def getName(self):
|
|
return self.name
|
|
|
|
def _build_url(self, path):
|
|
return urljoin(self.host + "/", str(path or "").strip())
|
|
|
|
def _normalize_url(self, value):
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
return ""
|
|
if raw.startswith("//"):
|
|
return "https:" + raw
|
|
if raw.startswith(("http://", "https://")):
|
|
return raw
|
|
return self._build_url(raw.lstrip("/"))
|
|
|
|
def _encode_site_path(self, value):
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
return ""
|
|
if raw.startswith("//"):
|
|
raw = "https:" + raw
|
|
|
|
if raw.startswith(("http://", "https://")):
|
|
parsed = urlsplit(raw)
|
|
path = parsed.path.lstrip("/")
|
|
query = parsed.query
|
|
else:
|
|
parsed = urlsplit(raw)
|
|
path = parsed.path.lstrip("/")
|
|
query = parsed.query
|
|
|
|
if not path and not query:
|
|
return ""
|
|
return path + ("?" + query if query else "")
|
|
|
|
def _decode_site_path(self, value):
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
return ""
|
|
if raw.startswith(("http://", "https://")):
|
|
return raw
|
|
return self._build_url(raw.lstrip("/"))
|
|
|
|
def _request_html(self, url):
|
|
response = self.fetch(url, headers=self.headers, timeout=10, verify=False)
|
|
if response.status_code != 200:
|
|
return ""
|
|
return response.text or ""
|
|
|
|
def _parse_html(self, html_text):
|
|
text = str(html_text or "").strip()
|
|
if not text:
|
|
return None
|
|
return lxml_html.fromstring(text)
|
|
|
|
def _clean_text(self, value):
|
|
return re.sub(r"\s+", " ", str(value or "").replace("\xa0", " ")).strip()
|
|
|
|
def _first_non_empty(self, values):
|
|
for value in values:
|
|
clean = self._clean_text(value)
|
|
if clean:
|
|
return clean
|
|
return ""
|
|
|
|
def _node_text(self, node):
|
|
if node is None:
|
|
return ""
|
|
return self._clean_text("".join(node.xpath(".//text()")))
|
|
|
|
def _first_xpath_text(self, node, expressions):
|
|
for expr in expressions:
|
|
values = node.xpath(expr)
|
|
if not values:
|
|
continue
|
|
if isinstance(values[0], str):
|
|
text = self._first_non_empty(values)
|
|
else:
|
|
text = self._first_non_empty([self._node_text(item) for item in values])
|
|
if text:
|
|
return text
|
|
return ""
|
|
|
|
def _first_xpath_attr(self, node, expressions):
|
|
for expr in expressions:
|
|
values = node.xpath(expr)
|
|
if values:
|
|
value = self._clean_text(values[0])
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
def _is_allowed_class_link(self, href):
|
|
path = "/" + self._encode_site_path(href).split("?", 1)[0].lstrip("/")
|
|
return bool(re.match(r"^/(movie|tv|anime|filter)(/.*)?$", path))
|
|
|
|
def _default_classes(self):
|
|
return [
|
|
{"type_id": "movie", "type_name": "电影"},
|
|
{"type_id": "tv", "type_name": "电视剧"},
|
|
{"type_id": "anime", "type_name": "动漫"},
|
|
{"type_id": "filter", "type_name": "筛选"},
|
|
]
|
|
|
|
def _extract_video_basic(self, node):
|
|
href = self._first_xpath_attr(
|
|
node,
|
|
[
|
|
".//a[contains(@href,'/play/')]/@href",
|
|
".//a[1]/@href",
|
|
".//h3//a[1]/@href",
|
|
],
|
|
)
|
|
vod_id = self._encode_site_path(href)
|
|
if not vod_id:
|
|
return None
|
|
|
|
title = self._first_xpath_text(
|
|
node,
|
|
[
|
|
".//h3[1]//text()",
|
|
".//*[contains(@class,'data')]//h3[1]//text()",
|
|
".//img[1]/@alt",
|
|
".//a[1]/@title",
|
|
],
|
|
) or "未知标题"
|
|
pic = self._encode_site_path(
|
|
self._first_xpath_attr(node, [".//img[1]/@data-src", ".//img[1]/@src"])
|
|
)
|
|
remarks = self._first_xpath_text(
|
|
node,
|
|
[
|
|
".//*[contains(@class,'absolute') and contains(@class,'bottom-0')]//span[last()]//text()",
|
|
".//*[contains(@class,'text-xs') and contains(@class,'text-gray-400')][last()]//text()",
|
|
".//*[contains(@class,'rating') or contains(@class,'imdb') or contains(@class,'vote')]//text()",
|
|
".//*[contains(@class,'type') or contains(@class,'genre') or contains(@class,'tag')]//text()",
|
|
".//span[last()]//text()",
|
|
],
|
|
)
|
|
|
|
return {
|
|
"vod_id": vod_id,
|
|
"vod_name": title,
|
|
"vod_pic": self._decode_site_path(pic) if pic else "",
|
|
"vod_remarks": remarks,
|
|
}
|
|
|
|
def _card_nodes(self, root):
|
|
if root is None:
|
|
return []
|
|
return root.xpath(
|
|
"//*[contains(@class,'movie-card')]"
|
|
"|//article"
|
|
"|//*[contains(@class,'items')]//article"
|
|
"|//*[contains(@class,'content')]//article"
|
|
)
|
|
|
|
def _parse_video_list(self, html_text):
|
|
root = self._parse_html(html_text)
|
|
seen = set()
|
|
videos = []
|
|
for node in self._card_nodes(root):
|
|
item = self._extract_video_basic(node)
|
|
if not item or item["vod_id"] in seen:
|
|
continue
|
|
seen.add(item["vod_id"])
|
|
videos.append(item)
|
|
return videos
|
|
|
|
def _parse_search_video_list(self, html_text):
|
|
root = self._parse_html(html_text)
|
|
if root is None:
|
|
return []
|
|
|
|
videos = []
|
|
seen = set()
|
|
for node in root.xpath("//a[starts-with(@href,'/play/')]"):
|
|
vod_id = self._encode_site_path(node.get("href"))
|
|
if not vod_id or vod_id in seen:
|
|
continue
|
|
|
|
title = self._first_non_empty(
|
|
[
|
|
self._first_xpath_text(node, [".//h3[1]//text()"]),
|
|
node.xpath(".//img[1]/@alt")[0] if node.xpath(".//img[1]/@alt") else "",
|
|
node.get("title", ""),
|
|
]
|
|
)
|
|
if not title:
|
|
continue
|
|
|
|
pic = self._first_xpath_attr(node, [".//img[1]/@data-src", ".//img[1]/@src"])
|
|
remarks = self._first_xpath_text(
|
|
node,
|
|
[
|
|
".//*[contains(@class,'absolute') and contains(@class,'top-2') and contains(@class,'right-2')]//text()",
|
|
".//*[contains(@class,'absolute') and contains(@class,'bottom-0')]//p//text()",
|
|
],
|
|
)
|
|
|
|
seen.add(vod_id)
|
|
videos.append(
|
|
{
|
|
"vod_id": vod_id,
|
|
"vod_name": title,
|
|
"vod_pic": self._decode_site_path(self._encode_site_path(pic)) if pic else "",
|
|
"vod_remarks": remarks,
|
|
}
|
|
)
|
|
return videos
|
|
|
|
def _page_result(self, items, pg):
|
|
page = int(pg)
|
|
return {
|
|
"list": items,
|
|
"page": page,
|
|
"limit": self.page_limit,
|
|
"total": (max(page, 1) - 1) * self.page_limit + len(items),
|
|
}
|
|
|
|
def homeContent(self, filter):
|
|
html_text = self._request_html(self.host)
|
|
root = self._parse_html(html_text)
|
|
classes = []
|
|
seen = set()
|
|
if root is not None:
|
|
for node in root.xpath("//nav//a[contains(@class,'nav-item')]"):
|
|
name = self._node_text(node)
|
|
href = node.get("href", "")
|
|
type_id = self._encode_site_path(href)
|
|
if (
|
|
not name
|
|
or not type_id
|
|
or name in ["首页", "影片下载", "片单"]
|
|
or not self._is_allowed_class_link(href)
|
|
or type_id in seen
|
|
):
|
|
continue
|
|
seen.add(type_id)
|
|
classes.append({"type_id": type_id, "type_name": name})
|
|
return {"class": classes or self._default_classes()}
|
|
|
|
def homeVideoContent(self):
|
|
html_text = self._request_html(self.host)
|
|
return {"list": self._parse_video_list(html_text)}
|
|
|
|
def _build_category_url(self, tid, pg):
|
|
encoded = self._encode_site_path(tid)
|
|
if not encoded:
|
|
return self.host
|
|
url = self._decode_site_path(encoded)
|
|
page = int(pg)
|
|
if page <= 1:
|
|
return url
|
|
return url + ("&" if "?" in url else "?") + f"page={page}"
|
|
|
|
def categoryContent(self, tid, pg, filter, extend):
|
|
html_text = self._request_html(self._build_category_url(tid, pg))
|
|
return self._page_result(self._parse_video_list(html_text), pg)
|
|
|
|
def filterSearchResults(self, results, search_key):
|
|
if not results or not search_key:
|
|
return results
|
|
|
|
query = self._clean_text(search_key).lower()
|
|
words = [item for item in query.split(" ") if item]
|
|
scored = []
|
|
for item in results:
|
|
title = self._clean_text(item.get("vod_name", "")).lower()
|
|
score = 0
|
|
if query == title:
|
|
score = 100
|
|
elif title.startswith(query):
|
|
score = 80
|
|
elif query in title:
|
|
score = 70
|
|
elif words and all(word in title for word in words):
|
|
score = 60
|
|
else:
|
|
matches = len([word for word in words if word in title])
|
|
if matches == 0:
|
|
continue
|
|
score = 30 + matches * 10
|
|
|
|
if "剧" in query and ("tvshows" in item.get("vod_id", "") or "/tv/" in item.get("vod_id", "")):
|
|
score += 5
|
|
if "电影" in query and ("movies" in item.get("vod_id", "") or "/movie/" in item.get("vod_id", "")):
|
|
score += 5
|
|
scored.append({"score": score, "result": item})
|
|
|
|
scored.sort(key=lambda item: item["score"], reverse=True)
|
|
min_score = 30 if len(words) > 1 else 40
|
|
filtered = [item["result"] for item in scored if item["score"] >= min_score]
|
|
if len(filtered) < 3 and len(scored) > 3:
|
|
return [item["result"] for item in scored[:10]]
|
|
return filtered
|
|
|
|
def searchContent(self, key, quick, pg="1"):
|
|
page = int(pg)
|
|
url = f"{self.host}/search?q={quote(str(key or ''))}"
|
|
if page > 1:
|
|
url += f"&page={page}"
|
|
html_text = self._request_html(url)
|
|
items = self.filterSearchResults(self._parse_search_video_list(html_text), key)
|
|
return self._page_result(items, pg)
|
|
|
|
def _extract_detail_map(self, root):
|
|
detail_map = {}
|
|
if root is None:
|
|
return detail_map
|
|
for node in root.xpath("//*[contains(@class,'grid')]//*[contains(@class,'text-gray-500')]"):
|
|
key = self._node_text(node)
|
|
value_node = node.getnext()
|
|
value = self._node_text(value_node)
|
|
if key and value:
|
|
detail_map[key] = value
|
|
return detail_map
|
|
|
|
def _extract_play_options(self, root, detail_id):
|
|
if root is None:
|
|
return []
|
|
play_links = []
|
|
nodes = root.xpath(
|
|
"//*[@id='playeroptions']//li"
|
|
"|//*[contains(concat(' ', normalize-space(@class), ' '), ' dooplay_player_option ')]"
|
|
)
|
|
for node in nodes:
|
|
title = self._first_xpath_text(node, [".//*[contains(@class,'title')]//text()", ".//span[contains(@class,'title')]//text()"]) or "播放"
|
|
server = self._first_xpath_text(node, [".//*[contains(@class,'server')]//text()", ".//span[contains(@class,'server')]//text()"])
|
|
if server:
|
|
title = f"{title}-{server}"
|
|
|
|
data_post = self._clean_text(node.get("data-post", ""))
|
|
data_nume = self._clean_text(node.get("data-nume", ""))
|
|
data_type = self._clean_text(node.get("data-type", "")) or "movie"
|
|
if not data_post or not data_nume:
|
|
continue
|
|
play_links.append(
|
|
{
|
|
"name": title,
|
|
"url": f"{detail_id}?post={data_post}&nume={data_nume}&type={data_type}",
|
|
}
|
|
)
|
|
return play_links
|
|
|
|
def _extract_episode_sources(self, root, detail_id):
|
|
if root is None:
|
|
return []
|
|
source_map = {}
|
|
order_keys = []
|
|
for node in root.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' episode-link ')][@data-line][@data-episode]"):
|
|
href = self._encode_site_path(node.get("href", ""))
|
|
if not href:
|
|
continue
|
|
line = self._clean_text(node.get("data-line", "")) or "1"
|
|
source_name = f"线路{line}"
|
|
raw_name = self._first_xpath_text(node, [".//span[last()]//text()"]) or self._clean_text(node.get("data-episode", "")) or "播放"
|
|
episode_number = self._clean_text(node.get("data-episode", ""))
|
|
if raw_name.isdigit():
|
|
episode_name = f"第{raw_name}集"
|
|
elif episode_number.isdigit():
|
|
episode_name = f"第{episode_number}集"
|
|
else:
|
|
episode_name = raw_name
|
|
|
|
if source_name not in source_map:
|
|
source_map[source_name] = []
|
|
order_keys.append(source_name)
|
|
|
|
try:
|
|
order = int(episode_number)
|
|
except Exception:
|
|
order = len(source_map[source_name]) + 1
|
|
|
|
source_map[source_name].append({"name": episode_name, "url": href, "order": order})
|
|
|
|
sources = []
|
|
for name in order_keys:
|
|
episodes = source_map[name]
|
|
episodes.sort(key=lambda item: item["order"])
|
|
sources.append({"name": name, "episodes": [{"name": item["name"], "url": item["url"]} for item in episodes]})
|
|
|
|
if sources:
|
|
return sources
|
|
|
|
play_links = self._extract_play_options(root, detail_id)
|
|
if play_links:
|
|
return [{"name": "4KVM", "episodes": play_links}]
|
|
return []
|
|
|
|
def _serialize_play_sources(self, sources):
|
|
valid = [item for item in sources if item.get("episodes")]
|
|
return {
|
|
"vod_play_from": "$$$".join([item.get("name", "") for item in valid]) or "4KVM",
|
|
"vod_play_url": "$$$".join(
|
|
["#".join([f"{ep.get('name', '')}${ep.get('url', '')}" for ep in item.get("episodes", [])]) for item in valid]
|
|
),
|
|
}
|
|
|
|
def detailContent(self, ids):
|
|
detail_id = self._encode_site_path(ids[0] if ids else "")
|
|
html_text = self._request_html(self._decode_site_path(detail_id))
|
|
root = self._parse_html(html_text)
|
|
detail_map = self._extract_detail_map(root)
|
|
|
|
meta_desc = self._first_xpath_attr(root, ["//meta[@name='description']/@content"]) if root is not None else ""
|
|
meta_pic = self._first_xpath_attr(root, ["//meta[@property='og:image']/@content"]) if root is not None else ""
|
|
meta_title = self._first_xpath_attr(root, ["//meta[@property='og:title']/@content"]) if root is not None else ""
|
|
meta_keywords = self._first_xpath_attr(root, ["//meta[@name='keywords']/@content"]) if root is not None else ""
|
|
|
|
vod = {
|
|
"vod_id": detail_id,
|
|
"vod_name": self._first_xpath_text(root, ["//h1[contains(@class,'text-xl')][1]//text()", "//h1[1]//text()"])
|
|
or re.sub(r"\s*-\s*第\d+集.*$", "", meta_title)
|
|
or "未知标题",
|
|
"vod_pic": self._decode_site_path(
|
|
self._encode_site_path(
|
|
self._first_xpath_attr(root, ["//*[contains(@class,'video-player')][1]/@data-poster", "//*[contains(@class,'video-player')]//img[1]/@src"])
|
|
or meta_pic
|
|
)
|
|
),
|
|
"vod_content": self._first_xpath_text(
|
|
root,
|
|
["//*[contains(@class,'bg-dark-800')]//p[contains(@class,'text-xs') and contains(@class,'text-gray-300')][1]//text()"],
|
|
)
|
|
or self._clean_text(meta_desc),
|
|
"vod_year": detail_map.get("年份", ""),
|
|
"vod_area": detail_map.get("地区", "") or detail_map.get("国家/地区", ""),
|
|
"vod_remarks": detail_map.get("状态", ""),
|
|
"vod_actor": detail_map.get("主演", ""),
|
|
"vod_director": detail_map.get("导演", ""),
|
|
}
|
|
|
|
genres = []
|
|
if detail_map.get("类型"):
|
|
genres.extend([self._clean_text(item) for item in detail_map["类型"].split("/") if self._clean_text(item)])
|
|
elif meta_keywords:
|
|
keywords = [self._clean_text(item) for item in meta_keywords.split(",") if self._clean_text(item)]
|
|
genres.extend([item for item in keywords if item not in [vod["vod_name"], vod["vod_director"]]][:1])
|
|
if genres:
|
|
vod["type_name"] = ", ".join(genres)
|
|
|
|
sources = self._extract_episode_sources(root, detail_id)
|
|
if not sources:
|
|
sources = [{"name": "4KVM", "episodes": [{"name": "播放", "url": detail_id}]}]
|
|
play_data = self._serialize_play_sources(sources)
|
|
vod["vod_play_from"] = play_data["vod_play_from"]
|
|
vod["vod_play_url"] = play_data["vod_play_url"]
|
|
return {"list": [vod]}
|
|
|
|
def _is_direct_play_url(self, url):
|
|
lower_url = str(url or "").lower()
|
|
return any(ext in lower_url for ext in self.play_extensions)
|
|
|
|
def _parse_json(self, text):
|
|
try:
|
|
return json.loads(text or "{}")
|
|
except Exception:
|
|
return {}
|
|
|
|
def playerContent(self, flag, id, vipFlags):
|
|
encoded = self._encode_site_path(id)
|
|
parsed = urlsplit(self._decode_site_path(encoded))
|
|
base_id = parsed.path.lstrip("/")
|
|
page_url = self._decode_site_path(base_id)
|
|
params = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
|
data_post = self._clean_text(params.get("post", ""))
|
|
data_nume = self._clean_text(params.get("nume", ""))
|
|
data_type = self._clean_text(params.get("type", "")) or "movie"
|
|
|
|
if data_post and data_nume:
|
|
api_url = f"{self.host}/wp-json/dooplayer/v1/post/{data_post}"
|
|
response = self.fetch(
|
|
api_url,
|
|
params={"type": data_type, "source": data_nume},
|
|
headers=self.headers,
|
|
timeout=10,
|
|
verify=False,
|
|
)
|
|
if response.status_code == 200:
|
|
embed_url = self._clean_text(self._parse_json(response.text).get("embed_url", ""))
|
|
if embed_url:
|
|
return {
|
|
"parse": 0 if self._is_direct_play_url(embed_url) else 1,
|
|
"url": self._normalize_url(embed_url),
|
|
"header": self.headers,
|
|
}
|
|
|
|
html_text = self._request_html(page_url)
|
|
root = self._parse_html(html_text)
|
|
iframe = self._first_xpath_attr(
|
|
root,
|
|
[
|
|
"(//iframe[contains(@class,'metaframe')]/@src)",
|
|
"(//*[contains(@class,'dooplay_player')]//iframe/@src)",
|
|
"(//*[contains(@class,'player')]//iframe/@src)",
|
|
"(//iframe/@src)",
|
|
],
|
|
)
|
|
if iframe:
|
|
iframe_url = self._normalize_url(iframe)
|
|
return {
|
|
"parse": 0 if self._is_direct_play_url(iframe_url) else 1,
|
|
"url": iframe_url,
|
|
"header": self.headers,
|
|
}
|
|
|
|
video_url = self._first_xpath_attr(root, ["(//video/source/@src)", "(//video/@src)"])
|
|
if video_url:
|
|
full_video_url = self._normalize_url(video_url)
|
|
return {"parse": 0, "url": full_video_url, "header": self.headers}
|
|
|
|
return {"parse": 1, "url": page_url or self._decode_site_path(encoded), "header": self.headers}
|