diff --git a/py/4KVM.py b/py/4KVM.py new file mode 100644 index 0000000..a816283 --- /dev/null +++ b/py/4KVM.py @@ -0,0 +1,548 @@ +# 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} diff --git a/py/tests/test_4KVM.py b/py/tests/test_4KVM.py new file mode 100644 index 0000000..e96f0de --- /dev/null +++ b/py/tests/test_4KVM.py @@ -0,0 +1,234 @@ +import unittest +from importlib.machinery import SourceFileLoader +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +MODULE = SourceFileLoader("k4vm_spider", str(ROOT / "4KVM.py")).load_module() +Spider = MODULE.Spider + + +class FakeResponse: + def __init__(self, text="", status_code=200, headers=None): + self.text = text + self.status_code = status_code + self.headers = headers or {} + self.encoding = "utf-8" + + +class Test4KVMSpider(unittest.TestCase): + def setUp(self): + Spider._instance = None + self.spider = Spider() + self.spider.init() + + def test_encode_site_path_keeps_short_ids(self): + self.assertEqual(self.spider._encode_site_path("/play/test-slug"), "play/test-slug") + self.assertEqual(self.spider._encode_site_path("https://www.4kvm.org/tv?page=2"), "tv?page=2") + self.assertEqual(self.spider._decode_site_path("play/test-slug"), "https://www.4kvm.org/play/test-slug") + + @patch.object(Spider, "fetch") + def test_home_content_and_home_video_content_parse_nav_and_cards(self, mock_fetch): + mock_fetch.return_value = FakeResponse( + """ + + +
+ + 首页推荐 + +

首页推荐

+
HD中字
+
+ + """ + ) + + home = self.spider.homeContent(False) + videos = self.spider.homeVideoContent() + + self.assertEqual( + home["class"], + [ + {"type_id": "movie", "type_name": "电影"}, + {"type_id": "tv", "type_name": "电视剧"}, + {"type_id": "anime", "type_name": "动漫"}, + ], + ) + self.assertEqual( + videos["list"], + [ + { + "vod_id": "play/home-film", + "vod_name": "首页推荐", + "vod_pic": "https://www.4kvm.org/poster.jpg", + "vod_remarks": "HD中字", + } + ], + ) + + @patch.object(Spider, "fetch") + def test_category_content_builds_page_url_and_returns_items_without_pagecount(self, mock_fetch): + mock_fetch.return_value = FakeResponse( + """ + +
+ 分类片 +

分类片

+ 更新至10集 +
+ + """ + ) + + result = self.spider.categoryContent("tv", "2", False, {}) + + self.assertEqual(result["page"], 2) + self.assertNotIn("pagecount", result) + self.assertEqual(result["list"][0]["vod_id"], "play/cate-film") + self.assertEqual(result["list"][0]["vod_remarks"], "更新至10集") + self.assertEqual(mock_fetch.call_args.args[0], "https://www.4kvm.org/tv?page=2") + + @patch.object(Spider, "fetch") + def test_search_content_filters_and_ranks_results(self, mock_fetch): + mock_fetch.return_value = FakeResponse( + """ + + + 繁花 +

繁花

+
完结
+
+ + 繁花前传 +

繁花前传

+
+ + 风起云涌 +

风起云涌

+
+ + """ + ) + + result = self.spider.searchContent("繁花", False, "3") + + self.assertEqual(result["page"], 3) + self.assertNotIn("pagecount", result) + self.assertEqual([item["vod_id"] for item in result["list"]], ["play/exact-hit", "play/partial-hit"]) + self.assertIn("/search?q=%E7%B9%81%E8%8A%B1&page=3", mock_fetch.call_args.args[0]) + + @patch.object(Spider, "fetch") + def test_detail_content_maps_meta_and_episode_sources(self, mock_fetch): + mock_fetch.return_value = FakeResponse( + """ + + + + + + + +

详情片

+
+
+
年份
2025
+
地区
大陆
+
状态
更新至2集
+
主演
主演甲/主演乙
+
导演
导演甲
+
类型
剧情/动作
+
+
+

正文简介

+
+ 1 + 2 + 1 + + + """ + ) + + result = self.spider.detailContent(["play/detail-slug"]) + vod = result["list"][0] + + self.assertEqual(vod["vod_id"], "play/detail-slug") + self.assertEqual(vod["vod_name"], "详情片") + self.assertEqual(vod["vod_pic"], "https://www.4kvm.org/poster.jpg") + self.assertEqual(vod["vod_content"], "正文简介") + self.assertEqual(vod["vod_year"], "2025") + self.assertEqual(vod["vod_area"], "大陆") + self.assertEqual(vod["vod_remarks"], "更新至2集") + self.assertEqual(vod["vod_actor"], "主演甲/主演乙") + self.assertEqual(vod["vod_director"], "导演甲") + self.assertEqual(vod["type_name"], "剧情, 动作") + self.assertEqual(vod["vod_play_from"], "线路2$$$线路1") + self.assertEqual( + vod["vod_play_url"], + "第1集$play/detail-slug?ep=1$$$第1集$play/detail-slug?ep=1#第2集$play/detail-slug?ep=2", + ) + + @patch.object(Spider, "fetch") + def test_detail_content_falls_back_to_dooplay_options(self, mock_fetch): + mock_fetch.return_value = FakeResponse( + """ + + +

选集片

+
  • + 正片 + 海外 +
  • + + + """ + ) + + result = self.spider.detailContent(["play/option-slug"]) + vod = result["list"][0] + + self.assertEqual(vod["vod_play_from"], "4KVM") + self.assertEqual(vod["vod_play_url"], "正片-海外$play/option-slug?post=7788&nume=2&type=movie") + + @patch.object(Spider, "fetch") + def test_player_content_prefers_api_embed_url(self, mock_fetch): + def fake_fetch(url, params=None, headers=None, timeout=5, verify=True, stream=False, allow_redirects=True): + self.assertEqual(url, "https://www.4kvm.org/wp-json/dooplayer/v1/post/7788") + self.assertEqual(params, {"type": "movie", "source": "2"}) + return FakeResponse('{"embed_url":"https://cdn.example.com/master.m3u8"}') + + mock_fetch.side_effect = fake_fetch + + result = self.spider.playerContent("", "play/option-slug?post=7788&nume=2&type=movie", []) + + self.assertEqual(result["parse"], 0) + self.assertEqual(result["url"], "https://cdn.example.com/master.m3u8") + + @patch.object(Spider, "fetch") + def test_player_content_falls_back_to_iframe_then_video(self, mock_fetch): + responses = [ + FakeResponse("{}", status_code=500), + FakeResponse(''), + FakeResponse(''), + ] + mock_fetch.side_effect = responses + + iframe_result = self.spider.playerContent("", "play/fallback?post=11&nume=1&type=tv", []) + video_result = self.spider.playerContent("", "play/video-only", []) + + self.assertEqual(iframe_result["parse"], 1) + self.assertEqual(iframe_result["url"], "https://player.example.com/embed/42") + self.assertEqual(video_result["parse"], 0) + self.assertEqual(video_result["url"], "https://www.4kvm.org/stream.m3u8") + + +if __name__ == "__main__": + unittest.main()