diff --git a/py/tests/test_袋鼠影视.py b/py/tests/test_袋鼠影视.py new file mode 100644 index 0000000..f541212 --- /dev/null +++ b/py/tests/test_袋鼠影视.py @@ -0,0 +1,291 @@ +import unittest +from importlib.machinery import SourceFileLoader +from pathlib import Path +from requests.exceptions import ConnectionError +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +MODULE = SourceFileLoader("daishu_spider", str(ROOT / "袋鼠影视.py")).load_module() +Spider = MODULE.Spider + + +class TestDaishuSpider(unittest.TestCase): + def setUp(self): + Spider._instance = None + self.spider = Spider() + self.spider.init() + + def test_name(self): + self.assertEqual(self.spider.getName(), "袋鼠影视") + + def test_home_content_exposes_categories_and_filters(self): + content = self.spider.homeContent(True) + self.assertEqual( + [item["type_id"] for item in content["class"]], + ["1", "2", "3", "4"], + ) + self.assertIn("filters", content) + self.assertIn("1", content["filters"]) + self.assertEqual(content["filters"]["1"][0]["key"], "tid") + + def test_home_content_without_filter(self): + content = self.spider.homeContent(False) + self.assertNotIn("filters", content) + + def test_build_url(self): + self.assertEqual(self.spider._build_url("/movie/123.html"), "https://daishuys.com/movie/123.html") + self.assertEqual(self.spider._build_url("https://other.com/x"), "https://other.com/x") + self.assertEqual(self.spider._build_url("//cdn.example.com/img.jpg"), "https://cdn.example.com/img.jpg") + self.assertEqual(self.spider._build_url(""), "") + + def test_encode_and_decode_detail_and_play_ids(self): + self.assertEqual(self.spider._encode_vod_id("/movie/index123.html"), "movie/index123") + self.assertEqual(self.spider._decode_vod_id("movie/index123"), "https://daishuys.com/movie/index123.html") + self.assertEqual(self.spider._encode_play_id("/play/123-1-2.html"), "play/123-1-2") + self.assertEqual(self.spider._decode_play_id("play/123-1-2"), "https://daishuys.com/play/123-1-2.html") + + def test_clean_text(self): + self.assertEqual(self.spider._clean_text(" hello world "), "hello world") + self.assertEqual(self.spider._clean_text("\xa0test"), "test") + self.assertEqual(self.spider._clean_text(None), "") + + def test_build_category_url(self): + url = self.spider._build_category_url("1", 1, {}) + self.assertIn("searchtype=5", url) + self.assertIn("tid=1", url) + self.assertIn("page=1", url) + + def test_build_category_url_with_filters(self): + url = self.spider._build_category_url("1", 2, {"tid": "5", "area": "大陆", "year": "2024"}) + self.assertIn("tid=5", url) + self.assertIn("area=", url) + self.assertIn("year=2024", url) + self.assertIn("page=2", url) + + def test_parse_category_cards_simple(self): + html = """ +
+
+ + + HD + +
+
+ """ + items, pagecount = self.spider._parse_category_cards(html) + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["vod_id"], "movie/123") + self.assertEqual(items[0]["vod_name"], "测试影片") + self.assertEqual(items[0]["vod_pic"], "https://daishuys.com/pic.jpg") + self.assertEqual(items[0]["vod_remarks"], "HD") + self.assertEqual(pagecount, 1) + + def test_parse_category_cards_with_detail(self): + html = """ +
+
+
+
+
+

详情影片

+
    +
  • 主演:张三
  • +
  • 导演:李四
  • +
  • 地区:大陆
  • +
  • 年份:2024
  • +
+
+
+
+
+ """ + items, _ = self.spider._parse_category_cards(html) + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["vod_name"], "详情影片") + self.assertEqual(items[0]["vod_actor"], "张三") + self.assertEqual(items[0]["vod_director"], "李四") + self.assertEqual(items[0]["vod_area"], "大陆") + self.assertEqual(items[0]["vod_year"], "2024") + + def test_parse_page_count(self): + html = """ +
+ 1 + 5 + 10 +
+ """ + root = self.spider.html(html) + self.assertEqual(self.spider._parse_page_count(root), 10) + + def test_parse_detail(self): + html = """ +

详情标题

+
+
+
+ + + 更新至10集 + +
+
  • 主演:演员甲 演员乙
  • +
  • 导演:导演甲
  • +
  • 年份:2024
  • +
  • 地区:大陆
  • +
  • 类型:电影
  • +
  • 语言:国语
  • +
  • 又名:Another Name
  • +
  • 豆瓣:8.5
  • +
    +
    +
    这是一段剧情简介
    +
    +
    + + +
    +
    + +
    + 正片 +
    +
    +
    + """ + result = self.spider._parse_detail(html, "https://daishuys.com/movie/123.html") + vod = result + self.assertEqual(vod["vod_name"], "详情标题") + self.assertEqual(vod["vod_pic"], "https://daishuys.com/poster.jpg") + self.assertEqual(vod["vod_remarks"], "更新至10集") + self.assertEqual(vod["vod_actor"], "演员甲 演员乙") + self.assertEqual(vod["vod_director"], "导演甲") + self.assertEqual(vod["vod_year"], "2024") + self.assertEqual(vod["vod_area"], "大陆") + self.assertEqual(vod["type_name"], "电影") + self.assertEqual(vod["vod_lang"], "国语") + self.assertEqual(vod["vod_content"], "这是一段剧情简介") + self.assertEqual(vod["vod_play_from"], "线路1$$$线路2") + self.assertEqual( + vod["vod_play_url"], + "第1集$play/123-1-1#第2集$play/123-1-2$$$" + "正片$play/123-2-1", + ) + + @patch.object(Spider, "_request_html") + def test_detail_content_resolves_relative_id(self, mock_request_html): + mock_request_html.return_value = "

    测试

    " + self.spider.detailContent(["/movie/123.html"]) + self.assertEqual(mock_request_html.call_args.args[0], "https://daishuys.com/movie/123.html") + + @patch.object(Spider, "_request_html") + def test_detail_content_passes_absolute_url(self, mock_request_html): + mock_request_html.return_value = "

    测试

    " + self.spider.detailContent(["https://daishuys.com/movie/456.html"]) + self.assertEqual(mock_request_html.call_args.args[0], "https://daishuys.com/movie/456.html") + + @patch.object(Spider, "_request_html") + def test_detail_content_decodes_short_vod_id(self, mock_request_html): + mock_request_html.return_value = "

    测试

    " + self.spider.detailContent(["movie/index456"]) + self.assertEqual(mock_request_html.call_args.args[0], "https://daishuys.com/movie/index456.html") + + def test_extract_play_url_double_quotes(self): + html = 'var now="https://video.example/stream.m3u8";' + self.assertEqual(self.spider._extract_play_url(html), "https://video.example/stream.m3u8") + + def test_extract_play_url_single_quotes(self): + html = "var now='https://video.example/stream.m3u8';" + self.assertEqual(self.spider._extract_play_url(html), "https://video.example/stream.m3u8") + + def test_extract_play_url_protocol_relative(self): + html = "var now='//video.example/stream.m3u8';" + self.assertEqual(self.spider._extract_play_url(html), "https://video.example/stream.m3u8") + + def test_extract_play_url_no_quotes(self): + html = 'var now=https://video.example/stream.m3u8;' + self.assertEqual(self.spider._extract_play_url(html), "https://video.example/stream.m3u8") + + def test_extract_play_url_m3u8_fallback(self): + html = 'some text https://video.example/path/stream.m3u8 other text' + self.assertEqual(self.spider._extract_play_url(html), "https://video.example/path/stream.m3u8") + + def test_extract_play_url_empty(self): + self.assertEqual(self.spider._extract_play_url(""), "") + self.assertEqual(self.spider._extract_play_url(""), "") + + @patch.object(Spider, "fetch") + def test_player_content_extracts_now_var(self, mock_fetch): + mock_response = type("R", (), {"status_code": 200, "text": 'var now="https://video.example/stream.m3u8";'})() + mock_fetch.return_value = mock_response + result = self.spider.playerContent("", "play/123-1-1", []) + self.assertEqual(result["parse"], 0) + self.assertEqual(result["jx"], 0) + self.assertEqual(result["playUrl"], "") + self.assertEqual(result["url"], "https://video.example/stream.m3u8") + self.assertIn("Referer", result["header"]) + self.assertEqual(result["header"]["Referer"], "https://daishuys.com/play/123-1-1.html") + self.assertEqual(mock_fetch.call_args.args[0], "https://daishuys.com/play/123-1-1.html") + + @patch.object(Spider, "fetch") + def test_player_content_falls_back_to_parse(self, mock_fetch): + mock_response = type("R", (), {"status_code": 200, "text": "no video"})() + mock_fetch.return_value = mock_response + result = self.spider.playerContent("", "play/123-1-1", []) + self.assertEqual(result["parse"], 1) + self.assertEqual(result["jx"], 1) + self.assertEqual(result["playUrl"], "") + self.assertEqual(result["header"]["Referer"], "https://daishuys.com/play/123-1-1.html") + self.assertEqual(result["url"], "https://daishuys.com/play/123-1-1.html") + + @patch.object(Spider, "fetch") + def test_player_content_handles_non_200(self, mock_fetch): + mock_response = type("R", (), {"status_code": 403, "text": ""})() + mock_fetch.return_value = mock_response + result = self.spider.playerContent("", "play/123-1-1", []) + self.assertEqual(result["parse"], 1) + self.assertEqual(result["playUrl"], "") + + @patch.object(Spider, "_curl_request") + @patch.object(Spider, "fetch") + def test_player_content_falls_back_to_curl_request_on_fetch_error(self, mock_fetch, mock_curl_request): + mock_fetch.side_effect = ConnectionError("dns failed") + mock_curl_request.return_value = {"body": 'var now="https://video.example/fallback.m3u8";', "status_code": 200} + result = self.spider.playerContent("", "play/123-1-1", []) + self.assertEqual(result["parse"], 0) + self.assertEqual(result["url"], "https://video.example/fallback.m3u8") + + def test_player_content_empty_id(self): + result = self.spider.playerContent("", "", []) + self.assertEqual(result["parse"], 1) + self.assertEqual(result["url"], "") + + @patch.object(Spider, "_request_html") + def test_category_content(self, mock_request_html): + mock_request_html.return_value = """ +
    +
    + + + TC + +
    +
    +
    + 5 +
    + """ + result = self.spider.categoryContent("1", "2", True, {}) + self.assertEqual(result["page"], 2) + self.assertEqual(result["pagecount"], 5) + self.assertEqual(len(result["list"]), 1) + self.assertEqual(result["list"][0]["vod_name"], "分类片") + + +if __name__ == "__main__": + unittest.main() diff --git a/py/袋鼠影视.py b/py/袋鼠影视.py new file mode 100644 index 0000000..9ff6a85 --- /dev/null +++ b/py/袋鼠影视.py @@ -0,0 +1,554 @@ +# coding=utf-8 +import re +import subprocess +import sys +from urllib.parse import quote, urljoin + +from base.spider import Spider as BaseSpider + +sys.path.append("..") + + +class Spider(BaseSpider): + def __init__(self): + self.name = "袋鼠影视" + self.host = "https://daishuys.com" + self.headers = { + "User-Agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) " + "Version/17.0 Mobile/15E148 Safari/604.1" + ), + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Referer": self.host + "/", + } + self.classes = [ + {"type_id": "1", "type_name": "电影"}, + {"type_id": "2", "type_name": "电视剧"}, + {"type_id": "3", "type_name": "综艺"}, + {"type_id": "4", "type_name": "动漫"}, + ] + self.filters = { + "1": [ + { + "key": "tid", + "name": "类型", + "value": [ + {"n": "全部", "v": "1"}, + {"n": "动作片", "v": "5"}, + {"n": "喜剧片", "v": "10"}, + {"n": "爱情片", "v": "6"}, + {"n": "科幻片", "v": "7"}, + {"n": "恐怖片", "v": "8"}, + {"n": "战争片", "v": "9"}, + {"n": "剧情片", "v": "12"}, + {"n": "动画片", "v": "41"}, + {"n": "纪录片", "v": "11"}, + ], + }, + { + "key": "area", + "name": "地区", + "value": [ + {"n": "全部", "v": ""}, + {"n": "大陆", "v": "大陆"}, + {"n": "香港", "v": "香港"}, + {"n": "台湾", "v": "台湾"}, + {"n": "日本", "v": "日本"}, + {"n": "韩国", "v": "韩国"}, + {"n": "美国", "v": "美国"}, + {"n": "英国", "v": "英国"}, + {"n": "印度", "v": "印度"}, + {"n": "法国", "v": "法国"}, + {"n": "泰国", "v": "泰国"}, + ], + }, + { + "key": "year", + "name": "年份", + "value": [ + {"n": "全部", "v": ""}, + {"n": "2026", "v": "2026"}, + {"n": "2025", "v": "2025"}, + {"n": "2024", "v": "2024"}, + {"n": "2023", "v": "2023"}, + {"n": "2022", "v": "2022"}, + {"n": "2021", "v": "2021"}, + {"n": "2020", "v": "2020"}, + ], + }, + ], + "2": [ + { + "key": "tid", + "name": "类型", + "value": [ + {"n": "全部", "v": "2"}, + {"n": "国产剧", "v": "13"}, + {"n": "港台剧", "v": "14"}, + {"n": "欧美剧", "v": "15"}, + {"n": "日韩剧", "v": "16"}, + ], + }, + { + "key": "area", + "name": "地区", + "value": [ + {"n": "全部", "v": ""}, + {"n": "大陆", "v": "大陆"}, + {"n": "香港", "v": "香港"}, + {"n": "台湾", "v": "台湾"}, + {"n": "日本", "v": "日本"}, + {"n": "韩国", "v": "韩国"}, + {"n": "美国", "v": "美国"}, + {"n": "英国", "v": "英国"}, + ], + }, + { + "key": "year", + "name": "年份", + "value": [ + {"n": "全部", "v": ""}, + {"n": "2026", "v": "2026"}, + {"n": "2025", "v": "2025"}, + {"n": "2024", "v": "2024"}, + {"n": "2023", "v": "2023"}, + {"n": "2022", "v": "2022"}, + ], + }, + ], + "3": [ + { + "key": "area", + "name": "地区", + "value": [ + {"n": "全部", "v": ""}, + {"n": "大陆", "v": "大陆"}, + {"n": "日本", "v": "日本"}, + {"n": "韩国", "v": "韩国"}, + {"n": "美国", "v": "美国"}, + ], + }, + { + "key": "year", + "name": "年份", + "value": [ + {"n": "全部", "v": ""}, + {"n": "2026", "v": "2026"}, + {"n": "2025", "v": "2025"}, + {"n": "2024", "v": "2024"}, + ], + }, + ], + "4": [ + { + "key": "area", + "name": "地区", + "value": [ + {"n": "全部", "v": ""}, + {"n": "大陆", "v": "大陆"}, + {"n": "日本", "v": "日本"}, + {"n": "韩国", "v": "韩国"}, + {"n": "美国", "v": "美国"}, + ], + }, + { + "key": "year", + "name": "年份", + "value": [ + {"n": "全部", "v": ""}, + {"n": "2026", "v": "2026"}, + {"n": "2025", "v": "2025"}, + {"n": "2024", "v": "2024"}, + ], + }, + ], + } + self.filter_defaults = { + "1": {"tid": "1", "area": "", "year": ""}, + "2": {"tid": "2", "area": "", "year": ""}, + "3": {"tid": "3", "area": "", "year": ""}, + "4": {"tid": "4", "area": "", "year": ""}, + } + + def init(self, extend=""): + return None + + def getName(self): + return self.name + + def homeContent(self, filter): + result = {"class": self.classes} + if filter: + result["filters"] = self.filters + return result + + def homeVideoContent(self): + html = self._request_html(self.host + "/") + root = self.html(html) + if root is None: + return {"list": []} + items = [] + seen = set() + for node in root.xpath("//*[contains(@class,'swiper-container') and contains(@class,'hy-slide')]//a[contains(@class,'videopic')][@href]"): + card = self._parse_card(root, node) + if card and card["vod_id"] not in seen: + seen.add(card["vod_id"]) + items.append(card) + for node in root.xpath("//*[contains(@class,'hy-video-list')]//*[contains(@class,'item')]//a[contains(@class,'videopic')][@href]"): + card = self._parse_card(root, node) + if card and card["vod_id"] not in seen: + seen.add(card["vod_id"]) + items.append(card) + return {"list": items} + + def _build_url(self, path): + raw = str(path or "").strip() + if not raw: + return "" + if raw.startswith(("http://", "https://")): + return raw + if raw.startswith("//"): + return "https:" + raw + return urljoin(self.host + "/", raw) + + def _encode_vod_id(self, href): + matched = re.search(r"/movie/([^/?#]+)\.html", self._build_url(href)) + return f"movie/{matched.group(1)}" if matched else "" + + def _decode_vod_id(self, vod_id): + matched = re.search(r"^movie/([^/?#]+)$", str(vod_id or "").strip()) + return self._build_url(f"/movie/{matched.group(1)}.html") if matched else "" + + def _encode_play_id(self, href): + matched = re.search(r"/play/([^/?#]+)\.html", self._build_url(href)) + return f"play/{matched.group(1)}" if matched else "" + + def _decode_play_id(self, play_id): + matched = re.search(r"^play/([^/?#]+)$", str(play_id or "").strip()) + return self._build_url(f"/play/{matched.group(1)}.html") if matched else "" + + def _clean_text(self, text): + return re.sub(r"\s+", " ", str(text or "").replace("\xa0", " ")).strip() + + def _request_html(self, path_or_url): + target = path_or_url if str(path_or_url).startswith("http") else self._build_url(path_or_url) + response = self.fetch(target, headers=dict(self.headers), timeout=15, verify=False) + if response.status_code != 200: + return "" + return response.text or "" + + def _normalize_media_url(self, value): + raw = str(value or "").strip().strip("'\"") + if not raw: + return "" + if raw.startswith("//"): + return "https:" + raw + if raw.startswith(("http://", "https://")): + return raw + if raw.startswith("/"): + return self._build_url(raw) + return "" + + def _first_text(self, node, expr): + for value in node.xpath(expr): + text = str(value or "").strip() + if text: + return text + return "" + + def _parse_card(self, root, anchor): + href = self._first_text(anchor, "./@href") + vod_id = self._encode_vod_id(href) + if not vod_id: + return None + title = ( + self._first_text(anchor, "./@title") + or self._first_text(anchor, ".//img[1]/@alt") + ) + pic = ( + self._first_text(anchor, "./@data-original") + or self._first_text(anchor, ".//img[1]/@data-original") + or self._first_text(anchor, ".//img[1]/@src") + ) + remarks = self._clean_text("".join(anchor.xpath(".//*[contains(@class,'note')][1]//text()"))) + return { + "vod_id": vod_id, + "vod_name": self._clean_text(title), + "vod_pic": self._build_url(pic), + "vod_remarks": remarks, + } + + def _parse_page_count(self, root): + max_page = 1 + for node in root.xpath("//*[contains(@class,'hy-page')]//a[@href]"): + href = self._first_text(node, "./@href") + match = re.search(r"[?&]page=(\d+)", href) + if match: + max_page = max(max_page, int(match.group(1))) + return max_page + + def _parse_category_cards(self, html): + root = self.html(html) + if root is None: + return [], 1 + items = [] + seen = set() + for node in root.xpath("//*[contains(@class,'hy-video-list')]//*[contains(@class,'item')]//a[contains(@class,'videopic')][@href]"): + card = self._parse_card(root, node) + if card and card["vod_id"] not in seen and card["vod_name"]: + seen.add(card["vod_id"]) + items.append(card) + for node in root.xpath("//*[contains(@class,'hy-video-details')]//*[contains(@class,'item')]//dl[contains(@class,'content')]"): + anchor = node.xpath(".//dt//a[contains(@class,'videopic')][@href]") + if not anchor: + continue + anchor = anchor[0] + href = self._first_text(anchor, "./@href") + vod_id = self._encode_vod_id(href) + if not vod_id or vod_id in seen: + continue + title = self._clean_text( + self._first_text(node, ".//dd//*[contains(@class,'head')]//a[1]//text()") + or self._first_text(node, ".//dd//*[contains(@class,'head')]//h3[1]//text()") + or self._first_text(node, ".//dd//*[contains(@class,'head')]//h5[1]//text()") + ) + pic = ( + self._first_text(anchor, "./@data-original") + or self._first_text(anchor, ".//img[1]/@src") + or "" + ) + style_match = re.search(r"url\(([^)]+)\)", self._first_text(anchor, "./@style")) + if not pic and style_match: + pic = style_match.group(1).strip("'\"") + remarks = self._clean_text("".join(anchor.xpath(".//*[contains(@class,'note')][1]//text()"))) + card = { + "vod_id": vod_id, + "vod_name": title, + "vod_pic": self._build_url(pic), + "vod_remarks": remarks, + } + for li in node.xpath(".//li"): + li_text = self._clean_text("".join(li.xpath(".//text()"))) + if li_text.startswith("主演:"): + card["vod_actor"] = li_text[3:] + elif li_text.startswith("导演:"): + card["vod_director"] = li_text[3:] + elif li_text.startswith("地区:"): + card["vod_area"] = li_text[3:] + elif li_text.startswith("年份:"): + card["vod_year"] = li_text[3:] + if card["vod_id"] not in seen and card["vod_name"]: + seen.add(card["vod_id"]) + items.append(card) + pagecount = self._parse_page_count(root) + return items, pagecount + + def _build_category_url(self, tid, pg, extend): + defaults = self.filter_defaults.get(str(tid), {"tid": str(tid)}) + merged = {**defaults, **(extend or {})} + tid_val = str(merged.get("tid", tid)) + params = f"searchtype=5&tid={tid_val}&page={int(pg)}" + area = str(merged.get("area", "")) + if area: + params += f"&area={quote(area)}" + year = str(merged.get("year", "")) + if year: + params += f"&year={quote(year)}" + return f"{self.host}/search.php?{params}" + + def categoryContent(self, tid, pg, filter, extend): + url = self._build_category_url(tid, pg, extend) + html = self._request_html(url) + items, pagecount = self._parse_category_cards(html) + page = int(pg) + return { + "page": page, + "pagecount": pagecount, + "limit": len(items) or 20, + "total": pagecount * (len(items) or 20), + "list": items, + } + + def searchContent(self, key, quick, pg="1"): + page = int(pg) + keyword = self._clean_text(key) + if not keyword: + return {"page": page, "pagecount": 0, "total": 0, "list": []} + search_headers = dict(self.headers) + search_headers["Content-Type"] = "application/x-www-form-urlencoded" + search_headers["Referer"] = f"{self.host}/search.php" + response = self.post( + f"{self.host}/search.php?page={page}", + data=f"searchword={quote(keyword)}", + headers=search_headers, + timeout=15, + verify=False, + ) + if response.status_code != 200: + return {"page": page, "pagecount": 0, "total": 0, "list": []} + html = response.text or "" + items, pagecount = self._parse_category_cards(html) + total_match = re.search(r"相关的.?“(\d+)”.?条结果", html) + total = int(total_match.group(1)) if total_match else len(items) + return { + "page": page, + "pagecount": pagecount or page, + "total": total, + "list": items, + } + + def _parse_detail(self, html, vod_id): + root = self.html(html) + if root is None: + return {"vod_id": vod_id, "vod_name": "", "vod_pic": "", "vod_play_from": "", "vod_play_url": ""} + title = self._clean_text("".join(root.xpath("//h1[contains(@class,'h4')][1]//text() | //h1[1]//text()"))) + pic = "" + detail_anchor = root.xpath("//*[contains(@class,'hy-video-details')]//*[contains(@class,'content')]//dt//a[contains(@class,'videopic')][1]") + if detail_anchor: + pic = ( + self._first_text(detail_anchor[0], ".//img[1]/@src") + or self._first_text(detail_anchor[0], "./@data-original") + ) + remarks = self._clean_text("".join(detail_anchor[0].xpath(".//*[contains(@class,'note')][1]//text()"))) + else: + remarks = "" + info = {} + for li in root.xpath("//*[contains(@class,'hy-video-details')]//li"): + li_text = self._clean_text("".join(li.xpath(".//text()"))) + if li_text.startswith("主演:"): + info["vod_actor"] = li_text[3:] + elif li_text.startswith("导演:"): + info["vod_director"] = li_text[3:] + elif li_text.startswith("年份:"): + info["vod_year"] = li_text[3:] + elif li_text.startswith("地区:"): + info["vod_area"] = li_text[3:] + elif li_text.startswith("类型:"): + info["type_name"] = li_text[3:] + elif li_text.startswith("语言:"): + info["vod_lang"] = li_text[3:] + elif li_text.startswith("又名:"): + info["other"] = li_text[3:] + elif li_text.startswith("豆瓣:"): + info["vod_douban_score"] = li_text[3:] + content = self._clean_text( + "".join(root.xpath("//*[@id='list3']//*[contains(@class,'plot')][1]//text()")) + or "".join(root.xpath("//*[contains(@class,'plot')][1]//text()")) + ) + play_from = [] + play_urls = [] + for index, panel in enumerate(root.xpath("//*[@id='playlist']//*[contains(@class,'panel')]")): + source_name = ( + self._clean_text(self._first_text(panel, ".//a[contains(@class,'option')][1]/@title")) + or self._clean_text( + "".join( + panel.xpath( + ".//a[contains(@class,'option')][1]//text()" + " | .//a[contains(@class,'option')][1]/text()" + ) + ) + ) + or f"线路{index + 1}" + ) + episodes = [] + for anchor in panel.xpath(".//*[contains(@class,'playlist')]//a[@href]"): + href = self._first_text(anchor, "./@href") + ep_name = self._clean_text(self._first_text(anchor, "./@title") or "".join(anchor.xpath(".//text()"))) + if not href or not ep_name: + continue + play_id = self._encode_play_id(href) + if not play_id: + continue + episodes.append(f"{ep_name}${play_id}") + if episodes: + play_from.append(source_name) + play_urls.append("#".join(episodes)) + return { + "vod_id": vod_id, + "vod_name": title, + "vod_pic": self._build_url(pic), + "vod_remarks": remarks, + "vod_content": content, + "vod_play_from": "$$$".join(play_from), + "vod_play_url": "$$$".join(play_urls), + **info, + } + + def detailContent(self, ids): + result = {"list": []} + for raw_id in ids: + vod_id = str(raw_id or "").strip() + if not vod_id: + continue + url = self._decode_vod_id(vod_id) + if not url: + url = vod_id if vod_id.startswith(("http://", "https://")) else self._build_url(vod_id) + html = self._request_html(url) + detail = self._parse_detail(html, vod_id) + result["list"].append(detail) + return result + + def _curl_request(self, url, headers=None): + command = ["curl", "-L", "--silent", "--show-error", url] + for key, value in (headers or {}).items(): + command.extend(["-H", f"{key}: {value}"]) + completed = subprocess.run(command, capture_output=True, text=True, check=True, timeout=20) + return {"body": completed.stdout or "", "status_code": 200} + + def _extract_play_url(self, html): + patterns = [ + r'var\s+now\s*=\s*"([^"]+)"', + r"var\s+now\s*=\s*'([^']+)'", + r'var\s+now\s*=\s*((?:https?:)?//[^\s;"\']+)', + ] + for p in patterns: + match = re.search(p, html) + if match: + url = self._normalize_media_url(match.group(1)) + if url: + return url + match = re.search(r'((?:https?:)?//[^\s"\'<>\)]+\.m3u8[^\s"\'<>\)]*)', html) + if match: + return self._normalize_media_url(match.group(1)) + return "" + + def playerContent(self, flag, id, vipFlags): + play_url = str(id or "").strip() + if not play_url: + return {"parse": 1, "jx": 1, "playUrl": "", "url": "", "header": {}} + url = self._decode_play_id(play_url) + if not url: + url = self._build_url(play_url) + headers = dict(self.headers) + headers["Referer"] = url + try: + response = self.fetch(url, headers=headers, timeout=15, verify=False) + html = response.text if response.status_code == 200 else "" + except Exception: + try: + html = self._curl_request(url, headers=headers).get("body", "") + except Exception: + html = "" + direct_url = self._extract_play_url(html) + if direct_url: + return { + "parse": 0, + "jx": 0, + "playUrl": "", + "url": direct_url, + "header": { + "User-Agent": self.headers["User-Agent"], + "Referer": url, + }, + } + return { + "parse": 1, + "jx": 1, + "playUrl": "", + "url": url, + "header": { + "User-Agent": self.headers["User-Agent"], + "Referer": url, + }, + }