From 92a8d61e6dec3ba1e9eb835cd2f7f603d01bbfae Mon Sep 17 00:00:00 2001
From: Harold <8866033@gmail.com>
Date: Mon, 20 Apr 2026 11:00:03 +0800
Subject: [PATCH] feat: add zxzj detail parsing
---
py/tests/test_在线之家.py | 66 ++++++++++++++++
py/在线之家.py | 154 ++++++++++++++++++++++++++++++++++++++
2 files changed, 220 insertions(+)
diff --git a/py/tests/test_在线之家.py b/py/tests/test_在线之家.py
index cf02424..fe4031a 100644
--- a/py/tests/test_在线之家.py
+++ b/py/tests/test_在线之家.py
@@ -98,6 +98,72 @@ class TestZXZJSpider(unittest.TestCase):
self.assertEqual(result["list"][0]["vod_name"], "搜索命中")
self.assertNotIn("pagecount", result)
+ def test_detect_pan_type_prefers_share_domain(self):
+ self.assertEqual(self.spider._detect_pan_type("百度网盘", "https://pan.quark.cn/s/demo"), "quark")
+ self.assertEqual(self.spider._detect_pan_type("资源链接", "https://drive.uc.cn/s/demo"), "uc")
+ self.assertEqual(self.spider._detect_pan_type("阿里网盘", "https://www.alipan.com/s/demo"), "aliyun")
+
+ @patch.object(Spider, "_request_html")
+ def test_detail_content_merges_zxzj_and_netdisk_groups(self, mock_request_html):
+ mock_request_html.side_effect = [
+ """
+
![]()
+
+
示例剧 2025 日本 剧情
+
导演:导演甲
+
主演:演员甲演员乙
+
+ 一段剧情简介
+ 在线播放
+
+ 夸克网盘
+
+ 百度云
+
+ """,
+ '',
+ '',
+ '',
+ ]
+ result = self.spider.detailContent(["voddetail/999.html"])
+ vod = result["list"][0]
+ self.assertEqual(vod["vod_id"], "voddetail/999.html")
+ self.assertEqual(vod["vod_name"], "示例剧")
+ self.assertEqual(vod["vod_pic"], "https://www.zxzjhd.com/poster.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"], "一段剧情简介")
+ self.assertEqual(vod["vod_play_from"], "zxzj$$$quark$$$baidu")
+ self.assertEqual(
+ vod["vod_play_url"],
+ "第1集$vodplay/999-1-1.html#第2集$vodplay/999-1-2.html$$$夸克一$https://pan.quark.cn/s/q-demo$$$百度合集$https://pan.baidu.com/s/b-demo",
+ )
+
+ @patch.object(Spider, "_request_html")
+ def test_extract_pan_groups_skips_invalid_or_unknown_links(self, mock_request_html):
+ mock_request_html.side_effect = [
+ '',
+ 'empty',
+ ]
+ groups = self.spider._extract_pan_groups(
+ [
+ {"name": "无效一", "url": "https://www.zxzjhd.com/vodplay/x-1.html", "tab_name": "网盘资源"},
+ {"name": "无效二", "url": "https://www.zxzjhd.com/vodplay/x-2.html", "tab_name": "网盘资源"},
+ ]
+ )
+ self.assertEqual(groups, [])
+
if __name__ == "__main__":
unittest.main()
diff --git a/py/在线之家.py b/py/在线之家.py
index 533568c..f14b8cf 100644
--- a/py/在线之家.py
+++ b/py/在线之家.py
@@ -225,3 +225,157 @@ class Spider(BaseSpider):
url = self._build_url("/vodsearch/{0}-------------.html".format(quote(str(key or "").strip())))
items = self._parse_cards(self._request_html(url))
return {"list": items, "page": page, "limit": len(items), "total": len(items)}
+
+ def _extract_actor_like_field(self, html, label):
+ matched = re.search(rf"{label}:([\s\S]*?)", str(html or ""))
+ if not matched:
+ return ""
+ texts = re.findall(r">([^<>]+)<", matched.group(1))
+ clean = [self._clean_text(text) for text in texts if self._clean_text(text)]
+ return ",".join(clean)
+
+ def _parse_detail_meta(self, html):
+ root = self.html(html)
+ if root is None:
+ return {}
+ title_line = self._clean_text(
+ "".join(root.xpath("//*[contains(@class,'stui-content__detail')]//*[contains(@class,'title')][1]//text()"))
+ )
+ title_match = re.match(r"^(.*?)(?:\s+(19\d{2}|20\d{2}))?(?:\s+([^\s]+))?(?:\s+([^\s]+))?$", title_line)
+ vod_name = self._clean_text(title_match.group(1) if title_match else title_line)
+ vod_year = self._clean_text(title_match.group(2) if title_match else "")
+ vod_area = self._clean_text(title_match.group(3) if title_match else "")
+ vod_class = self._clean_text(title_match.group(4) if title_match else "")
+ return {
+ "vod_name": vod_name,
+ "vod_year": vod_year,
+ "vod_area": vod_area,
+ "vod_class": vod_class,
+ "vod_pic": self._build_url(
+ ((root.xpath("//*[contains(@class,'stui-content__thumb')]//img/@data-original") or [""])[0]).strip()
+ ),
+ "vod_content": self._clean_text(
+ "".join(
+ root.xpath(
+ "//*[contains(concat(' ', normalize-space(@class), ' '), ' detail ')][1]//text()"
+ )
+ )
+ ),
+ "vod_director": self._extract_actor_like_field(html, "导演"),
+ "vod_actor": self._extract_actor_like_field(html, "主演"),
+ }
+
+ def _extract_playlists(self, html):
+ root = self.html(html)
+ if root is None:
+ return {"normal": [], "pan": []}
+ tabs = [self._clean_text("".join(node.xpath(".//text()"))) for node in root.xpath("//*[contains(@class,'stui-vodlist__head')]//h3")]
+ playlists = root.xpath("//*[contains(@class,'stui-content__playlist')]")
+ normal = []
+ pan = []
+ for index, playlist in enumerate(playlists):
+ tab_name = tabs[index] if index < len(tabs) else ""
+ items = []
+ for anchor in playlist.xpath(".//a[@href]"):
+ href = ((anchor.xpath("./@href") or [""])[0]).strip()
+ name = self._clean_text("".join(anchor.xpath(".//text()"))) or "正片"
+ play_id = re.sub(r"^/+", "", href)
+ if not play_id:
+ continue
+ items.append(
+ {
+ "name": name,
+ "url": self._build_url(play_id),
+ "play_id": play_id,
+ "tab_name": tab_name,
+ }
+ )
+ if any(keyword in tab_name for keyword in ["网盘", "百度", "夸克", "UC", "阿里", "迅雷"]):
+ pan.extend(items)
+ elif items:
+ normal.extend([f"{item['name']}${item['play_id']}" for item in items])
+ return {"normal": normal, "pan": pan}
+
+ def _extract_pan_url_from_play_page(self, html):
+ matched = re.search(r"player_[a-z0-9_]+\s*=\s*(\{[\s\S]*?\})\s*;?", str(html or ""), re.I)
+ if not matched:
+ return ""
+ try:
+ payload = json.loads(matched.group(1))
+ except Exception:
+ return ""
+ return str(payload.get("url") or "").strip()
+
+ def _detect_pan_type(self, tab_name, share_url):
+ text = str(share_url or "")
+ if "pan.baidu.com" in text:
+ return "baidu"
+ if "pan.quark.cn" in text:
+ return "quark"
+ if "drive.uc.cn" in text:
+ return "uc"
+ if "alipan.com" in text or "aliyundrive.com" in text:
+ return "aliyun"
+ if "pan.xunlei.com" in text:
+ return "xunlei"
+ name = str(tab_name or "").lower()
+ if "百度" in str(tab_name or ""):
+ return "baidu"
+ if "夸克" in str(tab_name or ""):
+ return "quark"
+ if "uc" in name:
+ return "uc"
+ if "阿里" in str(tab_name or "") or "aliyun" in name:
+ return "aliyun"
+ if "迅雷" in str(tab_name or ""):
+ return "xunlei"
+ return ""
+
+ def _extract_pan_groups(self, items):
+ grouped = {}
+ seen = set()
+ order = ["quark", "baidu", "uc", "aliyun", "xunlei"]
+ for item in items:
+ play_html = self._request_html(item["url"], referer=self.headers["Referer"])
+ share_url = self._extract_pan_url_from_play_page(play_html)
+ pan_type = self._detect_pan_type(item.get("tab_name", ""), share_url)
+ if not share_url or not pan_type:
+ continue
+ key = (pan_type, share_url)
+ if key in seen:
+ continue
+ seen.add(key)
+ grouped.setdefault(pan_type, []).append(f"{item['name']}${share_url}")
+ return [{"from": key, "urls": "#".join(grouped[key])} for key in order if grouped.get(key)]
+
+ def detailContent(self, ids):
+ raw_id = str(ids[0]).strip()
+ url = raw_id if raw_id.startswith("http") else self._build_url("/" + raw_id.lstrip("/"))
+ html = self._request_html(url)
+ meta = self._parse_detail_meta(html)
+ playlists = self._extract_playlists(html)
+ pan_groups = self._extract_pan_groups(playlists["pan"])
+ play_from = []
+ play_url = []
+ if playlists["normal"]:
+ play_from.append("zxzj")
+ play_url.append("#".join(playlists["normal"]))
+ for group in pan_groups:
+ play_from.append(group["from"])
+ play_url.append(group["urls"])
+ vod = {
+ "vod_id": raw_id,
+ "vod_name": meta.get("vod_name", ""),
+ "vod_pic": meta.get("vod_pic", ""),
+ "vod_content": meta.get("vod_content", ""),
+ "vod_remarks": "",
+ "vod_year": meta.get("vod_year", ""),
+ "vod_area": meta.get("vod_area", ""),
+ "vod_class": meta.get("vod_class", ""),
+ "vod_lang": "",
+ "vod_director": meta.get("vod_director", ""),
+ "vod_actor": meta.get("vod_actor", ""),
+ "vod_play_from": "$$$".join(play_from),
+ "vod_play_url": "$$$".join(play_url),
+ }
+ return {"list": [vod]}