From 53571a890a2e2ffd99914e1fe28faf5e0e4e90de Mon Sep 17 00:00:00 2001
From: Harold <8866033@gmail.com>
Date: Sun, 19 Apr 2026 16:43:15 +0800
Subject: [PATCH] feat: add ddys detail parsing
---
py/tests/test_低端影视.py | 45 +++++++++++++++++++
py/低端影视.py | 91 +++++++++++++++++++++++++++++++++++++++
2 files changed, 136 insertions(+)
diff --git a/py/tests/test_低端影视.py b/py/tests/test_低端影视.py
index f565e47..f7b3f8f 100644
--- a/py/tests/test_低端影视.py
+++ b/py/tests/test_低端影视.py
@@ -140,6 +140,51 @@ class TestDDYSSpider(unittest.TestCase):
self.assertEqual(result["list"][0]["vod_id"], "https://ddys.io/anime/result/")
self.assertEqual(result["pagecount"], 2)
+ def test_parse_detail_page_merges_direct_and_pan_sources(self):
+ html = """
+
+
低端示例DDYS Example
+
+ 2025 · 日本 · 动画
+ 导演:导演甲
+ 主演:主演乙
+
+
+
+
+
+
+
+
+
+ """
+ vod = self.spider._parse_detail_page(html, "https://ddys.io/anime/demo/")
+ self.assertEqual(vod["vod_name"], "低端示例")
+ self.assertEqual(vod["vod_pic"], "https://ddys.io/poster-detail.jpg")
+ self.assertEqual(vod["vod_year"], "2025")
+ self.assertEqual(vod["vod_area"], "日本")
+ self.assertEqual(vod["vod_class"], "动画")
+ self.assertEqual(vod["vod_director"], "导演甲")
+ self.assertEqual(vod["vod_actor"], "主演乙")
+ self.assertEqual(vod["vod_content"], "第一段简介。\n第二段简介。")
+ self.assertEqual(vod["vod_play_from"], "DDYS$$$quark$$$baidu")
+ self.assertIn("第2集$/play/ep2", vod["vod_play_url"])
+ self.assertIn("夸克查看$https://pan.quark.cn/s/abc123", vod["vod_play_url"])
+ self.assertIn("百度查看$https://pan.baidu.com/s/demo", vod["vod_play_url"])
+
+ @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 = """
+ 详情标题
+
+ """
+ result = self.spider.detailContent(["https://ddys.io/movie/demo/"])
+ self.assertEqual(mock_request_html.call_args.args[0], "https://ddys.io/movie/demo/")
+ self.assertEqual(result["list"][0]["vod_id"], "https://ddys.io/movie/demo/")
+ self.assertEqual(result["list"][0]["vod_name"], "详情标题")
+ self.assertEqual(result["list"][0]["vod_play_from"], "直连")
+ self.assertEqual(result["list"][0]["vod_play_url"], "全集$/play/detail-demo")
+
if __name__ == "__main__":
unittest.main()
diff --git a/py/低端影视.py b/py/低端影视.py
index 54759e7..d19b0b6 100644
--- a/py/低端影视.py
+++ b/py/低端影视.py
@@ -1,4 +1,5 @@
# coding=utf-8
+import base64
import json
import re
import sys
@@ -176,3 +177,93 @@ class Spider(BaseSpider):
if not items:
items = self._parse_movie_cards(html)
return {"page": page, "pagecount": page + 1 if items else page, "total": len(items), "list": items}
+
+ def _extract_switch_sources(self, html):
+ root = self.html(html)
+ if root is None:
+ return []
+ groups = []
+ for button in root.xpath("//button[contains(@onclick,'switchSource')]"):
+ onclick = (button.xpath("./@onclick") or [""])[0]
+ matched = re.search(r"switchSource\(\d+,\s*'([^']*)',\s*'[^']*'\)", onclick, re.S)
+ if not matched:
+ continue
+ name = self._clean_text("".join(button.xpath(".//text()"))) or self.name
+ payload = self._clean_text(matched.group(1))
+ if not payload:
+ continue
+ if "$" not in payload:
+ payload = f"全集${payload}"
+ groups.append({"from": name, "urls": payload})
+ return groups
+
+ def _extract_pan_sources(self, html):
+ root = self.html(html)
+ if root is None:
+ return []
+ groups = []
+ for panel in root.xpath("//*[contains(@class,'download-type-content')]"):
+ panel_id = ((panel.xpath("./@id") or [""])[0]).replace("download-type-", "").strip()
+ if panel_id not in ("quark", "xunlei", "baidu"):
+ continue
+ entries = []
+ for button in panel.xpath(".//button[@onclick]"):
+ onclick = (button.xpath("./@onclick") or [""])[0]
+ matched = re.search(r"atob\('([^']+)'\)", onclick)
+ if not matched:
+ continue
+ try:
+ link = base64.b64decode(matched.group(1)).decode("utf-8").strip()
+ except Exception:
+ continue
+ title = self._clean_text("".join(button.xpath(".//text()"))) or panel_id
+ if link:
+ entries.append(f"{title}${link}")
+ if entries:
+ groups.append({"from": panel_id, "urls": "#".join(dict.fromkeys(entries))})
+ return groups
+
+ def _parse_detail_page(self, html, vod_id):
+ root = self.html(html)
+ if root is None:
+ return {"vod_id": vod_id, "vod_name": "", "vod_play_from": "", "vod_play_url": ""}
+ title = self._clean_text("".join(root.xpath("//h1[1]/text()")))
+ pic = ((root.xpath("//img[@alt][1]/@src") or [""])[0]).strip()
+ meta = self._clean_text("".join(root.xpath("(//*[contains(@class,'text-gray-600')])[1]//text()")))
+ parts = [self._clean_text(item) for item in meta.split("·")] if meta else []
+ director = ""
+ actor = ""
+ for text in root.xpath("//*[contains(@class,'text-gray-700')]"):
+ joined = self._clean_text("".join(text.xpath(".//text()")))
+ if joined.startswith("导演:"):
+ director = joined.split(":", 1)[-1].strip()
+ if joined.startswith("主演:"):
+ actor = joined.split(":", 1)[-1].strip()
+ content = "\n".join(
+ [self._clean_text("".join(node.xpath(".//text()"))) for node in root.xpath("//*[contains(@class,'prose')]//p")]
+ ).strip()
+ play_groups = self._extract_switch_sources(html) + self._extract_pan_sources(html)
+ return {
+ "vod_id": vod_id,
+ "vod_name": title,
+ "vod_pic": self._build_url(pic),
+ "vod_content": content,
+ "vod_remarks": " · ".join([item for item in parts if item]),
+ "vod_year": parts[0] if len(parts) > 0 else "",
+ "vod_area": parts[1] if len(parts) > 1 else "",
+ "vod_class": parts[2] if len(parts) > 2 else "",
+ "vod_director": director,
+ "vod_actor": actor,
+ "vod_play_from": "$$$".join([item["from"] for item in play_groups]),
+ "vod_play_url": "$$$".join([item["urls"] for item in play_groups]),
+ }
+
+ def detailContent(self, ids):
+ result = {"list": []}
+ for raw_id in ids:
+ vod_id = self._stringify(raw_id).strip()
+ if not vod_id:
+ continue
+ vod = self._parse_detail_page(self._request_html(vod_id), vod_id)
+ result["list"].append(vod)
+ return result