From 59e15248105066345aaae7e6b47416981a81402a Mon Sep 17 00:00:00 2001 From: Harold <8866033@gmail.com> Date: Mon, 20 Apr 2026 14:59:12 +0800 Subject: [PATCH] feat: add ouge detail and player support --- py/tests/test_欧歌.py | 74 ++++++++++++++++++++++++++++++++++ py/欧歌.py | 94 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/py/tests/test_欧歌.py b/py/tests/test_欧歌.py index 85b940a..07625c3 100644 --- a/py/tests/test_欧歌.py +++ b/py/tests/test_欧歌.py @@ -135,6 +135,80 @@ class TestOuGeSpider(unittest.TestCase): self.assertEqual(result, {"page": 1, "total": 0, "list": []}) mock_request_html.assert_not_called() + def test_parse_detail_page_extracts_metadata_and_deduplicated_pan_groups(self): + html = """ +
欧歌示例
+
+ +
+
2025
+
导演
+
导演甲导演乙
+
主演
+
演员甲演员乙
+
剧情
+

这是一段剧情简介。

+

https://pan.quark.cn/s/q-demo

+

百度合集 https://pan.baidu.com/s/b-demo

+

https://pan.quark.cn/s/q-demo

+ """ + vod = self.spider._parse_detail_page(html, "/index.php/vod/detail/id/999.html") + self.assertEqual(vod["vod_id"], "/index.php/vod/detail/id/999.html") + self.assertEqual(vod["vod_name"], "欧歌示例") + self.assertEqual(vod["vod_pic"], "https://woog.nxog.eu.org/poster-detail.jpg") + self.assertEqual(vod["vod_year"], "2025") + self.assertEqual(vod["vod_director"], "导演甲,导演乙") + self.assertEqual(vod["vod_actor"], "演员甲,演员乙") + self.assertEqual(vod["vod_content"], "这是一段剧情简介。") + self.assertEqual(vod["vod_play_from"], "baidu$$$quark") + self.assertEqual( + vod["vod_play_url"], + "百度资源$https://pan.baidu.com/s/b-demo$$$夸克资源$https://pan.quark.cn/s/q-demo", + ) + + @patch.object(Spider, "_request_html") + def test_detail_content_reads_detail_page_and_returns_single_vod(self, mock_request_html): + mock_request_html.return_value = """ +
详情标题
+

https://pan.quark.cn/s/detail-demo

+ """ + result = self.spider.detailContent(["/index.php/vod/detail/id/1000.html"]) + self.assertEqual( + mock_request_html.call_args.args[0], + "https://woog.nxog.eu.org/index.php/vod/detail/id/1000.html", + ) + self.assertEqual(result["list"][0]["vod_id"], "/index.php/vod/detail/id/1000.html") + self.assertEqual(result["list"][0]["vod_name"], "详情标题") + self.assertEqual(result["list"][0]["vod_play_from"], "quark") + self.assertEqual(result["list"][0]["vod_play_url"], "夸克资源$https://pan.quark.cn/s/detail-demo") + + def test_parse_detail_page_returns_empty_shell_for_blank_html(self): + self.assertEqual( + self.spider._parse_detail_page("", "/index.php/vod/detail/id/404.html"), + { + "vod_id": "/index.php/vod/detail/id/404.html", + "vod_name": "", + "vod_pic": "", + "vod_year": "", + "vod_director": "", + "vod_actor": "", + "vod_content": "", + "vod_play_from": "", + "vod_play_url": "", + }, + ) + + def test_player_content_transparently_returns_supported_pan_link(self): + result = self.spider.playerContent("quark", "https://pan.quark.cn/s/demo", {}) + self.assertEqual(result["parse"], 0) + self.assertEqual(result["jx"], 0) + self.assertEqual(result["url"], "https://pan.quark.cn/s/demo") + self.assertEqual(result["header"], {}) + + def test_player_content_returns_empty_url_for_unknown_link(self): + result = self.spider.playerContent("unknown", "/index.php/vod/play/id/1.html", {}) + self.assertEqual(result, {"parse": 0, "jx": 0, "playUrl": "", "url": "", "header": {}}) + if __name__ == "__main__": unittest.main() diff --git a/py/欧歌.py b/py/欧歌.py index 965f9b6..7b34734 100644 --- a/py/欧歌.py +++ b/py/欧歌.py @@ -179,3 +179,97 @@ class Spider(BaseSpider): return {"page": page, "total": 0, "list": []} items = self._parse_search_cards(self._request_html(self._build_search_url(keyword, pg))) return {"page": page, "total": len(items), "list": items} + + def _blank_detail(self, vod_id): + return { + "vod_id": vod_id, + "vod_name": "", + "vod_pic": "", + "vod_year": "", + "vod_director": "", + "vod_actor": "", + "vod_content": "", + "vod_play_from": "", + "vod_play_url": "", + } + + def _extract_share_url(self, text): + matched = re.search(r"https?://[^\s'\"<>]+", self._stringify(text), re.I) + return matched.group(0).strip() if matched else "" + + def _join_people(self, node): + values = [self._clean_text(text) for text in node.xpath(".//a//text()")] + if not any(values): + values = [self._clean_text(text) for text in node.xpath(".//text()")] + values = [value for value in values if value] + return ",".join(values) + + def _extract_pan_groups(self, root): + groups = [] + seen = set() + for node in root.xpath("//*[contains(@class,'module-row-info')]//p"): + text = self._clean_text("".join(node.xpath(".//text()"))) + share_url = self._extract_share_url(text) + pan_type, title = self._detect_pan_type(share_url) + if not share_url or not pan_type or share_url in seen: + continue + seen.add(share_url) + groups.append((pan_type, f"{title}${share_url}")) + groups.sort(key=lambda item: self.pan_priority.get(item[0], 999)) + return groups + + def _parse_detail_page(self, html, vod_id): + if not self._stringify(html).strip(): + return self._blank_detail(vod_id) + root = self.html(html) + if root is None: + return self._blank_detail(vod_id) + + vod = self._blank_detail(vod_id) + vod["vod_name"] = self._clean_text("".join(root.xpath("(//*[contains(@class,'page-title')])[1]//text()"))) + pic = ( + ((root.xpath("//*[contains(@class,'mobile-play')]//*[contains(@class,'lazyload')][1]/@data-src") or [""])[0]).strip() + or ((root.xpath("//*[contains(@class,'mobile-play')]//*[contains(@class,'lazyload')][1]/@src") or [""])[0]).strip() + ) + vod["vod_pic"] = self._fix_img_url(pic) + vod["vod_year"] = self._clean_text( + ((root.xpath("(//*[contains(@class,'module-item-caption')]//span[1]/text())") or [""])[0]) + ) + + for label_node in root.xpath("//*[contains(@class,'video-info-itemtitle')]"): + label = self._clean_text("".join(label_node.xpath(".//text()"))) + siblings = label_node.xpath("./following-sibling::*[1]") + if not siblings: + continue + block = siblings[0] + if "导演" in label: + vod["vod_director"] = self._join_people(block) + elif "主演" in label: + vod["vod_actor"] = self._join_people(block) + elif "剧情" in label: + texts = block.xpath(".//p//text()") or block.xpath(".//text()") + vod["vod_content"] = self._clean_text("".join(texts)) + + pan_groups = self._extract_pan_groups(root) + vod["vod_play_from"] = "$$$".join(item[0] for item in pan_groups) + vod["vod_play_url"] = "$$$".join(item[1] for item in pan_groups) + return vod + + def detailContent(self, ids): + result = {"list": []} + for raw_id in ids: + vod_id = self._stringify(raw_id).strip() + if not vod_id: + continue + html = self._request_html(self._build_url(vod_id)) + result["list"].append(self._parse_detail_page(html, vod_id)) + return result + + def _is_supported_pan_link(self, url): + return bool(self._detect_pan_type(url)[0]) + + def playerContent(self, flag, id, vipFlags): + target = self._stringify(id).strip() + if target and self._is_supported_pan_link(target): + return {"parse": 0, "jx": 0, "playUrl": "", "url": target, "header": {}} + return {"parse": 0, "jx": 0, "playUrl": "", "url": "", "header": {}}