From 43d4bcc5d826033ad6c4d85cb309424c0d68867c Mon Sep 17 00:00:00 2001 From: Harold <8866033@gmail.com> Date: Sun, 19 Apr 2026 20:13:43 +0800 Subject: [PATCH] feat: add cupfox listing and detail parsing --- py/tests/test_茶杯狐.py | 102 ++++++++++++++++++++++ py/茶杯狐.py | 181 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) diff --git a/py/tests/test_茶杯狐.py b/py/tests/test_茶杯狐.py index b95e238..6dc36c6 100644 --- a/py/tests/test_茶杯狐.py +++ b/py/tests/test_茶杯狐.py @@ -92,3 +92,105 @@ class TestCupfoxSpider(unittest.TestCase): def test_decode2_recovers_shifted_text(self): encoded = "QXdCQ2tE" self.assertEqual(self.spider._decode2(encoded), "P0") + + def test_home_content_and_home_video_content_parse_cards(self): + html = """ + + +
+
+
+
+ + + 更新至1集 +
+
+ + +
+
+
+
+ + """ + self.spider._request_with_firewall = lambda url: html + home = self.spider.homeContent(False) + videos = self.spider.homeVideoContent() + + self.assertEqual( + home["class"], + [{"type_id": "1", "type_name": "电影"}, {"type_id": "2", "type_name": "电视剧"}], + ) + self.assertEqual(len(videos["list"]), 1) + self.assertEqual(videos["list"][0]["vod_id"], "detail/foo") + + def test_category_and_search_content_return_lists_without_pagecount(self): + category_html = """ +
+ + + 9.0 +
+ """ + search_html = """ +
+
+ + +
搜索片
+
搜索备注
+
+
+ """ + + self.spider._request_with_firewall = lambda url: category_html if "/type/" in url else search_html + category = self.spider.categoryContent("1", "2", False, {}) + search = self.spider.searchContent("繁花", False, "3") + + self.assertEqual(category["page"], 2) + self.assertEqual(category["limit"], 20) + self.assertNotIn("pagecount", category) + self.assertEqual(category["list"][0]["vod_id"], "detail/c1") + self.assertEqual(search["page"], 3) + self.assertEqual(search["list"][0]["vod_remarks"], "搜索备注") + self.assertNotIn("pagecount", search) + + def test_detail_content_builds_play_sources(self): + html = """ +

详情标题

+
+
这是简介展开
+
2026
+
导演张导李导
+
演员
+
+
线路一
+
线路二
+
+
+ +
+
+ +
+ """ + self.spider._request_with_firewall = lambda url: html + detail = self.spider.detailContent(["detail/d1"]) + vod = detail["list"][0] + + self.assertEqual(vod["vod_name"], "详情标题") + self.assertEqual(vod["vod_year"], "2026") + self.assertEqual(vod["vod_director"], "张导,李导") + self.assertEqual(vod["vod_actor"], "甲,乙") + self.assertEqual(vod["vod_play_from"], "线路一$$$线路二") + self.assertIn("第1集$play/p1", vod["vod_play_url"]) + self.assertIn("正片$play/p3", vod["vod_play_url"]) diff --git a/py/茶杯狐.py b/py/茶杯狐.py index da4831a..98de6b2 100644 --- a/py/茶杯狐.py +++ b/py/茶杯狐.py @@ -163,3 +163,184 @@ class Spider(BaseSpider): for index in range(1, len(raw), 3): result += lookup.get(raw[index], raw[index]) return result + + def _node_text(self, value): + return re.sub(r"\s+", " ", str(value or "")).strip() + + def _pick_first(self, values): + return values[0] if values else "" + + def _full_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 self._build_url(raw) + + def _parse_home_classes(self, html): + root = self.html(html or "") + if root is None: + return [] + classes = [] + for node in root.xpath("//nav[contains(@class,'bm-item-list')]//a[@href]"): + href = self._pick_first(node.xpath("./@href")) + matched = re.search(r"/type/(\d+)\.html", href or "") + name = self._node_text("".join(node.xpath(".//text()"))) + if matched and name: + classes.append({"type_id": matched.group(1), "type_name": name}) + return classes + + def _parse_cards(self, html): + root = self.html(html or "") + if root is None: + return [] + items = [] + seen = set() + for node in root.xpath("//*[contains(@class,'movie-list-item')]"): + href = self._pick_first(node.xpath(".//a[1]/@href")) + vod_id = self._encode_detail_id(href) + if not vod_id or vod_id in seen: + continue + name = self._node_text( + self._pick_first(node.xpath(".//a[1]/@title")) or "".join(node.xpath(".//a[1]//text()")) + ) + pic = self._full_url( + self._pick_first(node.xpath(".//*[contains(@class,'Lazy')][1]/@data-original")) + or self._pick_first(node.xpath(".//*[contains(@class,'Lazy')][1]/@src")) + ) + note = self._node_text("".join(node.xpath(".//*[contains(@class,'movie-item-note')][1]//text()"))) + if not note: + note = self._node_text("".join(node.xpath(".//*[contains(@class,'movie-item-score')][1]//text()"))) + if name: + seen.add(vod_id) + items.append( + {"vod_id": vod_id, "vod_name": name, "vod_pic": pic, "vod_remarks": note} + ) + return items + + def _parse_search_cards(self, html): + root = self.html(html or "") + if root is None: + return [] + items = [] + for node in root.xpath("//*[contains(@class,'vod-search-list')]//*[contains(@class,'box')]"): + href = self._pick_first(node.xpath(".//a[contains(@class,'cover-link')][1]/@href")) + vod_id = self._encode_detail_id(href) + name = self._node_text("".join(node.xpath(".//*[contains(@class,'movie-title')][1]//text()"))) + pic = self._full_url( + self._pick_first(node.xpath(".//*[contains(@class,'Lazy')][1]/@data-original")) + or self._pick_first(node.xpath(".//*[contains(@class,'Lazy')][1]/@src")) + ) + note = self._node_text("".join(node.xpath(".//*[contains(@class,'movie-item-note')][1]//text()"))) + if not note: + note = self._node_text( + "".join(node.xpath(".//*[contains(@class,'meta') and contains(@class,'getop')][1]//text()")) + ) + if vod_id and name: + items.append( + {"vod_id": vod_id, "vod_name": name, "vod_pic": pic, "vod_remarks": note} + ) + return items + + def homeContent(self, filter): + html = self._request_with_firewall(self.host) + return {"class": self._parse_home_classes(html)} + + def homeVideoContent(self): + html = self._request_with_firewall(self.host) + return {"list": self._parse_cards(html)[: self.page_limit]} + + def categoryContent(self, tid, pg, filter, extend): + page = int(pg) + html = self._request_with_firewall(self._build_url(f"/type/{tid}-{page}.html")) + items = self._parse_cards(html) + return {"page": page, "limit": self.page_limit, "total": page * len(items), "list": items} + + def searchContent(self, key, quick, pg="1"): + page = int(pg) + keyword = self._node_text(key) + if not keyword: + return {"page": page, "total": 0, "list": []} + html = self._request_with_firewall(self._build_url(f"/search/{keyword}----------{page}---.html")) + items = self._parse_search_cards(html) + if quick: + items = items[:10] + return {"page": page, "limit": self.page_limit, "total": page * len(items), "list": items} + + def detailContent(self, ids): + result = {"list": []} + for vod_id in ids: + url = self._decode_detail_id(vod_id) + if not url: + continue + root = self.html(self._request_with_firewall(url) or "") + if root is None: + continue + lines = [] + tabs = root.xpath("//*[contains(@class,'play_source_tab')]//*[contains(@class,'swiper-slide')]") + for index, box in enumerate(root.xpath("//*[contains(@class,'play_list_box')]")): + from_name = "" + if index < len(tabs): + from_name = self._node_text("".join(tabs[index].xpath(".//text()"))) + if not from_name: + from_name = f"线路{index + 1}" + episodes = [] + for anchor in box.xpath(".//*[contains(@class,'content_playlist')]//a[@href]"): + name = self._node_text("".join(anchor.xpath(".//text()"))) + play_id = self._encode_play_id(self._pick_first(anchor.xpath("./@href"))) + if name and play_id: + episodes.append(f"{name}${play_id}") + if episodes: + lines.append((from_name, "#".join(episodes))) + summary_nodes = root.xpath("//*[contains(@class,'summary') and contains(@class,'detailsTxt')][1]") + vod_content = "" + if summary_nodes: + summary = summary_nodes[0] + texts = [] + for text in summary.xpath(".//text()[not(ancestor::*[contains(@class,'ectogg')])]"): + cleaned = self._node_text(text) + if cleaned: + texts.append(cleaned) + vod_content = self._node_text(" ".join(texts)) + years = [ + self._node_text(text) + for text in root.xpath("//*[contains(@class,'scroll-content')]//a/text()") + ] + year = next((value for value in years if re.match(r"^\d{4}$", value)), "") + result["list"].append( + { + "vod_id": vod_id, + "vod_name": self._node_text( + "".join(root.xpath("//h1[contains(@class,'movie-title')][1]//text()")) + ), + "vod_pic": self._full_url( + self._pick_first(root.xpath("//*[contains(@class,'poster')]//img[1]/@src")) + ), + "vod_content": vod_content, + "vod_year": year, + "vod_director": ",".join( + [ + self._node_text(text) + for text in root.xpath( + "//*[contains(@class,'info-data')][contains(.,'导演')]//a/text()" + ) + if self._node_text(text) + ] + ), + "vod_actor": ",".join( + [ + self._node_text(text) + for text in root.xpath( + "//*[contains(@class,'info-data')][contains(.,'演员')]//a/text()" + ) + if self._node_text(text) + ] + ), + "vod_play_from": "$$$".join([item[0] for item in lines]), + "vod_play_url": "$$$".join([item[1] for item in lines]), + } + ) + return result