diff --git a/.gitignore b/.gitignore index bfe6fe3..5276221 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ master-secret.txt signing-private.pem signing-public.pem +py/tests/ +py/docs/ +__pycache__ +.codex diff --git a/py/docs/superpowers/plans/2026-04-18-dbku-spider.md b/py/docs/superpowers/plans/2026-04-18-dbku-spider.md deleted file mode 100644 index c8db55d..0000000 --- a/py/docs/superpowers/plans/2026-04-18-dbku-spider.md +++ /dev/null @@ -1,672 +0,0 @@ -# 独播库 Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的独播库爬虫,支持分类、详情、搜索和站内播放解析。 - -**Architecture:** 以单文件站点脚本 `独播库.py` 承担独播库站点逻辑,内部拆分为 URL 归一化、卡片解析、详情解析和 `player_data` 解码几组辅助方法。测试沿用当前仓库 `unittest + SourceFileLoader + mock` 风格,优先覆盖纯解析函数和高层方法的 mock 网络流程,不依赖真实站点网络。 - -**Tech Stack:** Python 3, `requests`, `lxml`, `unittest`, `unittest.mock`, `base64`, `json`, `urllib.parse` - ---- - -## File Structure - -- Create: `独播库.py` - - 独播库站点实现,继承 `base.spider.Spider` - - 暴露 `init`、`homeContent`、`homeVideoContent`、`categoryContent`、`detailContent`、`searchContent`、`playerContent` - - 私有方法负责 URL 归一化、列表卡片解析、搜索结果解析、详情解析、`player_data` 提取与解码 -- Create: `tests/test_dbku.py` - - 用 `SourceFileLoader` 加载 `独播库.py` - - 用 HTML 片段与 mock response 测试分类、搜索、详情和播放器解析 -- Modify: `docs/superpowers/plans/2026-04-18-dbku-spider.md` - - 仅用于勾选执行状态 - -### Task 1: Scaffold Spider And List Card Parsing - -**Files:** -- Create: `tests/test_dbku.py` -- Create: `独播库.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("dbku_spider", str(ROOT / "独播库.py")).load_module() -Spider = MODULE.Spider - - -class TestDBKUSpider(unittest.TestCase): - def setUp(self): - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - class_ids = [item["type_id"] for item in content["class"]] - self.assertEqual(class_ids, ["index", "movie", "variety", "anime", "hk", "luju"]) - - def test_parse_list_cards_extracts_detail_url_title_cover_and_description(self): - html = """ -
- - 更新至10集 -
- """ - cards = self.spider._parse_list_cards(html) - self.assertEqual( - cards, - [{ - "vod_id": "https://www.dbku.tv/voddetail/123.html", - "vod_name": "示例影片", - "vod_pic": "https://img.example/dbku.jpg", - "vod_remarks": "更新至10集", - }], - ) - - -if __name__ == "__main__": - unittest.main() -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_dbku.TestDBKUSpider -v` -Expected: FAIL with `FileNotFoundError` for `独播库.py` or missing methods. - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import sys -from urllib.parse import urljoin - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "独播库" - self.host = "https://www.dbku.tv" - self.headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/124.0.0.0 Safari/537.36" - ) - } - self.categories = [ - {"type_name": "连续剧", "type_id": "index"}, - {"type_name": "电影", "type_id": "movie"}, - {"type_name": "综艺", "type_id": "variety"}, - {"type_name": "动漫", "type_id": "anime"}, - {"type_name": "港剧", "type_id": "hk"}, - {"type_name": "陆剧", "type_id": "luju"}, - ] - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.categories} - - def homeVideoContent(self): - return {"list": []} - - def _build_url(self, href): - raw = str(href or "").strip() - if not raw: - return "" - if raw.startswith("http://") or raw.startswith("https://"): - return raw - if raw.startswith("//"): - return "https:" + raw - if raw.startswith("/"): - return self.host + raw - return self.host + "/" + raw - - def _parse_list_cards(self, html): - root = self.html(html) - results = [] - if root is None: - return results - - cards = root.xpath("//*[contains(@class,'myui-vodlist__box')]") - seen = set() - for card in cards: - href = "" - title = "" - pic = "" - for anchor in card.xpath(".//a[@href]"): - raw_href = (anchor.xpath("./@href") or [""])[0].strip() - if "/voddetail/" in raw_href: - href = self._build_url(raw_href) - title = ( - (anchor.xpath("./@title") or [""])[0].strip() - or "".join(anchor.xpath(".//text()")).strip() - ) - pic = ( - (anchor.xpath("./@data-original") or [""])[0].strip() - or (anchor.xpath("./@src") or [""])[0].strip() - ) - break - if not href or href in seen or not title: - continue - remarks = "".join(card.xpath(".//*[contains(@class,'pic-text')][1]//text()")).strip() - seen.add(href) - results.append({ - "vod_id": href, - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_remarks": remarks, - }) - return results -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_dbku.TestDBKUSpider -v` -Expected: PASS for the two new tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_dbku.py 独播库.py -git commit -m "feat: scaffold dbku spider parsing" -``` - -### Task 2: Add Category And Search Flows - -**Files:** -- Modify: `tests/test_dbku.py` -- Modify: `独播库.py` - -- [ ] **Step 1: Write the failing test** - -```python -from unittest.mock import patch - - -class TestDBKUSpider(unittest.TestCase): - @patch.object(Spider, "fetch") - def test_request_html_uses_dbku_headers(self, mock_fetch): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse("ok") - html = self.spider._request_html("/vodtype/1--------1---.html", expect_xpath="//body") - self.assertIn("ok", html) - called_headers = mock_fetch.call_args.kwargs["headers"] - self.assertEqual(called_headers["Referer"], "https://www.dbku.tv") - self.assertEqual(called_headers["Origin"], "https://www.dbku.tv") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_page_result(self, mock_request_html): - mock_request_html.return_value = """ -
- - HD -
- """ - result = self.spider.categoryContent("movie", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertEqual(result["list"][0]["vod_pic"], "https://www.dbku.tv/cover.jpg") - - def test_parse_search_cards_prefers_search_list_container(self): - html = """ -
-
- -
-
-
- -
- """ - results = self.spider._parse_search_cards(html) - self.assertEqual(len(results), 1) - self.assertEqual(results[0]["vod_name"], "搜索命中") - - @patch.object(Spider, "_request_html") - def test_search_content_reuses_search_parser(self, mock_request_html): - mock_request_html.return_value = """ -
-
- -
-
- """ - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(result["list"][0]["vod_id"], "https://www.dbku.tv/voddetail/321.html") - self.assertEqual(result["list"][0]["vod_name"], "搜索影片") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_dbku.TestDBKUSpider.test_request_html_uses_dbku_headers tests.test_dbku.TestDBKUSpider.test_category_content_builds_page_result tests.test_dbku.TestDBKUSpider.test_parse_search_cards_prefers_search_list_container tests.test_dbku.TestDBKUSpider.test_search_content_reuses_search_parser -v` -Expected: FAIL with missing `_request_html`, `categoryContent`, `searchContent`, or `_parse_search_cards`. - -- [ ] **Step 3: Write minimal implementation** - -```python -from urllib.parse import quote -from lxml import etree - - -class Spider(BaseSpider): - def __init__(self): - self.name = "独播库" - self.host = "https://www.dbku.tv" - self.headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/124.0.0.0 Safari/537.36" - ) - } - self.categories = [ - {"type_name": "连续剧", "type_id": "index"}, - {"type_name": "电影", "type_id": "movie"}, - {"type_name": "综艺", "type_id": "variety"}, - {"type_name": "动漫", "type_id": "anime"}, - {"type_name": "港剧", "type_id": "hk"}, - {"type_name": "陆剧", "type_id": "luju"}, - ] - self.category_paths = { - "index": "/vodtype/2--------{pg}---.html", - "movie": "/vodtype/1--------{pg}---.html", - "variety": "/vodtype/3--------{pg}---.html", - "anime": "/vodtype/4--------{pg}---.html", - "hk": "/vodtype/20--------{pg}---.html", - "luju": "/vodtype/13--------{pg}---.html", - } - - def _request_html(self, path_or_url, expect_xpath=None, referer=None): - target = path_or_url if path_or_url.startswith("http") else self._build_url(path_or_url) - headers = dict(self.headers) - headers["Referer"] = referer or self.host - headers["Origin"] = self.host - response = self.fetch(target, headers=headers, timeout=10) - if response.status_code != 200: - return "" - html = response.text or "" - if expect_xpath: - root = self.html(html) - if root is None or not root.xpath(expect_xpath): - return "" - return html - - def _parse_search_cards(self, html): - root = self.html(html) - if root is None: - return [] - search_list = root.xpath("//*[@id='searchList']") - if search_list: - cards = search_list[0].xpath(".//*[contains(@class,'myui-vodlist__box')]") - parsed = self._parse_cards_from_nodes(cards) - if parsed: - return parsed - return self._parse_list_cards(html) - - def _parse_cards_from_nodes(self, nodes): - results = [] - seen = set() - for card in nodes: - snippet = self._parse_list_cards(etree.tostring(card, encoding='unicode')) - for item in snippet: - if item["vod_id"] in seen: - continue - seen.add(item["vod_id"]) - results.append(item) - return results - - def _page_result(self, items, pg): - page = int(pg) - pagecount = page + 1 if items else page - return { - "list": items, - "page": page, - "pagecount": pagecount, - "limit": len(items), - "total": pagecount * max(len(items), 1), - } - - def categoryContent(self, tid, pg, filter, extend): - path = self.category_paths.get(tid, self.category_paths["index"]).format(pg=pg) - html = self._request_html(path, expect_xpath="//*[contains(@class,'myui-vodlist__box')]") - return self._page_result(self._parse_list_cards(html), pg) - - def searchContent(self, key, quick, pg="1"): - path = "/vodsearch/-------------.html?wd={0}&submit=".format(quote(key)) - html = self._request_html(path, expect_xpath="//*[@id='searchList']|//*[contains(@class,'myui-vodlist__box')]") - return self._page_result(self._parse_search_cards(html), pg) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_dbku.TestDBKUSpider.test_request_html_uses_dbku_headers tests.test_dbku.TestDBKUSpider.test_category_content_builds_page_result tests.test_dbku.TestDBKUSpider.test_parse_search_cards_prefers_search_list_container tests.test_dbku.TestDBKUSpider.test_search_content_reuses_search_parser -v` -Expected: PASS for all four tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_dbku.py 独播库.py -git commit -m "feat: add dbku category and search flow" -``` - -### Task 3: Add Detail Parsing And Episode List Extraction - -**Files:** -- Modify: `tests/test_dbku.py` -- Modify: `独播库.py` - -- [ ] **Step 1: Write the failing test** - -```python -class TestDBKUSpider(unittest.TestCase): - def test_parse_detail_page_extracts_meta_and_episodes(self): - html = """ -
- -
-
-

独播剧

-

年份:2025

-

地区:大陆

-

导演:张三

-

主演:李四

-
- 一段剧情简介 - 第1集 - 第2集 - """ - result = self.spider._parse_detail_page(html, "https://www.dbku.tv/voddetail/100.html") - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "独播剧") - self.assertEqual(vod["vod_year"], "2025") - self.assertEqual(vod["vod_play_from"], "独播库") - self.assertIn("第1集$https://www.dbku.tv/vodplay/100-1-1.html", vod["vod_play_url"]) - - @patch.object(Spider, "_request_html") - def test_detail_content_reads_from_vod_id_url(self, mock_request_html): - mock_request_html.return_value = """ -

详情影片

- 第1集 - """ - result = self.spider.detailContent(["https://www.dbku.tv/voddetail/200.html"]) - self.assertEqual(result["list"][0]["vod_id"], "https://www.dbku.tv/voddetail/200.html") - self.assertEqual(result["list"][0]["vod_name"], "详情影片") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_dbku.TestDBKUSpider.test_parse_detail_page_extracts_meta_and_episodes tests.test_dbku.TestDBKUSpider.test_detail_content_reads_from_vod_id_url -v` -Expected: FAIL with missing `_parse_detail_page` or `detailContent`. - -- [ ] **Step 3: Write minimal implementation** - -```python -import re - - -class Spider(BaseSpider): - def _extract_text_by_prefix(self, html, prefixes): - texts = re.findall(r">([^<>]+)<", html) - for text in texts: - clean = text.strip() - for prefix in prefixes: - if clean.startswith(prefix): - return clean.split(":", 1)[-1].strip() - return "" - - def _parse_detail_page(self, html, vod_id): - root = self.html(html) - title = ((root.xpath("//*[contains(@class,'title')][1]//text()") or [""])[0]).strip() - pic = ( - (root.xpath("//*[contains(@class,'myui-content__thumb')]//img/@data-original") or [""])[0].strip() - or (root.xpath("//*[contains(@class,'myui-content__thumb')]//img/@src") or [""])[0].strip() - ) - content = "".join(root.xpath("//*[contains(@class,'data')][1]//text()")).strip() - - episodes = [] - seen = set() - for href, label in re.findall(r']+href=["\\\']([^"\\\']*/vodplay/\\d+-\\d+-\\d+\\.html[^"\\\']*)["\\\'][^>]*>([\\s\\S]*?)', html, re.I): - url = self._build_url(href) - name = re.sub(r"<[^>]*>", "", label).strip() - if not url or not name or "立即播放" in name or url in seen: - continue - seen.add(url) - episodes.append(f"{name}${url}") - - vod = { - "vod_id": vod_id, - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_year": self._extract_text_by_prefix(html, ["年份:"]), - "vod_area": self._extract_text_by_prefix(html, ["地区:"]), - "vod_actor": self._extract_text_by_prefix(html, ["主演:"]), - "vod_director": self._extract_text_by_prefix(html, ["导演:"]), - "vod_content": content, - "vod_play_from": "独播库", - "vod_play_url": "#".join(episodes), - } - return {"list": [vod]} - - def detailContent(self, ids): - vod_id = ids[0] - html = self._request_html(vod_id, expect_xpath="//*[contains(@class,'title')]|//a[contains(@href,'/vodplay/')]") - return self._parse_detail_page(html, vod_id) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_dbku.TestDBKUSpider.test_parse_detail_page_extracts_meta_and_episodes tests.test_dbku.TestDBKUSpider.test_detail_content_reads_from_vod_id_url -v` -Expected: PASS for both detail tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_dbku.py 独播库.py -git commit -m "feat: add dbku detail parsing" -``` - -### Task 4: Add player_data Extraction And Encrypt Decoders - -**Files:** -- Modify: `tests/test_dbku.py` -- Modify: `独播库.py` - -- [ ] **Step 1: Write the failing test** - -```python -import base64 - - -class TestDBKUSpider(unittest.TestCase): - def test_parse_player_data_reads_json_block(self): - html = '' - data = self.spider._parse_player_data(html) - self.assertEqual(data["url"], "https://video.example/a.m3u8") - - def test_decode_play_url_by_encrypt_supports_modes_0_1_2_3(self): - self.assertEqual( - self.spider._decode_play_url_by_encrypt("https://video.example/raw.m3u8", 0), - "https://video.example/raw.m3u8", - ) - self.assertEqual( - self.spider._decode_play_url_by_encrypt("https%3A//video.example/escape.m3u8", 1), - "https://video.example/escape.m3u8", - ) - mode2 = base64.b64encode("https://video.example/base64.m3u8".encode("utf-8")).decode("utf-8") - self.assertEqual( - self.spider._decode_play_url_by_encrypt(mode2, 2), - "https://video.example/base64.m3u8", - ) - mode3_raw = "ABCDEFGHhttps://video.example/trimmed.m3u8HGFEDCBA" - mode3 = base64.b64encode(mode3_raw.encode("utf-8")).decode("utf-8") - self.assertEqual( - self.spider._decode_play_url_by_encrypt("12345678" + mode3, 3), - "https://video.example/trimmed.m3u8", - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_dbku.TestDBKUSpider.test_parse_player_data_reads_json_block tests.test_dbku.TestDBKUSpider.test_decode_play_url_by_encrypt_supports_modes_0_1_2_3 -v` -Expected: FAIL with missing `_parse_player_data` or `_decode_play_url_by_encrypt`. - -- [ ] **Step 3: Write minimal implementation** - -```python -import base64 -import json -from urllib.parse import unquote - - -class Spider(BaseSpider): - def _parse_player_data(self, html): - matched = re.search(r"var\\s+player_data\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*", html, re.I) - if not matched: - matched = re.search(r"var\\s+player_[^=]*\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*", html, re.I) - if not matched: - return None - try: - return json.loads(matched.group(1)) - except Exception: - return None - - def _decode_play_url_by_encrypt(self, value, encrypt): - raw = str(value or "") - mode = int(encrypt or 0) - if not raw: - return "" - try: - if mode == 1: - return unquote(raw) - if mode == 2: - decoded = base64.b64decode(raw).decode("utf-8") - return unquote(decoded) - if mode == 3: - text = raw[8:] if len(raw) > 16 else raw - text = base64.b64decode(text).decode("utf-8") - if len(text) > 16: - text = text[8:-8] - return text - return raw - except Exception: - return raw -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_dbku.TestDBKUSpider.test_parse_player_data_reads_json_block tests.test_dbku.TestDBKUSpider.test_decode_play_url_by_encrypt_supports_modes_0_1_2_3 -v` -Expected: PASS for both decoder tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_dbku.py 独播库.py -git commit -m "feat: add dbku player decoders" -``` - -### Task 5: Wire High-Level playerContent And Full Regression - -**Files:** -- Modify: `tests/test_dbku.py` -- Modify: `独播库.py` - -- [ ] **Step 1: Write the failing test** - -```python -class TestDBKUSpider(unittest.TestCase): - @patch.object(Spider, "_request_html") - def test_player_content_returns_decoded_direct_url(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.playerContent("独播库", "https://www.dbku.tv/vodplay/100-1-1.html", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/final.m3u8") - self.assertEqual(result["header"]["Referer"], "https://www.dbku.tv/vodplay/100-1-1.html") - - @patch.object(Spider, "_request_html") - def test_player_content_follows_internal_jump(self, mock_request_html): - mock_request_html.side_effect = [ - '', - '', - ] - result = self.spider.playerContent("独播库", "https://www.dbku.tv/vodplay/100-1-1.html", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/jump-final.m3u8") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_dbku.TestDBKUSpider.test_player_content_returns_decoded_direct_url tests.test_dbku.TestDBKUSpider.test_player_content_follows_internal_jump -v` -Expected: FAIL with missing `playerContent` or incorrect return payload. - -- [ ] **Step 3: Write minimal implementation** - -```python -class Spider(BaseSpider): - def _build_player_headers(self, referer): - return { - "User-Agent": self.headers["User-Agent"], - "Referer": referer, - } - - def playerContent(self, flag, id, vipFlags): - current = id - for _ in range(3): - html = self._request_html(current, referer=self.host) - data = self._parse_player_data(html) - if not data: - return {"parse": 0, "playUrl": "", "url": ""} - decoded = self._decode_play_url_by_encrypt(data.get("url", ""), data.get("encrypt", 0)) - play_url = self._build_url(decoded) - if not play_url: - return {"parse": 0, "playUrl": "", "url": ""} - if self.host in play_url and "/vodplay/" in play_url and play_url != current: - current = play_url - continue - return { - "parse": 0, - "playUrl": "", - "url": play_url, - "header": self._build_player_headers(current), - } - return {"parse": 0, "playUrl": "", "url": ""} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_dbku.TestDBKUSpider.test_player_content_returns_decoded_direct_url tests.test_dbku.TestDBKUSpider.test_player_content_follows_internal_jump -v` -Expected: PASS for both player tests. - -- [ ] **Step 5: Run the full regression suite** - -Run: `python -m unittest tests.test_dbku -v` -Expected: PASS for the complete `tests/test_dbku.py` suite. - -- [ ] **Step 6: Commit** - -```bash -git add tests/test_dbku.py 独播库.py -git commit -m "feat: finish dbku spider implementation" -``` diff --git a/py/docs/superpowers/plans/2026-04-18-libvio-spider.md b/py/docs/superpowers/plans/2026-04-18-libvio-spider.md deleted file mode 100644 index 83ff6a7..0000000 --- a/py/docs/superpowers/plans/2026-04-18-libvio-spider.md +++ /dev/null @@ -1,524 +0,0 @@ -# LibVIO Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 LibVIO 爬虫,支持 `home/category/detail/search/player` 全链路。 - -**Architecture:** 采用单文件站点脚本 `libvio.py` 承担 LibVIO 规则,内部拆分为 URL 归一化、列表卡片解析、详情字段解析、播放配置提取与播放器 API 解析几组辅助方法。测试沿用当前仓库 `unittest + SourceFileLoader + mock` 风格,优先覆盖纯解析函数和高层方法的 mock 网络流程,不依赖真实站点网络。 - -**Tech Stack:** Python 3, `requests`, `lxml`, `unittest`, `unittest.mock`, `json`, `re`, `urllib.parse` - ---- - -## File Structure - -- Create: `libvio.py` - - LibVIO 站点实现,继承 `base.spider.Spider` - - 暴露 `init`、`homeContent`、`homeVideoContent`、`categoryContent`、`detailContent`、`searchContent`、`playerContent` - - 私有方法负责 URL/ID 归一化、列表卡片解析、详情解析、播放配置与 API 解析 -- Create: `tests/test_libvio.py` - - 用 `SourceFileLoader` 加载 `libvio.py` - - 用 HTML/JS 片段与 mock response 测试首页、分类、搜索、详情和播放器解析 - -### Task 1: Scaffold Spider, Home Flow, And List/Search Parsing - -**Files:** -- Create: `tests/test_libvio.py` -- Create: `libvio.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("libvio_spider", str(ROOT / "libvio.py")).load_module() -Spider = MODULE.Spider - - -class TestLibVioSpider(unittest.TestCase): - def setUp(self): - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - class_ids = [item["type_id"] for item in content["class"]] - self.assertEqual(class_ids, ["index", "movie", "series", "anime", "jpandkr", "euandus"]) - - def test_parse_list_cards_extracts_compact_vod_id(self): - html = """ -
- - 更新至10集 -
- """ - cards = self.spider._parse_list_cards(html) - self.assertEqual( - cards, - [{ - "vod_id": "456", - "vod_name": "示例影片", - "vod_pic": "https://libvio.site/cover.jpg", - "vod_remarks": "更新至10集", - }], - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_reuses_list_card_parser(self, mock_request_html): - mock_request_html.return_value = """ -
- - HD -
- """ - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_id"], "111") - self.assertEqual(result["list"][0]["vod_name"], "最近更新") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_page_result(self, mock_request_html): - mock_request_html.return_value = """ -
- - 完结 -
- """ - result = self.spider.categoryContent("movie", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "222") - - @patch.object(Spider, "_request_html") - def test_search_content_reuses_card_parser(self, mock_request_html): - mock_request_html.return_value = """ -
- - 抢先版 -
- """ - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(result["list"][0]["vod_id"], "333") - self.assertEqual(result["list"][0]["vod_name"], "搜索影片") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_libvio.TestLibVioSpider -v` -Expected: FAIL with `FileNotFoundError` for `libvio.py` or missing methods. - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import re -import sys -from urllib.parse import quote - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "LibVIO" - self.host = "https://libvio.site" - self.headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/124.0.0.0 Safari/537.36" - ) - } - self.categories = [ - {"type_name": "最近更新", "type_id": "index"}, - {"type_name": "电影", "type_id": "movie"}, - {"type_name": "电视剧", "type_id": "series"}, - {"type_name": "动漫", "type_id": "anime"}, - {"type_name": "日韩剧", "type_id": "jpandkr"}, - {"type_name": "欧美剧", "type_id": "euandus"}, - ] - self.category_paths = { - "index": "/", - "movie": "/type/1-{pg}.html", - "series": "/type/2-{pg}.html", - "anime": "/type/4-{pg}.html", - "jpandkr": "/type/15-{pg}.html", - "euandus": "/type/16-{pg}.html", - } - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.categories} - - def _build_url(self, href): - raw = str(href or "").strip() - if not raw: - return "" - if raw.startswith(("http://", "https://")): - return raw - if raw.startswith("//"): - return "https:" + raw - return self.host + "/" + raw.lstrip("/") - - def _extract_vod_id(self, href): - raw = str(href or "").strip() - matched = re.search(r"/detail/(\d+)\.html", raw) - if matched: - return matched.group(1) - if re.fullmatch(r"\d+", raw): - return raw - return "" - - def _parse_list_cards(self, html): - root = self.html(html) - results = [] - if root is None: - return results - for card in root.xpath("//*[contains(@class,'stui-vodlist__box')]"): - href = ((card.xpath(".//a[@href][1]/@href") or [""])[0]).strip() - vod_id = self._extract_vod_id(href) - title = ((card.xpath(".//a[@title][1]/@title") or [""])[0]).strip() - pic = ( - (card.xpath(".//a[@data-original][1]/@data-original") or [""])[0].strip() - or (card.xpath(".//img[@data-original][1]/@data-original") or [""])[0].strip() - or (card.xpath(".//img[@src][1]/@src") or [""])[0].strip() - ) - remarks = "".join(card.xpath(".//*[contains(@class,'pic-text')][1]//text()")).strip() - if not vod_id or not title: - continue - results.append({ - "vod_id": vod_id, - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_remarks": remarks, - }) - return results - - def _request_html(self, path_or_url, expect_xpath=None, referer=None): - target = path_or_url if str(path_or_url).startswith("http") else self._build_url(path_or_url) - headers = dict(self.headers) - headers["Referer"] = referer or (self.host + "/") - response = self.fetch(target, headers=headers, timeout=10) - if response.status_code != 200: - return "" - return response.text or "" - - def _page_result(self, items, pg): - page = int(pg) - pagecount = page + 1 if items else page - return {"list": items, "page": page, "pagecount": pagecount, "limit": len(items), "total": pagecount * max(len(items), 1)} - - def homeVideoContent(self): - html = self._request_html("/", expect_xpath="//*[contains(@class,'stui-vodlist__box')]") - return {"list": self._parse_list_cards(html)} - - def categoryContent(self, tid, pg, filter, extend): - path = self.category_paths.get(tid, self.category_paths["movie"]).format(pg=pg) - html = self._request_html(path, expect_xpath="//*[contains(@class,'stui-vodlist__box')]") - return self._page_result(self._parse_list_cards(html), pg) - - def searchContent(self, key, quick, pg="1"): - path = "/search/-------------.html?wd={0}".format(quote(key)) - html = self._request_html(path, expect_xpath="//*[contains(@class,'stui-vodlist__box')]") - return self._page_result(self._parse_list_cards(html), pg) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_libvio.TestLibVioSpider -v` -Expected: PASS for the five new tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_libvio.py libvio.py -git commit -m "feat: scaffold libvio list parsing" -``` - -### Task 2: Add Detail Parsing And Compact Play IDs - -**Files:** -- Modify: `tests/test_libvio.py` -- Modify: `libvio.py` - -- [ ] **Step 1: Write the failing test** - -```python -class TestLibVioSpider(unittest.TestCase): - def test_parse_detail_page_extracts_fields_and_filters_pan_sources(self): - html = """ -
- -
-
-

示例剧

-

类型:剧情

-

地区:大陆

-

年份:2026

-

导演:张三

-

主演:李四王五

-

简介:一段剧情简介

-
-

在线播放

- -

夸克资源

- - """ - result = self.spider._parse_detail_page(html, "999") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "999") - self.assertEqual(vod["path"], "https://libvio.site/detail/999.html") - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["type_name"], "剧情") - self.assertEqual(vod["vod_area"], "大陆") - self.assertEqual(vod["vod_year"], "2026") - self.assertEqual(vod["vod_director"], "张三") - self.assertEqual(vod["vod_actor"], "李四,王五") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_play_from"], "LibVIO") - self.assertEqual(vod["vod_play_url"], "第1集$999-1-1#第2集$999-1-2") - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_detail_request_url_from_vod_id(self, mock_request_html): - mock_request_html.return_value = '

详情影片

' - result = self.spider.detailContent(["123"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://libvio.site/detail/123.html") - self.assertEqual(result["list"][0]["vod_id"], "123") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_libvio.TestLibVioSpider.test_parse_detail_page_extracts_fields_and_filters_pan_sources tests.test_libvio.TestLibVioSpider.test_detail_content_builds_detail_request_url_from_vod_id -v` -Expected: FAIL with missing `_parse_detail_page`, `detailContent`, or incorrect field values. - -- [ ] **Step 3: Write minimal implementation** - -```python - def _extract_play_id(self, href): - raw = str(href or "").strip() - matched = re.search(r"/play/([^./]+-\d+-\d+)\.html", raw) - if matched: - return matched.group(1) - if re.fullmatch(r"[^./]+-\d+-\d+", raw): - return raw - return "" - - def _build_detail_request_url(self, vod_id): - return f"{self.host}/detail/{self._extract_vod_id(vod_id)}.html" - - def _build_play_request_url(self, play_id): - return f"{self.host}/play/{self._extract_play_id(play_id)}.html" - - def _clean_text(self, text): - return re.sub(r"\s+", " ", str(text or "").replace("\xa0", " ")).strip() - - def _extract_detail_field(self, root, label, joiner=""): - nodes = root.xpath(f'.//span[contains(normalize-space(.), "{label}:")]') - if not nodes: - return "" - values = [] - for sibling in nodes[0].itersiblings(): - if sibling.tag == "span" and "text-muted" in " ".join(sibling.xpath("./@class")): - break - text = self._clean_text("".join(sibling.xpath(".//text()"))) - if text: - values.append(text) - values = [value for index, value in enumerate(values) if value and value not in values[:index]] - return joiner.join(values) if joiner else "".join(values) - - def _parse_detail_page(self, html, vod_id): - root = self.html(html) - detail_root = (root.xpath("//*[contains(@class,'stui-content__detail')][1]") or [root])[0] - title = ((detail_root.xpath(".//*[contains(@class,'title')][1]//text()") or [""])[0]).strip() - pic = ( - (root.xpath("//*[contains(@class,'stui-content__thumb')]//img/@data-original") or [""])[0].strip() - or (root.xpath("//*[contains(@class,'stui-content__thumb')]//img/@src") or [""])[0].strip() - ) - episodes = [] - for playlist in root.xpath("//*[contains(@class,'stui-content__playlist')]"): - heading = self._clean_text("".join(playlist.xpath("./preceding-sibling::*[1]//text()"))) - if any(keyword in heading for keyword in ("夸克", "UC", "网盘")): - continue - for anchor in playlist.xpath(".//a[@href]"): - play_id = self._extract_play_id((anchor.xpath("./@href") or [""])[0]) - name = self._clean_text("".join(anchor.xpath(".//text()"))) - if play_id and name: - episodes.append(f"{name}${play_id}") - vod = { - "vod_id": vod_id, - "path": self._build_detail_request_url(vod_id), - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_tag": "", - "vod_time": "", - "vod_remarks": "", - "vod_play_from": "LibVIO", - "vod_play_url": "#".join(episodes), - "type_name": self._extract_detail_field(detail_root, "类型"), - "vod_content": self._extract_detail_field(detail_root, "简介"), - "vod_year": self._extract_detail_field(detail_root, "年份"), - "vod_area": self._extract_detail_field(detail_root, "地区"), - "vod_lang": "", - "vod_director": self._extract_detail_field(detail_root, "导演", joiner=","), - "vod_actor": self._extract_detail_field(detail_root, "主演", joiner=","), - } - return {"list": [vod]} - - def detailContent(self, ids): - vod_id = ids[0] - html = self._request_html(self._build_detail_request_url(vod_id), expect_xpath="//*[contains(@class,'stui-content__playlist')]") - return self._parse_detail_page(html, vod_id) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_libvio.TestLibVioSpider.test_parse_detail_page_extracts_fields_and_filters_pan_sources tests.test_libvio.TestLibVioSpider.test_detail_content_builds_detail_request_url_from_vod_id -v` -Expected: PASS for the two new tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_libvio.py libvio.py -git commit -m "feat: add libvio detail parsing" -``` - -### Task 3: Add Player Config Parsing And Final URL Resolution - -**Files:** -- Modify: `tests/test_libvio.py` -- Modify: `libvio.py` - -- [ ] **Step 1: Write the failing test** - -```python -class TestLibVioSpider(unittest.TestCase): - def test_extract_player_config_reads_json_assignment(self): - html = '' - data = self.spider._parse_player_config(html) - self.assertEqual(data["from"], "line") - - def test_extract_play_api_base_reads_player_js(self): - body = 'var player={}; src="/player/api.php?url=";' - self.assertEqual(self.spider._extract_play_api_base(body), "https://libvio.site/player/api.php?url=") - - @patch.object(Spider, "_request_html") - def test_player_content_resolves_direct_api_url(self, mock_request_html): - mock_request_html.side_effect = [ - '', - 'src="/player/api.php?url="', - 'var urls="https://video.example/final.m3u8";', - ] - result = self.spider.playerContent("LibVIO", "999-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/final.m3u8") - self.assertEqual(result["header"]["Referer"], "https://libvio.site/") - - @patch.object(Spider, "_request_html") - def test_player_content_returns_empty_for_pan_source(self, mock_request_html): - mock_request_html.return_value = '' - self.assertEqual(self.spider.playerContent("LibVIO", "999-1-1", {}), {"parse": 0, "playUrl": "", "url": ""}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_libvio.TestLibVioSpider.test_extract_player_config_reads_json_assignment tests.test_libvio.TestLibVioSpider.test_extract_play_api_base_reads_player_js tests.test_libvio.TestLibVioSpider.test_player_content_resolves_direct_api_url tests.test_libvio.TestLibVioSpider.test_player_content_returns_empty_for_pan_source -v` -Expected: FAIL with missing player helpers or unresolved final URL. - -- [ ] **Step 3: Write minimal implementation** - -```python -import json - - def _parse_player_config(self, html): - matched = re.search(r"player_[a-z0-9_]+\s*=\s*(\{[\s\S]*?\})\s*;?", html, re.I) - if not matched: - return None - try: - return json.loads(matched.group(1)) - except Exception: - return None - - def _extract_play_api_base(self, body): - matched = re.search(r'src\s*=\s*["\']([^"\']+)["\']', body, re.I) - if not matched: - return "" - return self._build_url(matched.group(1)) - - def _extract_playable_url(self, body): - patterns = [ - r'["\']?urls?["\']?\s*:\s*["\']([^"\']+)["\']', - r'(?:var|let|const)\s+urls?\s*=\s*["\']([^"\']+)["\']', - r'["\']url["\']\s*:\s*["\']([^"\']+\.(?:m3u8|mp4)[^"\']*)["\']', - r'url\s*=\s*["\']([^"\']+\.(?:m3u8|mp4)[^"\']*)["\']', - ] - for pattern in patterns: - matched = re.search(pattern, body, re.I) - if matched: - return self._build_url(matched.group(1).replace("\\/", "/").replace("&", "&")) - return "" - - def _request_player_js(self, source): - return self._request_html(f"/static/player/{source}.js", referer=self.host + "/") - - def playerContent(self, flag, id, vipFlags): - play_page_url = self._build_play_request_url(id) - detail_html = self._request_html(play_page_url, referer=self.host + "/") - config = self._parse_player_config(detail_html) - if not config or config.get("from") in ("kuake", "uc"): - return {"parse": 0, "playUrl": "", "url": ""} - if config.get("from") == "ty_new1": - api_body = self._request_html(f"/vid/ty4.php?url={config.get('url', '')}", referer=self.host + "/") - final_url = self._extract_playable_url(api_body) - else: - player_js = self._request_player_js(config.get("from", "")) - api_base = self._extract_play_api_base(player_js) - api_url = "{base}{url}&next={next}&id={id}&nid={nid}".format( - base=api_base, - url=config.get("url", ""), - next=config.get("link_next", ""), - id=config.get("id", ""), - nid=config.get("nid", ""), - ) - api_body = self._request_html(api_url, referer=self.host + "/") - final_url = self._extract_playable_url(api_body) - if not final_url: - return {"parse": 0, "playUrl": "", "url": ""} - return { - "parse": 0, - "playUrl": "", - "url": final_url, - "header": {"User-Agent": self.headers["User-Agent"], "Referer": self.host + "/"}, - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_libvio.TestLibVioSpider.test_extract_player_config_reads_json_assignment tests.test_libvio.TestLibVioSpider.test_extract_play_api_base_reads_player_js tests.test_libvio.TestLibVioSpider.test_player_content_resolves_direct_api_url tests.test_libvio.TestLibVioSpider.test_player_content_returns_empty_for_pan_source -v` -Expected: PASS for the four new tests. - -- [ ] **Step 5: Run the full suite** - -Run: `python -m unittest tests.test_libvio -v` -Expected: PASS with all `libvio` tests green. - -- [ ] **Step 6: Commit** - -```bash -git add tests/test_libvio.py libvio.py -git commit -m "feat: add libvio player parsing" -``` diff --git a/py/docs/superpowers/plans/2026-04-18-youknow-spider.md b/py/docs/superpowers/plans/2026-04-18-youknow-spider.md deleted file mode 100644 index 6d6f2c9..0000000 --- a/py/docs/superpowers/plans/2026-04-18-youknow-spider.md +++ /dev/null @@ -1,620 +0,0 @@ -# YouKnowTV Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 YouKnowTV 爬虫,支持 `home/homeVideo/category/detail/search/player` 全链路。 - -**Architecture:** 采用单文件站点脚本 `youknow.py` 承担 YouKnowTV 规则,内部拆分为 URL/ID 归一化、列表卡片解析、详情字段解析、多线路剧集对齐和播放候选直链解析几组辅助方法。测试沿用当前仓库 `unittest + SourceFileLoader + mock` 风格,优先覆盖纯解析函数和高层方法的 mock 网络流程,不依赖真实站点网络。 - -**Tech Stack:** Python 3, `requests`, `lxml`, `unittest`, `unittest.mock`, `json`, `base64`, `re`, `urllib.parse` - ---- - -## File Structure - -- Create: `youknow.py` - - YouKnowTV 站点实现,继承 `base.spider.Spider` - - 暴露 `init`、`homeContent`、`homeVideoContent`、`categoryContent`、`detailContent`、`searchContent`、`playerContent` - - 私有方法负责 URL/ID 归一化、列表卡片解析、详情解析、剧集 payload 序列化与播放候选直链解析 -- Create: `tests/test_youknow.py` - - 用 `SourceFileLoader` 加载 `youknow.py` - - 用 HTML/JS 片段与 mock response 测试首页、分类、搜索、详情和播放器解析 - -### Task 1: Scaffold Spider, Home Flow, And List/Search Parsing - -**Files:** -- Create: `tests/test_youknow.py` -- Create: `youknow.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("youknow_spider", str(ROOT / "youknow.py")).load_module() -Spider = MODULE.Spider - - -class TestYouKnowSpider(unittest.TestCase): - def setUp(self): - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - class_ids = [item["type_id"] for item in content["class"]] - self.assertEqual(class_ids, ["index", "drama", "movie", "variety", "anime", "short", "doc"]) - - def test_parse_list_cards_extracts_compact_vod_id(self): - html = """ - -
更新至10集
-
- """ - cards = self.spider._parse_list_cards(html) - self.assertEqual( - cards, - [{ - "vod_id": "1234", - "vod_name": "示例影片", - "vod_pic": "https://www.youknow.tv/cover.jpg", - "vod_remarks": "更新至10集", - }], - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_uses_today_updates_page(self, mock_request_html): - mock_request_html.return_value = """ - -
HD
-
- """ - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_id"], "111") - self.assertEqual(result["list"][0]["vod_name"], "今日更新") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_page_result(self, mock_request_html): - mock_request_html.return_value = """ - -
完结
-
- """ - result = self.spider.categoryContent("movie", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "222") - - @patch.object(Spider, "_request_html") - def test_search_content_reuses_card_parser(self, mock_request_html): - mock_request_html.return_value = """ - -
抢先版
-
- """ - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(result["list"][0]["vod_id"], "333") - self.assertEqual(result["list"][0]["vod_name"], "搜索影片") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_youknow.TestYouKnowSpider -v` -Expected: FAIL with `FileNotFoundError` for `youknow.py` or missing methods. - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import re -import sys -from urllib.parse import quote - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "YouKnowTV" - self.host = "https://www.youknow.tv" - self.headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/137.0.0.0 Safari/537.36" - ), - "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", - } - self.categories = [ - {"type_name": "今日更新", "type_id": "index"}, - {"type_name": "剧集", "type_id": "drama"}, - {"type_name": "电影", "type_id": "movie"}, - {"type_name": "综艺", "type_id": "variety"}, - {"type_name": "动漫", "type_id": "anime"}, - {"type_name": "短剧", "type_id": "short"}, - {"type_name": "纪录片", "type_id": "doc"}, - ] - self.category_paths = { - "index": "/label/new/", - "drama": "/show/1--------{pg}---/", - "movie": "/show/2--------{pg}---/", - "variety": "/show/3--------{pg}---/", - "anime": "/show/4--------{pg}---/", - "short": "/show/55--------{pg}---/", - "doc": "/show/5--------{pg}---/", - } - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.categories} - - def _build_url(self, href): - raw = str(href or "").strip() - if not raw: - return "" - if raw.startswith(("http://", "https://")): - return raw - if raw.startswith("//"): - return "https:" + raw - return self.host + "/" + raw.lstrip("/") - - def _extract_vod_id(self, href): - raw = str(href or "").strip() - matched = re.search(r"/v/(\d+)\.html", raw) - if matched: - return matched.group(1) - if re.fullmatch(r"\d+", raw): - return raw - return "" - - def _parse_list_cards(self, html): - root = self.html(html) - if root is None: - return [] - results = [] - seen = set() - for card in root.xpath("//*[contains(@class,'module-poster-item') or contains(@class,'module-card-item-poster')]"): - href = ((card.xpath("./@href") or [""])[0]).strip() - vod_id = self._extract_vod_id(href) - title = ((card.xpath("./@title") or [""])[0]).strip() or ((card.xpath(".//@alt") or [""])[0]).strip() - pic = ( - (card.xpath("./@data-original") or [""])[0].strip() - or (card.xpath("./@data-src") or [""])[0].strip() - or (card.xpath(".//@src") or [""])[0].strip() - ) - remarks = "".join(card.xpath(".//*[contains(@class,'module-item-note') or contains(@class,'module-item-text')][1]//text()")).strip() - if not vod_id or vod_id in seen or not title: - continue - seen.add(vod_id) - results.append({ - "vod_id": vod_id, - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_remarks": remarks, - }) - return results - - def _request_html(self, path_or_url, expect_xpath=None, referer=None): - target = path_or_url if str(path_or_url).startswith("http") else self._build_url(path_or_url) - headers = dict(self.headers) - headers["Referer"] = referer or (self.host + "/") - response = self.fetch(target, headers=headers, timeout=10) - if response.status_code != 200: - return "" - return response.text or "" - - def _page_result(self, items, pg): - page = int(pg) - pagecount = page + 1 if items else page - return {"list": items, "page": page, "pagecount": pagecount, "limit": len(items), "total": pagecount * max(len(items), 1)} - - def homeVideoContent(self): - html = self._request_html("/label/new/", expect_xpath="//*[contains(@class,'module-poster-item')]") - return {"list": self._parse_list_cards(html)} - - def categoryContent(self, tid, pg, filter, extend): - path = self.category_paths.get(tid, self.category_paths["movie"]).format(pg=pg) - html = self._request_html(path, expect_xpath="//*[contains(@class,'module-poster-item') or contains(@class,'module-card-item-poster')]") - return self._page_result(self._parse_list_cards(html), pg) - - def searchContent(self, key, quick, pg="1"): - path = "/search/-------------.html?wd={0}".format(quote(key)) - html = self._request_html(path, expect_xpath="//*[contains(@class,'module-poster-item') or contains(@class,'module-card-item-poster')]") - return self._page_result(self._parse_list_cards(html), pg) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_youknow.TestYouKnowSpider -v` -Expected: PASS for the five new tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_youknow.py youknow.py -git commit -m "feat: scaffold youknow list parsing" -``` - -### Task 2: Add Detail Parsing And Multi-Source Episode Payloads - -**Files:** -- Modify: `tests/test_youknow.py` -- Modify: `youknow.py` - -- [ ] **Step 1: Write the failing test** - -```python -class TestYouKnowSpider(unittest.TestCase): - def test_parse_detail_page_extracts_fields_and_aligns_episode_sources(self): - html = """ -
- -
-
-

示例剧

-
-
-
类型:剧情
-
地区:大陆
-
年份:2026
-
导演:张三
-
主演:李四王五
-
语言:国语
-
备注:更新至2集
-
简介:

一段剧情简介

-
-
-
-
- 第1集 - 第2集 -
-
- 第1集 - 第2集 -
- """ - result = self.spider._parse_detail_page(html, "888") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "888") - self.assertEqual(vod["path"], "https://www.youknow.tv/v/888.html") - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["type_name"], "剧情") - self.assertEqual(vod["vod_area"], "大陆") - self.assertEqual(vod["vod_year"], "2026") - self.assertEqual(vod["vod_lang"], "国语") - self.assertEqual(vod["vod_remarks"], "更新至2集") - self.assertEqual(vod["vod_director"], "张三") - self.assertEqual(vod["vod_actor"], "李四,王五") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_play_from"], "YouKnowTV") - first_episode = vod["vod_play_url"].split("#")[0] - self.assertTrue(first_episode.startswith("第1集$")) - payload = self.spider._decode_episode_payload(first_episode.split("$", 1)[1]) - self.assertEqual(payload["vod_id"], "888") - self.assertEqual(payload["episode_index"], 1) - self.assertEqual(len(payload["candidates"]), 2) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_detail_request_url_from_vod_id(self, mock_request_html): - mock_request_html.return_value = '

详情影片

第1集
' - result = self.spider.detailContent(["123"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.youknow.tv/v/123.html") - self.assertEqual(result["list"][0]["vod_id"], "123") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_youknow.TestYouKnowSpider.test_parse_detail_page_extracts_fields_and_aligns_episode_sources tests.test_youknow.TestYouKnowSpider.test_detail_content_builds_detail_request_url_from_vod_id -v` -Expected: FAIL with missing detail helpers or incorrect payload structure. - -- [ ] **Step 3: Write minimal implementation** - -```python -import base64 -import json - - def _extract_play_meta(self, href): - raw = str(href or "").strip() - matched = re.search(r"/p/(\d+)-(\d+)-(\d+)/?$", raw) - if not matched: - return None - return { - "vod_id": matched.group(1), - "source_id": matched.group(2), - "episode_index": int(matched.group(3)), - } - - def _build_detail_request_url(self, vod_id): - return f"{self.host}/v/{self._extract_vod_id(vod_id)}.html" - - def _encode_episode_payload(self, payload): - raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") - return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=") - - def _decode_episode_payload(self, value): - text = str(value or "") - padded = text + "=" * (-len(text) % 4) - return json.loads(base64.urlsafe_b64decode(padded.encode("utf-8")).decode("utf-8")) - - def _clean_text(self, text): - return re.sub(r"\s+", " ", str(text or "").replace("\xa0", " ")).strip() - - def _extract_detail_field(self, root, label, joiner=""): - nodes = root.xpath(f'.//*[contains(normalize-space(.), "{label}:")][1]') - if not nodes: - return "" - node = nodes[0] - texts = [self._clean_text(text) for text in node.xpath(".//text()")] - value = "".join([text for text in texts if text and text != label + ":"]) - if joiner and node.xpath(".//a"): - return joiner.join([self._clean_text(text) for text in node.xpath(".//a//text()") if self._clean_text(text)]) - return value - - def _parse_detail_page(self, html, vod_id): - root = self.html(html) - title = ((root.xpath("//*[contains(@class,'module-info-heading')]//h1[1]//text()") or [""])[0]).strip() - pic = ( - (root.xpath("//*[contains(@class,'module-info-poster')]//img/@data-original") or [""])[0].strip() - or (root.xpath("//*[contains(@class,'module-info-poster')]//img/@src") or [""])[0].strip() - ) - source_names = [self._clean_text(value) for value in root.xpath("//*[@data-dropdown-value]/@data-dropdown-value")] - play_groups = root.xpath("//*[contains(@class,'module-play-list')]") - aligned = {} - for group_index, group in enumerate(play_groups): - source_name = source_names[group_index] if group_index < len(source_names) else f"线路{group_index + 1}" - for anchor in group.xpath(".//a[@href]"): - href = (anchor.xpath("./@href") or [""])[0] - meta = self._extract_play_meta(href) - if not meta: - continue - key = meta["episode_index"] - aligned.setdefault(key, {"vod_id": meta["vod_id"], "episode_index": key, "title": self._clean_text("".join(anchor.xpath(".//text()"))), "candidates": []}) - aligned[key]["candidates"].append({"source": source_name, "source_id": meta["source_id"], "episode_url": self._build_url(href)}) - play_urls = [] - for episode_index in sorted(aligned): - record = aligned[episode_index] - payload = self._encode_episode_payload(record) - title_text = record["title"] or f"第{episode_index}集" - play_urls.append(f"{title_text}${payload}") - vod = { - "vod_id": vod_id, - "path": self._build_detail_request_url(vod_id), - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_tag": "", - "vod_time": "", - "vod_remarks": self._extract_detail_field(root, "备注"), - "vod_play_from": "YouKnowTV", - "vod_play_url": "#".join(play_urls), - "type_name": self._extract_detail_field(root, "类型"), - "vod_content": self._extract_detail_field(root, "简介"), - "vod_year": self._extract_detail_field(root, "年份"), - "vod_area": self._extract_detail_field(root, "地区"), - "vod_lang": self._extract_detail_field(root, "语言"), - "vod_director": self._extract_detail_field(root, "导演", joiner=","), - "vod_actor": self._extract_detail_field(root, "主演", joiner=","), - } - return {"list": [vod]} - - def detailContent(self, ids): - vod_id = ids[0] - html = self._request_html(self._build_detail_request_url(vod_id), expect_xpath="//*[contains(@class,'module-play-list')]") - return self._parse_detail_page(html, vod_id) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_youknow.TestYouKnowSpider.test_parse_detail_page_extracts_fields_and_aligns_episode_sources tests.test_youknow.TestYouKnowSpider.test_detail_content_builds_detail_request_url_from_vod_id -v` -Expected: PASS for the two new tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_youknow.py youknow.py -git commit -m "feat: add youknow detail parsing" -``` - -### Task 3: Add Player Candidate Parsing And Direct URL Resolution - -**Files:** -- Modify: `tests/test_youknow.py` -- Modify: `youknow.py` - -- [ ] **Step 1: Write the failing test** - -```python -class TestYouKnowSpider(unittest.TestCase): - def test_extract_player_config_reads_player_aaaa(self): - html = '' - data = self.spider._parse_player_config(html) - self.assertEqual(data["encrypt"], "1") - - def test_decode_player_url_supports_encrypt_1_and_2(self): - self.assertEqual( - self.spider._decode_player_url("https%3A%2F%2Fvideo.example%2Fa.m3u8", "1"), - "https://video.example/a.m3u8", - ) - encoded = "aHR0cHMlM0ElMkYlMkZ2aWRlby5leGFtcGxlJTJGYi5tM3U4" - self.assertEqual( - self.spider._decode_player_url(encoded, "2"), - "https://video.example/b.m3u8", - ) - - @patch.object(Spider, "_request_html") - def test_player_content_collects_candidates_from_page_and_iframe(self, mock_request_html): - payload = self.spider._encode_episode_payload( - { - "vod_id": "888", - "episode_index": 1, - "title": "第1集", - "candidates": [{"source": "线路A", "source_id": "1", "episode_url": "https://www.youknow.tv/p/888-1-1/"}], - } - ) - mock_request_html.side_effect = [ - '', - 'https://video.example/iframe-final.m3u8', - ] - result = self.spider.playerContent("YouKnowTV", payload, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/page.m3u8") - self.assertEqual(result["header"]["Referer"], "https://www.youknow.tv/") - - @patch.object(Spider, "_request_html") - def test_player_content_tries_next_candidate_when_first_fails(self, mock_request_html): - payload = self.spider._encode_episode_payload( - { - "vod_id": "888", - "episode_index": 1, - "title": "第1集", - "candidates": [ - {"source": "线路A", "source_id": "1", "episode_url": "https://www.youknow.tv/p/888-1-1/"}, - {"source": "线路B", "source_id": "2", "episode_url": "https://www.youknow.tv/p/888-2-1/"}, - ], - } - ) - mock_request_html.side_effect = [ - 'empty', - 'https://video.example/backup.m3u8', - ] - result = self.spider.playerContent("YouKnowTV", payload, {}) - self.assertEqual(result["url"], "https://video.example/backup.m3u8") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_youknow.TestYouKnowSpider.test_extract_player_config_reads_player_aaaa tests.test_youknow.TestYouKnowSpider.test_decode_player_url_supports_encrypt_1_and_2 tests.test_youknow.TestYouKnowSpider.test_player_content_collects_candidates_from_page_and_iframe tests.test_youknow.TestYouKnowSpider.test_player_content_tries_next_candidate_when_first_fails -v` -Expected: FAIL with missing player helpers or unresolved final URL. - -- [ ] **Step 3: Write minimal implementation** - -```python -from urllib.parse import unquote - - def _safe_unquote(self, value): - try: - return unquote(str(value or "")) - except Exception: - return str(value or "") - - def _base64_decode(self, value): - text = str(value or "").replace("-", "+").replace("_", "/") - text += "=" * (-len(text) % 4) - try: - return base64.b64decode(text.encode("utf-8")).decode("utf-8", errors="ignore") - except Exception: - return "" - - def _normalize_play_url(self, value): - text = str(value or "").strip().replace("\\/", "/").replace("\\u0026", "&") - if text.startswith("//"): - return "https:" + text - if text.startswith("/"): - return self.host + text - return text - - def _parse_player_config(self, html): - matched = re.search(r"player_aaaa\s*=\s*(\{[\s\S]*?\})\s*;?", str(html or ""), re.I) - if not matched: - return None - try: - return json.loads(matched.group(1)) - except Exception: - return None - - def _collect_direct_media_urls(self, html): - text = str(html or "").replace("\\/", "/") - urls = re.findall(r'https?:\/\/[^"\'\s]+?\.(?:m3u8|mp4|flv)(?:\?[^"\'\s]*)?', text, re.I) - proto_less = ["https:" + item for item in re.findall(r'\/\/[^"\'\s]+?\.(?:m3u8|mp4|flv)(?:\?[^"\'\s]*)?', text, re.I)] - out = [] - for value in urls + proto_less: - normalized = self._normalize_play_url(value) - if normalized and normalized not in out: - out.append(normalized) - return out - - def _decode_player_url(self, raw_url, encrypt): - mode = str(encrypt or "0") - if mode == "1": - return self._normalize_play_url(self._safe_unquote(raw_url)) - if mode == "2": - seeds = [str(raw_url or ""), self._safe_unquote(raw_url), self._safe_unquote(self._safe_unquote(raw_url))] - candidates = [] - for seed in seeds: - for value in [seed, self._base64_decode(seed)]: - if not value: - continue - candidates.append(value) - candidates.append(self._safe_unquote(value)) - candidates.append(self._safe_unquote(self._safe_unquote(value))) - for value in candidates: - normalized = self._normalize_play_url(value) - if any(ext in normalized.lower() for ext in (".m3u8", ".mp4", ".flv")): - return normalized - return self._normalize_play_url(raw_url) - - def _collect_playable_urls_from_html(self, html): - urls = [] - config = self._parse_player_config(html) - if config and config.get("url"): - decoded = self._decode_player_url(config.get("url", ""), config.get("encrypt", "0")) - if decoded: - urls.append(decoded) - for value in self._collect_direct_media_urls(html): - if value not in urls: - urls.append(value) - iframe = ((self.html(html).xpath("//*[contains(@class,'embed-responsive-item')][1]/@src")) or [""])[0] - return urls, self._build_url(iframe) if iframe else "" - - def playerContent(self, flag, id, vipFlags): - payload = self._decode_episode_payload(id) - candidates = payload.get("candidates", []) if isinstance(payload, dict) else [] - for candidate in candidates: - episode_url = str(candidate.get("episode_url", "")).strip() - if not episode_url: - continue - page_html = self._request_html(episode_url, referer=self.host + "/") - page_urls, iframe_url = self._collect_playable_urls_from_html(page_html) - if iframe_url: - iframe_html = self._request_html(iframe_url, referer=episode_url) - iframe_urls, _ = self._collect_playable_urls_from_html(iframe_html) - for value in iframe_urls: - if value not in page_urls: - page_urls.append(value) - if page_urls: - return { - "parse": 0, - "playUrl": "", - "url": page_urls[0], - "header": {"User-Agent": self.headers["User-Agent"], "Referer": self.host + "/"}, - } - return {"parse": 0, "playUrl": "", "url": ""} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_youknow.TestYouKnowSpider.test_extract_player_config_reads_player_aaaa tests.test_youknow.TestYouKnowSpider.test_decode_player_url_supports_encrypt_1_and_2 tests.test_youknow.TestYouKnowSpider.test_player_content_collects_candidates_from_page_and_iframe tests.test_youknow.TestYouKnowSpider.test_player_content_tries_next_candidate_when_first_fails -v` -Expected: PASS for the four new tests. - -- [ ] **Step 5: Run the full suite** - -Run: `python -m unittest tests.test_youknow -v` -Expected: PASS with all `youknow` tests green. - -- [ ] **Step 6: Commit** - -```bash -git add tests/test_youknow.py youknow.py -git commit -m "feat: add youknow player parsing" -``` diff --git a/py/docs/superpowers/plans/2026-04-19-juquanquan-spider.md b/py/docs/superpowers/plans/2026-04-19-juquanquan-spider.md deleted file mode 100644 index 8be3e0a..0000000 --- a/py/docs/superpowers/plans/2026-04-19-juquanquan-spider.md +++ /dev/null @@ -1,671 +0,0 @@ -# 剧圈圈 Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 新增 `剧圈圈.py` 与 `tests/test_剧圈圈.py`,实现首页、分类、详情、搜索和播放解析,并统一使用压缩后的站内 id。 - -**Architecture:** 沿用当前仓库“单站点单文件 + 单测”的 Spider 结构,在站点文件内部封装文本清洗、短 id 编解码、列表/详情解析和播放解码 helper。播放逻辑优先从播放页直接解出媒体直链,失败时再请求站内解析接口,并在异常场景回退到播放页。 - -**Tech Stack:** Python 3、`base.spider.Spider`、`self.fetch`、lxml/XPath、regex、`json`、`hashlib`、`base64`、`urllib.parse` - ---- - -## File Structure - -- Create: `剧圈圈.py` - - 新 Spider 实现,包含站点配置、id 编解码、列表/详情/搜索/播放解析 -- Create: `tests/test_剧圈圈.py` - - 覆盖首页、分类、搜索、详情、多线路播放与回退逻辑 -- Reference: `剧迷.py` - - 参考同仓库现有 Spider 的接口组织方式 -- Reference: `tests/test_剧迷.py` - - 参考同仓库 unittest 风格与 mock 方式 - -### Task 1: Scaffold Spider And Core Helpers - -**Files:** -- Create: `剧圈圈.py` -- Test: `tests/test_剧圈圈.py` - -- [ ] **Step 1: Write the failing tests for name, categories, id helpers, and search-id compression** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("juquanquan_spider", str(ROOT / "剧圈圈.py")).load_module() -Spider = MODULE.Spider - - -class TestJuQuanQuanSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["dianying", "juji", "dongman", "zongyi", "duanju"], - ) - - def test_encode_and_decode_detail_and_play_ids(self): - self.assertEqual(self.spider._encode_vod_id("/vod/123.html"), "vod/123") - self.assertEqual(self.spider._decode_vod_id("vod/123"), "https://www.jqqzx.cc/vod/123.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://www.jqqzx.cc/play/123-1-2.html") - - def test_parse_search_list_maps_items_to_compact_vod_ids(self): - payload = '{"list":[{"id":"888","name":"搜索影片","pic":"https://img.example/888.jpg"}]}' - self.assertEqual( - self.spider._parse_search_list(payload), - [ - { - "vod_id": "vod/888", - "vod_name": "搜索影片", - "vod_pic": "https://img.example/888.jpg", - "vod_remarks": "", - } - ], - ) -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `python -m unittest tests.test_剧圈圈.TestJuQuanQuanSpider.test_home_content_exposes_expected_categories tests.test_剧圈圈.TestJuQuanQuanSpider.test_encode_and_decode_detail_and_play_ids tests.test_剧圈圈.TestJuQuanQuanSpider.test_parse_search_list_maps_items_to_compact_vod_ids -v` - -Expected: `FAIL` or `ERROR` because `剧圈圈.py` and helper methods do not exist yet. - -- [ ] **Step 3: Write the minimal Spider skeleton and helper implementation** - -```python -# coding=utf-8 -import json -import re -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://www.jqqzx.cc" - self.headers = { - "User-Agent": "Mozilla/5.0", - "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", - "Referer": self.host + "/", - } - self.categories = [ - {"type_id": "dianying", "type_name": "电影"}, - {"type_id": "juji", "type_name": "剧集"}, - {"type_id": "dongman", "type_name": "动漫"}, - {"type_id": "zongyi", "type_name": "综艺"}, - {"type_id": "duanju", "type_name": "短剧"}, - ] - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.categories} - - def _build_url(self, path): - return urljoin(self.host + "/", str(path or "").strip()) - - def _encode_vod_id(self, href): - matched = re.search(r"/vod/([^/?#]+)\.html", self._build_url(href)) - return f"vod/{matched.group(1)}" if matched else "" - - def _decode_vod_id(self, vod_id): - matched = re.search(r"^vod/([^/?#]+)$", str(vod_id or "").strip()) - return self._build_url(f"/vod/{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+", " ", re.sub(r"<[^>]+>", " ", str(text or ""))).strip() - - def _parse_search_list(self, payload): - try: - data = json.loads(str(payload or "{}")) - except Exception: - return [] - items = [] - for item in data.get("list", []): - vod_id = f"vod/{item.get('id')}".rstrip("/") - if vod_id == "vod/" or not self._clean_text(item.get("name")): - continue - items.append( - { - "vod_id": vod_id, - "vod_name": self._clean_text(item.get("name")), - "vod_pic": self._build_url(item.get("pic")), - "vod_remarks": "", - } - ) - return items -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `python -m unittest tests.test_剧圈圈.TestJuQuanQuanSpider.test_home_content_exposes_expected_categories tests.test_剧圈圈.TestJuQuanQuanSpider.test_encode_and_decode_detail_and_play_ids tests.test_剧圈圈.TestJuQuanQuanSpider.test_parse_search_list_maps_items_to_compact_vod_ids -v` - -Expected: `OK` - -- [ ] **Step 5: Commit** - -```bash -git add 剧圈圈.py tests/test_剧圈圈.py -git commit -m "feat: scaffold juquanquan spider" -``` - -### Task 2: Implement Home, Category, Search, And Shared Card Parsing - -**Files:** -- Modify: `剧圈圈.py` -- Modify: `tests/test_剧圈圈.py` - -- [ ] **Step 1: Write the failing tests for homepage recommendations, category paging, and search flow** - -```python -from unittest.mock import patch - - def test_parse_cards_extracts_compact_vod_ids(self): - html = """ - - -
示例影片
-
更新至1集
-
- """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "vod/123", - "vod_name": "示例影片", - "vod_pic": "https://www.jqqzx.cc/cover.jpg", - "vod_remarks": "更新至1集", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_limits_recommendations(self, mock_request_html): - mock_request_html.return_value = "".join( - f'
影片{i}
' - for i in range(1, 45) - ) - result = self.spider.homeVideoContent() - self.assertEqual(len(result["list"]), 40) - self.assertEqual(result["list"][0]["vod_id"], "vod/1") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_page_result(self, mock_request_html): - mock_request_html.return_value = """ - -
分类影片
-
- """ - result = self.spider.categoryContent("juji", "2", False, {}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.jqqzx.cc/type/juji/page/2.html") - self.assertEqual(result["page"], 2) - self.assertEqual(result["pagecount"], 3) - self.assertEqual(result["list"][0]["vod_id"], "vod/456") - - @patch.object(Spider, "_request_html") - def test_search_content_uses_suggest_api(self, mock_request_html): - mock_request_html.return_value = '{"list":[{"id":"777","name":"搜索结果","pic":"/pic.jpg"}]}' - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.jqqzx.cc/index.php/ajax/suggest?mid=1&wd=%E7%B9%81%E8%8A%B1", - ) - self.assertEqual(result["list"][0]["vod_id"], "vod/777") - self.assertEqual(result["pagecount"], 1) -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `python -m unittest tests.test_剧圈圈.TestJuQuanQuanSpider.test_parse_cards_extracts_compact_vod_ids tests.test_剧圈圈.TestJuQuanQuanSpider.test_home_video_content_limits_recommendations tests.test_剧圈圈.TestJuQuanQuanSpider.test_category_content_builds_page_result tests.test_剧圈圈.TestJuQuanQuanSpider.test_search_content_uses_suggest_api -v` - -Expected: `FAIL` or `ERROR` because `_parse_cards` and high-level methods are incomplete. - -- [ ] **Step 3: Implement shared request and list parsing methods** - -```python - def _request_html(self, path_or_url, headers=None): - target = path_or_url if str(path_or_url).startswith("http") else self._build_url(path_or_url) - merged = dict(self.headers) - if headers: - merged.update(headers) - response = self.fetch(target, headers=merged, timeout=10) - if response.status_code != 200: - return "" - return response.text or "" - - def _parse_cards(self, html): - root = self.html(html) - if root is None: - return [] - items = [] - seen = set() - for anchor in root.xpath("//a[contains(@class,'module-poster-item') and contains(@class,'module-item')]"): - vod_id = self._encode_vod_id((anchor.xpath("./@href") or [""])[0]) - if not vod_id or vod_id in seen: - continue - seen.add(vod_id) - title = self._clean_text("".join(anchor.xpath(".//*[contains(@class,'module-poster-item-title')][1]//text()"))) or self._clean_text((anchor.xpath("./@title") or [""])[0]) - if not title: - continue - pic = (anchor.xpath(".//img[1]/@data-original") or anchor.xpath(".//img[1]/@src") or [""])[0] - note = self._clean_text("".join(anchor.xpath(".//*[contains(@class,'module-item-note')][1]//text()"))) - items.append({"vod_id": vod_id, "vod_name": title, "vod_pic": self._build_url(pic), "vod_remarks": note}) - return items - - def homeVideoContent(self): - return {"list": self._parse_cards(self._request_html(self.host))[:40]} - - def categoryContent(self, tid, pg, filter, extend): - page = int(pg) - items = self._parse_cards(self._request_html(self._build_url(f"/type/{tid}/page/{page}.html"))) - return {"page": page, "pagecount": page + 1 if items else page, "total": page * len(items) + (1 if items else 0), "list": items} - - def searchContent(self, key, quick, pg="1"): - page = int(pg) - keyword = self._clean_text(key) - if not keyword: - return {"page": page, "pagecount": 1, "total": 0, "list": []} - url = self._build_url(f"/index.php/ajax/suggest?mid=1&wd={quote(keyword)}") - items = self._parse_search_list(self._request_html(url)) - return {"page": page, "pagecount": 1, "total": len(items), "list": items} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `python -m unittest tests.test_剧圈圈.TestJuQuanQuanSpider.test_parse_cards_extracts_compact_vod_ids tests.test_剧圈圈.TestJuQuanQuanSpider.test_home_video_content_limits_recommendations tests.test_剧圈圈.TestJuQuanQuanSpider.test_category_content_builds_page_result tests.test_剧圈圈.TestJuQuanQuanSpider.test_search_content_uses_suggest_api -v` - -Expected: `OK` - -- [ ] **Step 5: Commit** - -```bash -git add 剧圈圈.py tests/test_剧圈圈.py -git commit -m "feat: add juquanquan list and search flows" -``` - -### Task 3: Implement Detail Parsing With Multi-Line Playlists - -**Files:** -- Modify: `剧圈圈.py` -- Modify: `tests/test_剧圈圈.py` - -- [ ] **Step 1: Write the failing tests for detail metadata and compact play ids** - -```python - def test_parse_detail_page_extracts_metadata_and_playlists(self): - html = """ -

详情标题

-
-
-
导演
- -
-
-
主演
- -
-
-
备注
-
更新至3集
-
-
一段剧情简介
- -
-
-
-
-
- 第1集 - 第2集 -
-
- 正片 -
- """ - result = self.spider._parse_detail_page(html, "vod/123") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "vod/123") - self.assertEqual(vod["vod_name"], "详情标题") - self.assertEqual(vod["vod_pic"], "https://www.jqqzx.cc/poster.jpg") - self.assertEqual(vod["type_name"], "古装 / 剧情") - self.assertEqual(vod["vod_director"], "导演甲") - self.assertEqual(vod["vod_actor"], "演员甲 / 演员乙") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_play_from"], "线路A$$$线路B") - 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_decodes_compact_vod_id(self, mock_request_html): - mock_request_html.return_value = '

详情标题

' - self.spider.detailContent(["vod/321"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.jqqzx.cc/vod/321.html") -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `python -m unittest tests.test_剧圈圈.TestJuQuanQuanSpider.test_parse_detail_page_extracts_metadata_and_playlists tests.test_剧圈圈.TestJuQuanQuanSpider.test_detail_content_decodes_compact_vod_id -v` - -Expected: `FAIL` or `ERROR` because detail parsing is not implemented. - -- [ ] **Step 3: Implement detail metadata parsing and playlist extraction** - -```python - def _parse_info_items(self, root): - info = {} - for item in root.xpath("//*[contains(@class,'module-info-item')]"): - title = self._clean_text("".join(item.xpath(".//*[contains(@class,'module-info-item-title')][1]//text()"))).rstrip("::") - if not title: - continue - values = [self._clean_text("".join(node.xpath(".//text()"))) for node in item.xpath(".//*[contains(@class,'module-info-item-content')][1]//a")] - if values: - info[title] = " / ".join([value for value in values if value]) - else: - info[title] = self._clean_text("".join(item.xpath(".//*[contains(@class,'module-info-item-content')][1]//text()"))) - return info - - def _parse_detail_page(self, html, vod_id): - root = self.html(html) - if root is None: - return {"list": []} - info = self._parse_info_items(root) - tab_names = [self._clean_text((node.xpath("./@data-dropdown-value") or [""])[0] or "".join(node.xpath(".//span[1]//text()"))) for node in root.xpath("//*[@id='y-playList']//*[contains(@class,'module-tab-item')]")] - groups = [] - for index, box in enumerate(root.xpath("//*[contains(@class,'his-tab-list')]")): - episodes = [] - for anchor in box.xpath(".//a[contains(@class,'module-play-list-link') and @href]"): - play_id = self._encode_play_id((anchor.xpath("./@href") or [""])[0]) - title = self._clean_text("".join(anchor.xpath(".//span[1]//text()")) or "".join(anchor.xpath(".//text()"))) - if play_id and title: - episodes.append(f"{title}${play_id}") - if episodes: - groups.append({"from": tab_names[index] if index < len(tab_names) and tab_names[index] else f"线路{index + 1}", "urls": "#".join(episodes)}) - type_name = " / ".join([self._clean_text("".join(node.xpath(".//text()"))) for node in root.xpath("//*[contains(@class,'module-info-tag-link')]//a") if self._clean_text("".join(node.xpath(".//text()")))]) - return { - "list": [ - { - "vod_id": vod_id, - "vod_name": self._clean_text("".join(root.xpath("//*[contains(@class,'module-info-heading')]//h1[1]//text()"))), - "vod_pic": self._build_url((root.xpath("(//*[contains(@class,'module-item-pic')]//img/@data-original | //*[contains(@class,'module-info-poster')]//img/@data-original | //*[contains(@class,'module-item-pic')]//img/@src | //*[contains(@class,'module-info-poster')]//img/@src)[1]") or [""])[0]), - "type_name": type_name, - "vod_remarks": info.get("备注") or info.get("状态", ""), - "vod_actor": info.get("主演", ""), - "vod_director": info.get("导演", ""), - "vod_content": self._clean_text("".join(root.xpath("//*[contains(@class,'module-info-introduction-content')][1]//text()"))), - "vod_play_from": "$$$".join([group["from"] for group in groups]), - "vod_play_url": "$$$".join([group["urls"] for group in groups]), - } - ] - } - - def detailContent(self, ids): - result = {"list": []} - for raw in ids: - vod_id = str(raw or "").strip() - url = self._decode_vod_id(vod_id) - if not url: - continue - parsed = self._parse_detail_page(self._request_html(url), vod_id) - result["list"].extend(parsed.get("list", [])) - return result -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `python -m unittest tests.test_剧圈圈.TestJuQuanQuanSpider.test_parse_detail_page_extracts_metadata_and_playlists tests.test_剧圈圈.TestJuQuanQuanSpider.test_detail_content_decodes_compact_vod_id -v` - -Expected: `OK` - -- [ ] **Step 5: Commit** - -```bash -git add 剧圈圈.py tests/test_剧圈圈.py -git commit -m "feat: add juquanquan detail parsing" -``` - -### Task 4: Implement Player Decoding, Direct Media Priority, And Parse Fallback - -**Files:** -- Modify: `剧圈圈.py` -- Modify: `tests/test_剧圈圈.py` - -- [ ] **Step 1: Write the failing tests for direct URL playback, parse API fallback, and missing-player fallback** - -```python - def test_extract_player_data_reads_player_aaaa(self): - html = '' - self.assertEqual(self.spider._extract_player_data(html)["url"], "https://video.example/direct.m3u8") - - @patch.object(Spider, "_request_with_headers") - def test_player_content_returns_direct_media_url(self, mock_request_with_headers): - mock_request_with_headers.return_value = { - "body": '', - "headers": {}, - "status_code": 200, - } - result = self.spider.playerContent("线路A", "play/123-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/direct.m3u8") - - @patch.object(Spider, "_request_with_headers") - def test_player_content_uses_parse_api_when_player_vid_is_not_direct(self, mock_request_with_headers): - mock_request_with_headers.side_effect = [ - { - "body": '', - "headers": {"set-cookie": ["foo=bar; Path=/"]}, - "status_code": 200, - }, - {"body": "", "headers": {"set-cookie": ["token=abc; Path=/"]}, "status_code": 200}, - {"body": '{"code":200,"data":{"url":"error://apiRes_dummy"}}', "headers": {}, "status_code": 200}, - ] - self.spider._decode_url = lambda value: "https://video.example/fallback.m3u8" - result = self.spider.playerContent("线路A", "play/123-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/fallback.m3u8") - - @patch.object(Spider, "_request_with_headers") - def test_player_content_falls_back_to_play_page_when_player_data_missing(self, mock_request_with_headers): - mock_request_with_headers.return_value = {"body": "", "headers": {}, "status_code": 200} - result = self.spider.playerContent("线路A", "play/123-1-1", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://www.jqqzx.cc/play/123-1-1.html") -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `python -m unittest tests.test_剧圈圈.TestJuQuanQuanSpider.test_extract_player_data_reads_player_aaaa tests.test_剧圈圈.TestJuQuanQuanSpider.test_player_content_returns_direct_media_url tests.test_剧圈圈.TestJuQuanQuanSpider.test_player_content_uses_parse_api_when_player_vid_is_not_direct tests.test_剧圈圈.TestJuQuanQuanSpider.test_player_content_falls_back_to_play_page_when_player_data_missing -v` - -Expected: `FAIL` or `ERROR` because playback helpers are missing. - -- [ ] **Step 3: Implement request-with-headers, player extraction, decode helpers, and playerContent** - -```python -import base64 -import hashlib -import json -from urllib.parse import quote, unquote - - def _request_with_headers(self, path_or_url, headers=None, data=None): - target = path_or_url if str(path_or_url).startswith("http") else self._build_url(path_or_url) - merged = dict(self.headers) - if headers: - merged.update(headers) - response = self.fetch(target, headers=merged, data=data, timeout=10) - return {"body": response.text or "", "headers": getattr(response, "headers", {}) or {}, "status_code": response.status_code} - - def _get_set_cookies(self, headers): - raw = headers.get("set-cookie") or headers.get("Set-Cookie") or [] - items = raw if isinstance(raw, list) else [raw] - return [str(item).split(";", 1)[0] for item in items if str(item).strip()] - - def _merge_cookies(self, *groups): - values = {} - for group in groups: - for item in (group if isinstance(group, list) else [group]): - text = str(item or "").strip() - if "=" not in text: - continue - key, val = text.split("=", 1) - values[key] = val - return [f"{key}={val}" for key, val in values.items()] - - def _base64_decode(self, value): - text = re.sub(r"\s+", "", str(value or "")) - if not text: - return "" - text += "=" * ((4 - len(text) % 4) % 4) - try: - return base64.b64decode(text).decode("utf-8") - except Exception: - return "" - - def _decode_url(self, value): - raw = str(value or "").replace("error://apiRes_", "").strip() - if not raw: - return "" - key = hashlib.md5("test".encode("utf-8")).hexdigest() - first = self._base64_decode(raw) - mixed = "".join(chr(ord(ch) ^ ord(key[index % len(key)])) for index, ch in enumerate(first)) - decoded = self._base64_decode(mixed) - parts = decoded.split("/") - if len(parts) < 3: - return "" - from_map = json.loads(self._base64_decode(parts[1])) - to_map = json.loads(self._base64_decode(parts[0])) - body = self._base64_decode("/".join(parts[2:])) - mapped = re.sub(r"[a-zA-Z]", lambda match: to_map[from_map.index(match.group(0))] if match.group(0) in from_map else match.group(0), body) - matched = re.search(r"https?://[^\s'\"<>]+", mapped) - return matched.group(0) if matched else mapped.strip() - - def _extract_player_data(self, html): - matched = re.search(r"player_aaaa\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*;?\\s*", str(html or ""), re.I) - if not matched: - return None - try: - return json.loads(matched.group(1)) - except Exception: - return None - - def _is_media_url(self, value): - return bool(re.search(r"^https?://.*\\.(m3u8|mp4|flv|m4s)(\\?.*)?$", str(value or ""), re.I)) - - def playerContent(self, flag, id, vipFlags): - play_url = self._decode_play_id(id) - if not play_url: - return {"parse": 1, "jx": 1, "url": "", "header": dict(self.headers)} - try: - play_res = self._request_with_headers(play_url, headers={"Referer": self.host + "/"}) - player = self._extract_player_data(play_res["body"]) - if not player: - return {"parse": 1, "jx": 1, "url": play_url, "header": dict(self.headers)} - vid = unquote(str(player.get("url") or "")).strip() - if vid and not self._is_media_url(vid): - decoded = self._decode_url(vid) - if decoded.startswith("http"): - vid = decoded - if self._is_media_url(vid): - return {"parse": 0, "jx": 0, "url": vid, "header": {**self.headers, "Referer": play_url}} - cookies = self._get_set_cookies(play_res["headers"]) - player_page = self._build_url(f"/jx/player.php?vid={quote(vid)}") - player_res = self._request_with_headers(player_page, headers={"Referer": play_url, **({"Cookie": "; ".join(cookies)} if cookies else {})}) - cookies = self._merge_cookies(cookies, self._get_set_cookies(player_res["headers"])) - api_res = self._request_with_headers( - self._build_url("/jx/api.php"), - headers={ - "Referer": player_page, - "Origin": self.host, - "X-Requested-With": "XMLHttpRequest", - **({"Cookie": "; ".join(cookies)} if cookies else {}), - }, - data=f"vid={quote(vid)}", - ) - payload = json.loads(api_res["body"] or "{}") - real_url = self._decode_url(payload.get("data", {}).get("url")) - if real_url.startswith("http"): - return {"parse": 0, "jx": 0, "url": real_url, "header": {**self.headers, "Referer": player_page}} - return {"parse": 1, "jx": 1, "url": play_url, "header": {**self.headers, "Referer": player_page}} - except Exception: - return {"parse": 1, "jx": 1, "url": play_url, "header": dict(self.headers)} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `python -m unittest tests.test_剧圈圈.TestJuQuanQuanSpider.test_extract_player_data_reads_player_aaaa tests.test_剧圈圈.TestJuQuanQuanSpider.test_player_content_returns_direct_media_url tests.test_剧圈圈.TestJuQuanQuanSpider.test_player_content_uses_parse_api_when_player_vid_is_not_direct tests.test_剧圈圈.TestJuQuanQuanSpider.test_player_content_falls_back_to_play_page_when_player_data_missing -v` - -Expected: `OK` - -- [ ] **Step 5: Commit** - -```bash -git add 剧圈圈.py tests/test_剧圈圈.py -git commit -m "feat: add juquanquan player parsing" -``` - -### Task 5: Run Focused Verification And Clean Up - -**Files:** -- Modify: `剧圈圈.py` -- Modify: `tests/test_剧圈圈.py` - -- [ ] **Step 1: Run the full dedicated test file** - -Run: `python -m unittest tests.test_剧圈圈 -v` - -Expected: all tests in `tests/test_剧圈圈.py` pass. - -- [ ] **Step 2: Fix any isolated assertion mismatches found by the dedicated test file** - -```python -# Only make the smallest change required by the failing assertion. -# Typical examples: -# - adjust XPath fallback order -# - normalize whitespace in actor/director strings -# - preserve compact id format in a return payload -``` - -- [ ] **Step 3: Re-run the full dedicated test file** - -Run: `python -m unittest tests.test_剧圈圈 -v` - -Expected: `OK` - -- [ ] **Step 4: Review the final diff for accidental scope creep** - -Run: `git diff -- 剧圈圈.py tests/test_剧圈圈.py` - -Expected: diff only touches the new spider and its tests. - -- [ ] **Step 5: Commit** - -```bash -git add 剧圈圈.py tests/test_剧圈圈.py -git commit -m "feat: finalize juquanquan spider" -``` diff --git a/py/docs/superpowers/plans/2026-04-19-short-id-audit.md b/py/docs/superpowers/plans/2026-04-19-short-id-audit.md deleted file mode 100644 index cfe0de3..0000000 --- a/py/docs/superpowers/plans/2026-04-19-short-id-audit.md +++ /dev/null @@ -1,232 +0,0 @@ -# Spider 短路径 ID 审计修正 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 审计现有 Spider 的 `vod_id` 与播放 id 返回形式,并修复仍然暴露完整站内 URL 的实现,统一改为各站原始路径压缩后的短 id。 - -**Architecture:** 先基于现有测试和代码结构确认哪些 Spider 实际违规,再只对违规文件做最小可逆改动。每个违规 Spider 都增加短路径回归测试,并在 Spider 内部增加 `encode/decode` helper 或复用现有 helper 来完成“对外短 id、对内完整 URL”的分层。 - -**Tech Stack:** Python 3、`base.spider.Spider`、unittest、`unittest.mock`、lxml/XPath、regex - ---- - -## File Structure - -- Modify: `低端影视.py` - - 将 `vod_id` 从完整 URL 压缩为站内原始路径,并在详情解析中恢复 -- Modify: `滴答影视.py` - - 将 `vod_id` 从完整 URL 压缩为站内原始路径,并在详情解析中恢复 -- Modify: `tests/test_低端影视.py` - - 覆盖 `vod_id` 短路径输出和短路径详情请求 -- Modify: `tests/test_滴答影视.py` - - 覆盖 `vod_id` 短路径输出和短路径详情请求 -- Reference only: `libvio.py`, `剧迷.py`, `橘子TV.py`, `youknow.py`, `乌云影视.py`, `剧圈圈.py` - - 只做确认,不修改 - -### Task 1: Audit Existing Spider Behavior And Lock Failing Tests For 低端影视 - -**Files:** -- Modify: `tests/test_低端影视.py` -- Reference: `低端影视.py` - -- [ ] **Step 1: Write the failing tests for short detail ids in list/search/detail flows** - -```python - def test_parse_cards_returns_compact_site_path_ids(self): - html = """ -
- - -

示例影片

-
-
- """ - items = self.spider._parse_cards(html) - self.assertEqual(items[0]["vod_id"], "movie/demo") - - @patch.object(Spider, "_request_html") - def test_detail_content_decodes_compact_site_path_id(self, mock_request_html): - mock_request_html.return_value = "

示例影片

" - self.spider.detailContent(["movie/demo"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://ddys.io/movie/demo/") - - @patch.object(Spider, "_request_html") - def test_search_content_returns_compact_site_path_ids(self, mock_request_html): - mock_request_html.return_value = """ -
- - -

搜索影片

-
-
- """ - result = self.spider.searchContent("搜索影片", False, "1") - self.assertEqual(result["list"][0]["vod_id"], "movie/search-demo") -``` - -- [ ] **Step 2: Run the targeted tests to verify they fail** - -Run: `python -m unittest tests.test_低端影视.TestDDYSSpider.test_parse_cards_returns_compact_site_path_ids tests.test_低端影视.TestDDYSSpider.test_detail_content_decodes_compact_site_path_id tests.test_低端影视.TestDDYSSpider.test_search_content_returns_compact_site_path_ids -v` - -Expected: `FAIL` because `低端影视.py` currently returns full detail URLs and reads detail ids as URLs. - -- [ ] **Step 3: Implement the minimal path-based id encode/decode logic in 低端影视.py** - -```python - def _extract_site_path_id(self, href): - full = self._build_url(href) - matched = re.search(r"https?://[^/]+/(movie/[^?#]+?)/?$", full) - return matched.group(1).rstrip("/") if matched else "" - - def _build_detail_request_url(self, vod_id): - value = self._stringify(vod_id).strip().strip("/") - return self._build_url(value + "/") if value else "" - - def _parse_cards(self, html): - ... - vod_id = self._extract_site_path_id(href) - ... - - def detailContent(self, ids): - ... - vod_id = self._stringify(raw_id).strip().strip("/") - request_url = self._build_detail_request_url(vod_id) - if not request_url: - continue - vod = self._parse_detail_page(self._request_html(request_url), vod_id) - ... -``` - -- [ ] **Step 4: Run the targeted tests to verify they pass** - -Run: `python -m unittest tests.test_低端影视.TestDDYSSpider.test_parse_cards_returns_compact_site_path_ids tests.test_低端影视.TestDDYSSpider.test_detail_content_decodes_compact_site_path_id tests.test_低端影视.TestDDYSSpider.test_search_content_returns_compact_site_path_ids -v` - -Expected: `OK` - -- [ ] **Step 5: Commit** - -```bash -git add 低端影视.py tests/test_低端影视.py -git commit -m "fix: compress ddys detail ids" -``` - -### Task 2: Lock Failing Tests And Fix 短路径 IDs In 滴答影视 - -**Files:** -- Modify: `tests/test_滴答影视.py` -- Modify: `滴答影视.py` - -- [ ] **Step 1: Write the failing tests for short detail ids in list/search/detail flows** - -```python - def test_parse_cards_returns_compact_site_path_ids(self): - html = """ -
-
- -
- """ - cards = self.spider._parse_cards(html) - self.assertEqual(cards[0]["vod_id"], "detail/888") - - @patch.object(Spider, "_request_html") - def test_detail_content_decodes_compact_site_path_id(self, mock_request_html): - mock_request_html.return_value = '

详情标题

' - self.spider.detailContent(["detail/111"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.didahd.pro/detail/111.html") - - @patch.object(Spider, "_request_html") - def test_search_content_returns_compact_site_path_ids(self, mock_request_html): - mock_request_html.return_value = """ -
-
- -
- """ - result = self.spider.searchContent("搜索影片", False, "1") - self.assertEqual(result["list"][0]["vod_id"], "detail/321") -``` - -- [ ] **Step 2: Run the targeted tests to verify they fail** - -Run: `python -m unittest tests.test_滴答影视.TestDidaSpider.test_parse_cards_returns_compact_site_path_ids tests.test_滴答影视.TestDidaSpider.test_detail_content_decodes_compact_site_path_id tests.test_滴答影视.TestDidaSpider.test_search_content_returns_compact_site_path_ids -v` - -Expected: `FAIL` because `滴答影视.py` currently returns full detail URLs and reads detail ids as URLs. - -- [ ] **Step 3: Implement the minimal path-based id encode/decode logic in 滴答影视.py** - -```python - def _extract_site_path_id(self, href): - matched = re.search(r"/(detail/[^/?#]+)\.html", self._build_url(href)) - return matched.group(1) if matched else "" - - def _build_detail_request_url(self, vod_id): - value = self._stringify(vod_id).strip().strip("/") - return self._build_url(f"/{value}.html") if value else "" - - def _parse_cards(self, html): - ... - vod_id = self._extract_site_path_id(href) - ... - - def detailContent(self, ids): - ... - vod_id = self._stringify(raw).strip().strip("/") - if not vod_id: - continue - result["list"].append(self._parse_detail_page(self._request_html(self._build_detail_request_url(vod_id)), vod_id)) -``` - -- [ ] **Step 4: Run the targeted tests to verify they pass** - -Run: `python -m unittest tests.test_滴答影视.TestDidaSpider.test_parse_cards_returns_compact_site_path_ids tests.test_滴答影视.TestDidaSpider.test_detail_content_decodes_compact_site_path_id tests.test_滴答影视.TestDidaSpider.test_search_content_returns_compact_site_path_ids -v` - -Expected: `OK` - -- [ ] **Step 5: Commit** - -```bash -git add 滴答影视.py tests/test_滴答影视.py -git commit -m "fix: compress dida detail ids" -``` - -### Task 3: Verify No Regression In Already-Compliant Spiders - -**Files:** -- Reference only: `libvio.py` -- Reference only: `剧迷.py` -- Reference only: `youknow.py` -- Reference only: `橘子TV.py` -- Reference only: `乌云影视.py` -- Reference only: `剧圈圈.py` - -- [ ] **Step 1: Re-run focused test suites for the modified spiders** - -Run: `python -m unittest tests.test_低端影视 tests.test_滴答影视 -v` - -Expected: all tests in the two modified files pass. - -- [ ] **Step 2: Re-run sanity checks for already-compliant spiders without modifying them** - -Run: `python -m unittest tests.test_libvio tests.test_youknow tests.test_剧迷 -v` - -Expected: their existing compact-id-related behavior remains green. If unrelated known failures appear, note them and do not change those spiders in this task. - -- [ ] **Step 3: Review the final diff to ensure only actual offenders changed** - -Run: `git diff -- 低端影视.py 滴答影视.py tests/test_低端影视.py tests/test_滴答影视.py` - -Expected: diff only touches the two offending spiders and their tests. - -- [ ] **Step 4: Re-run the modified-spider tests one final time** - -Run: `python -m unittest tests.test_低端影视 tests.test_滴答影视 -v` - -Expected: `OK` - -- [ ] **Step 5: Commit** - -```bash -git add 低端影视.py 滴答影视.py tests/test_低端影视.py tests/test_滴答影视.py -git commit -m "fix: normalize short ids for audited spiders" -``` diff --git a/py/docs/superpowers/plans/2026-04-19-wooyun-spider.md b/py/docs/superpowers/plans/2026-04-19-wooyun-spider.md deleted file mode 100644 index 0ffdf03..0000000 --- a/py/docs/superpowers/plans/2026-04-19-wooyun-spider.md +++ /dev/null @@ -1,842 +0,0 @@ -# 乌云影视 Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的乌云影视爬虫,支持 `home/homeVideo/category/detail/search/player` 全链路。 - -**Architecture:** 采用单文件站点脚本 `乌云影视.py` 承担乌云影视的 JSON API 适配逻辑,内部拆分为请求封装、分类与筛选构造、列表映射、详情聚合、播放载荷编解码与剧集回查几个 helper。测试沿用当前仓库 `unittest + SourceFileLoader + mock` 风格,优先覆盖纯函数与高层 mock 网络流程,不依赖真实站点网络。 - -**Tech Stack:** Python 3, `requests`, `unittest`, `unittest.mock`, `json`, `base64`, `math`, `urllib.parse`, `base.spider.Spider` - ---- - -## File Structure - -- Create: `乌云影视.py` - - 乌云影视站点实现,继承 `base.spider.Spider` - - 暴露 `init`、`homeContent`、`homeVideoContent`、`categoryContent`、`detailContent`、`searchContent`、`playerContent` - - 私有方法负责 JSON 请求、分类筛选构造、列表映射、详情聚合、播放载荷编解码、剧集直链回查 -- Create: `tests/test_乌云影视.py` - - 用 `SourceFileLoader` 加载 `乌云影视.py` - - 用 mock JSON 返回覆盖首页、分类、详情、搜索和播放解析 - -### Task 1: Scaffold Spider, Request Helpers, And Menu Parsing - -**Files:** -- Create: `tests/test_乌云影视.py` -- Create: `乌云影视.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("wooyun_spider", str(ROOT / "乌云影视.py")).load_module() -Spider = MODULE.Spider - - -class TestWooyunSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_builds_classes_and_filters_from_menu(self): - menu = [ - {"code": "movie", "name": "电影"}, - {"code": "tv_series", "name": "电视剧"}, - {"code": "animation", "name": "动画"}, - {"code": "variety", "name": "综艺"}, - {"code": "short_drama", "name": "短剧"}, - { - "nameEn": "year", - "children": [ - {"code": "THIS_YEAR", "name": "今年"}, - {"code": "LAST_YEAR", "name": "去年"}, - {"code": "2024", "name": "2024"}, - ], - }, - { - "nameEn": "region", - "children": [{"code": "CN", "name": "大陆"}], - }, - { - "nameEn": "genre", - "children": [{"code": "COMEDY", "name": "喜剧"}], - }, - { - "nameEn": "language", - "children": [{"code": "ZH", "name": "国语"}], - }, - ] - - self.spider._request_json = lambda path, method="GET", data=None, headers=None: menu - content = self.spider.homeContent(False) - - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["movie", "tv_series", "animation", "variety", "short_drama", "THIS_YEAR", "LAST_YEAR"], - ) - self.assertEqual(content["filters"]["movie"][0]["key"], "year") - self.assertEqual(content["filters"]["movie"][1]["key"], "region") - self.assertEqual(content["filters"]["movie"][2]["key"], "genre") - self.assertEqual(content["filters"]["movie"][3]["key"], "lang") - self.assertEqual(content["filters"]["movie"][4]["key"], "sort") - - def test_request_json_uses_post_for_json_payload(self): - calls = {} - - class FakeResponse: - def __init__(self): - self.status_code = 200 - self.text = "" - - def json(self): - return {"data": {"records": []}} - - def fake_post(url, json=None, headers=None, timeout=5, verify=True, stream=False, allow_redirects=True, params=None, data=None, cookies=None): - calls["url"] = url - calls["json"] = json - calls["headers"] = headers - return FakeResponse() - - self.spider.post = fake_post - result = self.spider._request_json( - "/movie/media/search", - method="POST", - data={"pageIndex": "1"}, - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(calls["url"], "https://wooyun.tv/movie/media/search") - self.assertEqual(calls["json"], {"pageIndex": "1"}) - self.assertEqual(calls["headers"]["Origin"], "https://wooyun.tv") - self.assertEqual(result, {"records": []}) - - def test_build_category_body_handles_year_shortcuts_and_filters(self): - body = self.spider._build_category_body( - "THIS_YEAR", - "3", - {"region": "CN", "year": "2025", "lang": "ZH", "sort": "hot"}, - ) - self.assertEqual(body["menuCodeList"], ["THIS_YEAR", "CN", "2025", "ZH"]) - self.assertEqual(body["pageIndex"], "3") - self.assertEqual(body["sortCode"], "hot") - self.assertEqual(body["topCode"], "movie") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_乌云影视.TestWooyunSpider -v` -Expected: FAIL with `FileNotFoundError` for `乌云影视.py` or missing `Spider` attributes. - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import base64 -import json -import math -import sys - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "乌云影视" - self.host = "https://wooyun.tv" - self.page_size = 24 - self.home_limit = 12 - self.headers = { - "Accept": "application/json, text/plain, */*", - "Origin": self.host, - "Referer": self.host + "/", - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/124.0.0.0 Safari/537.36" - ), - } - self.sort_options = [ - {"n": "默认", "v": "default"}, - {"n": "最新", "v": "latest"}, - {"n": "最热", "v": "hot"}, - {"n": "评分", "v": "score"}, - ] - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def _stringify(self, value): - return "" if value is None else str(value) - - def _ensure_list(self, value): - return value if isinstance(value, list) else [] - - def _normalize_ext(self, extend): - if isinstance(extend, dict): - return extend - if not extend: - return {} - try: - return json.loads(str(extend)) - except Exception: - return {} - - def _full_url(self, path): - raw = self._stringify(path).strip() - if not raw: - return "" - if raw.startswith(("http://", "https://")): - return raw - if raw.startswith("//"): - return "https:" + raw - if raw.startswith("/"): - return self.host + raw - return self.host + "/" + raw - - def _request_json(self, path, method="GET", data=None, headers=None): - url = path if str(path).startswith("http") else self.host + path - merged_headers = dict(self.headers) - if headers: - merged_headers.update(headers) - - if method == "POST": - response = self.post(url, json=data, headers=merged_headers, timeout=15) - else: - response = self.fetch(url, headers=merged_headers, timeout=15) - - if response.status_code < 200 or response.status_code >= 300: - raise ValueError(f"{method} {path} => HTTP {response.status_code}") - - payload = response.json() - if payload.get("code") not in (None, 200) and payload.get("isSuccess") is False: - raise ValueError(payload.get("resultMsg") or str(payload.get("code"))) - return payload.get("data") - - def _build_classes(self, menu): - classes = [] - for code, fallback in [ - ("movie", "电影"), - ("tv_series", "电视剧"), - ("animation", "动画"), - ("variety", "综艺"), - ("short_drama", "短剧"), - ]: - found = next((item for item in self._ensure_list(menu) if item.get("code") == code), None) - classes.append({"type_id": code, "type_name": self._stringify((found or {}).get("name") or fallback)}) - - year_group = next((item for item in self._ensure_list(menu) if item.get("nameEn") == "year"), {}) - year_children = self._ensure_list(year_group.get("children")) - this_year = next((item for item in year_children if item.get("code") == "THIS_YEAR"), {}) - last_year = next((item for item in year_children if item.get("code") == "LAST_YEAR"), {}) - classes.append({"type_id": "THIS_YEAR", "type_name": self._stringify(this_year.get("name") or "今年")}) - classes.append({"type_id": "LAST_YEAR", "type_name": self._stringify(last_year.get("name") or "去年")}) - return classes - - def _build_filters(self, menu, classes): - option_map = {"genre": [], "region": [], "year": [], "language": []} - for group in self._ensure_list(menu): - values = [] - for item in self._ensure_list(group.get("children")): - code = self._stringify(item.get("code")) - name = self._stringify(item.get("name") or item.get("nameEn") or code) - if code and name and code not in ("THIS_YEAR", "LAST_YEAR"): - values.append({"n": name, "v": code}) - if group.get("nameEn") in option_map: - option_map[group.get("nameEn")] = values - - filters = {} - for cls in classes: - items = [] - if option_map["year"]: - items.append({"key": "year", "name": "年份", "init": "", "value": [{"n": "全部", "v": ""}] + option_map["year"]}) - if option_map["region"]: - items.append({"key": "region", "name": "地区", "init": "", "value": [{"n": "全部", "v": ""}] + option_map["region"]}) - if option_map["genre"] and cls["type_id"] in ("movie", "tv_series", "animation", "variety", "short_drama"): - items.append({"key": "genre", "name": "类型", "init": "", "value": [{"n": "全部", "v": ""}] + option_map["genre"]}) - if option_map["language"]: - items.append({"key": "lang", "name": "语言", "init": "", "value": [{"n": "全部", "v": ""}] + option_map["language"]}) - items.append({"key": "sort", "name": "排序", "init": "default", "value": self.sort_options}) - filters[cls["type_id"]] = items - return filters - - def _map_top_code(self, category_id): - mapping = { - "movie": "movie", - "tv_series": "tv_series", - "animation": "animation", - "variety": "variety", - "short_drama": "short_drama", - "THIS_YEAR": "movie", - "LAST_YEAR": "movie", - } - return mapping.get(category_id, "movie") - - def _build_category_body(self, category_id, page, extend): - filters = self._normalize_ext(extend) - menu_code_list = [] - if category_id not in ("movie", "tv_series", "animation", "variety", "short_drama"): - menu_code_list.append(category_id) - for key in ("genre", "region", "year", "lang", "other"): - value = self._stringify(filters.get(key)) - if value and value not in menu_code_list: - menu_code_list.append(value) - return { - "menuCodeList": menu_code_list, - "pageIndex": self._stringify(page or 1), - "pageSize": self.page_size, - "searchKey": "", - "sortCode": "" if filters.get("sort") in (None, "", "default") else self._stringify(filters.get("sort")), - "topCode": self._map_top_code(category_id), - } - - def homeContent(self, filter): - menu = self._request_json("/movie/category/menu") - classes = self._build_classes(menu) - return {"class": classes, "filters": self._build_filters(menu, classes)} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_乌云影视.TestWooyunSpider -v` -Expected: PASS for the three new tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_乌云影视.py 乌云影视.py -git commit -m "feat: scaffold wooyun spider menu parsing" -``` - -### Task 2: Add Home Video, Category, And Search Flows - -**Files:** -- Modify: `tests/test_乌云影视.py` -- Modify: `乌云影视.py` - -- [ ] **Step 1: Write the failing test** - -```python -from unittest.mock import patch - - -class TestWooyunSpider(unittest.TestCase): - def test_extract_home_list_deduplicates_media_resources(self): - home_blocks = { - "records": [ - { - "mediaResources": [ - {"id": 1, "title": "影片A", "posterUrl": "/a.jpg"}, - {"id": 1, "title": "影片A", "posterUrl": "/a.jpg"}, - ] - }, - { - "mediaResources": [ - {"id": 2, "title": "影片B", "posterUrlS3": "https://img.example/b.jpg"}, - ] - }, - ] - } - items = self.spider._extract_home_list(home_blocks) - self.assertEqual([item["id"] for item in items], ["1", "2"]) - - def test_map_vod_picks_expected_fields(self): - item = { - "id": 7, - "title": "示例片", - "posterUrl": "/poster.jpg", - "mediaType": {"code": "movie", "name": "电影"}, - "episodeStatus": "更新至4集", - "releaseYear": "2026", - "rating": "8.8", - "actors": ["甲", "乙"], - "directors": ["丙"], - } - vod = self.spider._map_vod(item) - self.assertEqual(vod["vod_id"], "7") - self.assertEqual(vod["vod_pic"], "/poster.jpg") - self.assertEqual(vod["vod_actor"], "甲/乙") - self.assertEqual(vod["vod_director"], "丙") - - @patch.object(Spider, "_request_json") - def test_home_video_content_reads_home_blocks(self, mock_request_json): - mock_request_json.return_value = { - "records": [ - {"mediaResources": [{"id": 1, "title": "首页片", "posterUrl": "/a.jpg"}]} - ] - } - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_id"], "1") - self.assertEqual(result["list"][0]["vod_name"], "首页片") - - @patch.object(Spider, "_request_json") - def test_category_content_posts_search_body_and_returns_page_data(self, mock_request_json): - mock_request_json.return_value = { - "records": [{"id": 9, "title": "分类片", "posterUrl": "/cate.jpg"}], - "total": 30, - "pages": 2, - } - result = self.spider.categoryContent("movie", "2", False, {"sort": "latest"}) - kwargs = mock_request_json.call_args.kwargs - self.assertEqual(kwargs["method"], "POST") - self.assertEqual(kwargs["data"]["pageIndex"], "2") - self.assertEqual(kwargs["data"]["sortCode"], "latest") - self.assertEqual(result["page"], 2) - self.assertEqual(result["pagecount"], 2) - self.assertEqual(result["limit"], 24) - self.assertEqual(result["total"], 30) - - @patch.object(Spider, "_request_json") - def test_search_content_returns_empty_result_for_blank_keyword(self, mock_request_json): - result = self.spider.searchContent("", False, "1") - self.assertEqual(result, {"page": 1, "pagecount": 0, "total": 0, "list": []}) - mock_request_json.assert_not_called() - - @patch.object(Spider, "_request_json") - def test_search_content_posts_keyword_body(self, mock_request_json): - mock_request_json.return_value = { - "records": [{"id": 15, "title": "搜索片", "posterUrl": "/search.jpg"}], - "total": 1, - "pages": 1, - } - result = self.spider.searchContent("繁花", False, "1") - body = mock_request_json.call_args.kwargs["data"] - self.assertEqual(body["searchKey"], "繁花") - self.assertEqual(body["topCode"], "") - self.assertEqual(result["list"][0]["vod_id"], "15") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_乌云影视.TestWooyunSpider -v` -Expected: FAIL with missing `_extract_home_list` / `_map_vod` / `homeVideoContent` / `categoryContent` / `searchContent`. - -- [ ] **Step 3: Write minimal implementation** - -```python - def _join_text(self, value): - return "/".join([self._stringify(item) for item in self._ensure_list(value) if self._stringify(item)]) - - def _pick_poster(self, item): - return self._stringify( - item.get("posterUrlS3") or item.get("posterUrl") or item.get("thumbUrlS3") or item.get("thumbUrl") - ) - - def _extract_home_list(self, home_blocks): - blocks = self._ensure_list((home_blocks or {}).get("records") if isinstance(home_blocks, dict) else home_blocks) - seen = set() - items = [] - for block in blocks: - for item in self._ensure_list(block.get("mediaResources")): - vod_id = self._stringify(item.get("id")) - if not vod_id or vod_id in seen: - continue - seen.add(vod_id) - copied = dict(item) - copied["id"] = vod_id - items.append(copied) - return items - - def _map_vod(self, item): - return { - "vod_id": self._stringify(item.get("id")), - "vod_name": self._stringify(item.get("title")), - "vod_pic": self._pick_poster(item), - "type_id": self._stringify((item.get("mediaType") or {}).get("code")), - "type_name": self._stringify((item.get("mediaType") or {}).get("name")), - "vod_remarks": self._stringify(item.get("episodeStatus") or item.get("remark")), - "vod_year": self._stringify(item.get("releaseYear")), - "vod_douban_score": self._stringify(item.get("rating")), - "vod_actor": self._join_text(item.get("actors")), - "vod_director": self._join_text(item.get("directors")), - } - - def homeVideoContent(self): - data = self._request_json(f"/movie/media/home/custom/classify/1/3?limit={self.home_limit}") - return {"list": [self._map_vod(item) for item in self._extract_home_list(data)[: self.home_limit]]} - - def categoryContent(self, tid, pg, filter, extend): - page = int(pg) - data = self._request_json( - "/movie/media/search", - method="POST", - data=self._build_category_body(self._stringify(tid or "movie"), page, extend), - headers={"Content-Type": "application/json"}, - ) or {} - records = [self._map_vod(item) for item in self._ensure_list(data.get("records"))] - total = int(data.get("total") or 0) - pagecount = int(data.get("pages") or (math.ceil(total / self.page_size) if total else (page if records else 0))) - return {"page": page, "pagecount": pagecount, "limit": self.page_size, "total": total, "list": records} - - def searchContent(self, key, quick, pg="1"): - page = int(pg) - keyword = self._stringify(key).strip() - if not keyword: - return {"page": page, "pagecount": 0, "total": 0, "list": []} - - data = self._request_json( - "/movie/media/search", - method="POST", - data={ - "menuCodeList": [], - "pageIndex": self._stringify(page), - "pageSize": self.page_size, - "searchKey": keyword, - "sortCode": "", - "topCode": "", - }, - headers={"Content-Type": "application/json"}, - ) or {} - records = [self._map_vod(item) for item in self._ensure_list(data.get("records"))] - total = int(data.get("total") or 0) - pagecount = int(data.get("pages") or (math.ceil(total / self.page_size) if total else (page if records else 0))) - return {"page": page, "pagecount": pagecount, "total": total, "list": records} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_乌云影视.TestWooyunSpider -v` -Expected: PASS for the new home/category/search tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_乌云影视.py 乌云影视.py -git commit -m "feat: add wooyun list and search flows" -``` - -### Task 3: Add Detail Aggregation And Multi-Season Playlists - -**Files:** -- Modify: `tests/test_乌云影视.py` -- Modify: `乌云影视.py` - -- [ ] **Step 1: Write the failing test** - -```python -class TestWooyunSpider(unittest.TestCase): - def test_build_play_sources_encodes_multiple_seasons(self): - seasons = [ - { - "seasonNo": 1, - "videoList": [ - {"id": 101, "epNo": 1, "remark": "", "playUrl": "/play-1.m3u8"}, - {"id": 102, "epNo": 2, "remark": "加更", "playUrl": "/play-2.m3u8"}, - ], - }, - { - "seasonNo": 2, - "videoList": [ - {"id": 201, "epNo": 1, "remark": "", "playUrl": "/play-3.m3u8"}, - ], - }, - ] - payload = self.spider._build_play_sources(seasons, "99") - self.assertEqual(payload["vod_play_from"], "第1季$$$第2季") - self.assertIn("第1集$", payload["vod_play_url"]) - self.assertIn("第2集 加更$", payload["vod_play_url"]) - - def test_decode_play_id_round_trips_base64url_payload(self): - encoded = self.spider._encode_play_payload( - {"mediaId": "88", "seasonNo": 1, "epNo": 3, "videoId": 12, "playUrl": "/video.m3u8", "name": "第3集"} - ) - decoded = self.spider._decode_play_id(encoded) - self.assertEqual(decoded["mediaId"], "88") - self.assertEqual(decoded["seasonNo"], 1) - self.assertEqual(decoded["epNo"], 3) - self.assertEqual(decoded["playUrl"], "/video.m3u8") - - @patch.object(Spider, "_request_json") - def test_detail_content_merges_detail_apis_and_videos(self, mock_request_json): - mock_request_json.side_effect = [ - {"id": 300, "title": "基础标题", "posterUrl": "/base.jpg"}, - { - "id": 300, - "title": "完整标题", - "posterUrlS3": "https://img.example/detail.jpg", - "mediaType": {"code": "tv_series", "name": "电视剧"}, - "episodeStatus": "更新至2集", - "releaseYear": "2026", - "region": "大陆", - "actors": ["张三", "李四"], - "directors": ["王五"], - "overview": "一段简介", - "rating": "9.1", - }, - [ - { - "seasonNo": 1, - "videoList": [ - {"id": 901, "epNo": 1, "remark": "", "playUrl": "/ep1.m3u8"}, - {"id": 902, "epNo": 2, "remark": "超前", "playUrl": "/ep2.m3u8"}, - ], - } - ], - ] - result = self.spider.detailContent(["300"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "300") - self.assertEqual(vod["vod_name"], "完整标题") - self.assertEqual(vod["vod_pic"], "https://img.example/detail.jpg") - self.assertEqual(vod["type_name"], "电视剧") - self.assertEqual(vod["vod_area"], "大陆") - self.assertEqual(vod["vod_actor"], "张三/李四") - self.assertEqual(vod["vod_director"], "王五") - self.assertEqual(vod["vod_content"], "一段简介") - self.assertEqual(vod["vod_douban_score"], "9.1") - self.assertEqual(vod["vod_play_from"], "乌云影视") - self.assertIn("第2集 超前$", vod["vod_play_url"]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_乌云影视.TestWooyunSpider -v` -Expected: FAIL with missing `_build_play_sources` / `_encode_play_payload` / `_decode_play_id` / `detailContent`. - -- [ ] **Step 3: Write minimal implementation** - -```python - def _encode_play_payload(self, payload): - raw = json.dumps(payload, ensure_ascii=False).encode("utf-8") - return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=") - - def _decode_play_id(self, play_id): - raw = self._stringify(play_id).strip() - padding = "=" * (-len(raw) % 4) - try: - return json.loads(base64.urlsafe_b64decode((raw + padding).encode("utf-8")).decode("utf-8")) - except Exception: - return {"mediaId": raw, "playUrl": raw} - - def _build_play_sources(self, seasons, media_id): - season_list = self._ensure_list(seasons) - multiple = len(season_list) > 1 - from_list = [] - url_list = [] - for index, season in enumerate(season_list, start=1): - season_no = season.get("seasonNo") or index - episode_entries = [] - for video_index, video in enumerate(self._ensure_list(season.get("videoList")), start=1): - ep_no = int(video.get("epNo") or video_index) - remark = self._stringify(video.get("remark")).strip() - if ep_no > 0: - name = f"第{ep_no}集" if not remark else f"第{ep_no}集 {remark}" - else: - name = remark or "正片" - payload = self._encode_play_payload( - { - "mediaId": self._stringify(media_id), - "seasonNo": season_no, - "epNo": ep_no, - "videoId": video.get("id"), - "playUrl": video.get("playUrl"), - "name": name, - } - ) - episode_entries.append(f"{name}${payload}") - if episode_entries: - from_list.append(f"第{season_no}季" if multiple else self._stringify(season.get("lineName") or season.get("title") or self.name)) - url_list.append("#".join(episode_entries)) - return {"vod_play_from": "$$$".join(from_list), "vod_play_url": "$$$".join(url_list)} - - def detailContent(self, ids): - result = {"list": []} - for raw_id in ids: - media_id = self._stringify(raw_id).strip() - if not media_id: - continue - base_detail = self._request_json(f"/movie/media/base/detail?mediaId={media_id}") or {} - detail = self._request_json(f"/movie/media/detail?mediaId={media_id}") or {} - seasons = self._request_json(f"/movie/media/video/list?mediaId={media_id}&lineName=&resolutionCode=") or [] - merged = detail or base_detail - play_data = self._build_play_sources(seasons, media_id) - result["list"].append( - { - "vod_id": self._stringify(merged.get("id") or media_id), - "vod_name": self._stringify(merged.get("title") or base_detail.get("title")), - "vod_pic": self._pick_poster(merged or base_detail), - "type_id": self._stringify((merged.get("mediaType") or {}).get("code")), - "type_name": self._stringify((merged.get("mediaType") or {}).get("name")), - "vod_remarks": self._stringify(merged.get("episodeStatus")), - "vod_year": self._stringify(merged.get("releaseYear")), - "vod_area": self._stringify(merged.get("region")), - "vod_actor": self._join_text(merged.get("actors")), - "vod_director": self._join_text(merged.get("directors")), - "vod_content": self._stringify(merged.get("overview") or merged.get("description")), - "vod_douban_score": self._stringify(merged.get("rating")), - **play_data, - } - ) - return result -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_乌云影视.TestWooyunSpider -v` -Expected: PASS for the new detail and playlist tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_乌云影视.py 乌云影视.py -git commit -m "feat: add wooyun detail aggregation" -``` - -### Task 4: Add Player Resolution And Fallback Logic - -**Files:** -- Modify: `tests/test_乌云影视.py` -- Modify: `乌云影视.py` - -- [ ] **Step 1: Write the failing test** - -```python -class TestWooyunSpider(unittest.TestCase): - def test_find_play_url_matches_video_id_then_ep_no(self): - seasons = [ - { - "seasonNo": 1, - "videoList": [ - {"id": 10, "epNo": 1, "playUrl": "/ep1.m3u8"}, - {"id": 11, "epNo": 2, "playUrl": "/ep2.m3u8"}, - ], - } - ] - self.assertEqual(self.spider._find_play_url(seasons, {"seasonNo": 1, "videoId": 11}), "/ep2.m3u8") - self.assertEqual(self.spider._find_play_url(seasons, {"seasonNo": 1, "epNo": 1}), "/ep1.m3u8") - - def test_player_content_returns_direct_url_when_payload_has_play_url(self): - play_id = self.spider._encode_play_payload( - {"mediaId": "100", "seasonNo": 1, "epNo": 1, "videoId": 10, "playUrl": "/direct.m3u8", "name": "第1集"} - ) - result = self.spider.playerContent("乌云影视", play_id, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://wooyun.tv/direct.m3u8") - self.assertEqual(result["header"]["Referer"], "https://wooyun.tv/") - - @patch.object(Spider, "_request_json") - def test_player_content_refetches_video_list_when_payload_missing_play_url(self, mock_request_json): - mock_request_json.return_value = [ - { - "seasonNo": 1, - "videoList": [ - {"id": 77, "epNo": 3, "playUrl": "/refetched.m3u8"}, - ], - } - ] - play_id = self.spider._encode_play_payload( - {"mediaId": "100", "seasonNo": 1, "epNo": 3, "videoId": 77, "playUrl": "", "name": "第3集"} - ) - result = self.spider.playerContent("乌云影视", play_id, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://wooyun.tv/refetched.m3u8") - - def test_player_content_falls_back_to_external_parse_when_no_url_found(self): - result = self.spider.playerContent("乌云影视", "raw-play-id", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "raw-play-id") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m unittest tests.test_乌云影视.TestWooyunSpider -v` -Expected: FAIL with missing `_find_play_url` or incorrect `playerContent` fallback behavior. - -- [ ] **Step 3: Write minimal implementation** - -```python - def _find_play_url(self, seasons, target): - for season in self._ensure_list(seasons): - if target.get("seasonNo") is not None and int(season.get("seasonNo") or 0) != int(target.get("seasonNo")): - continue - for video in self._ensure_list(season.get("videoList")): - if target.get("videoId") is not None and int(video.get("id") or 0) == int(target.get("videoId")): - return self._stringify(video.get("playUrl")) - if target.get("epNo") is not None and int(video.get("epNo") or 0) == int(target.get("epNo")): - return self._stringify(video.get("playUrl")) - - first_season = self._ensure_list(seasons)[0] if self._ensure_list(seasons) else {} - first_video = self._ensure_list(first_season.get("videoList"))[0] if self._ensure_list(first_season.get("videoList")) else {} - return self._stringify(first_video.get("playUrl")) - - def playerContent(self, flag, id, vipFlags): - payload = self._decode_play_id(self._stringify(id).strip()) - final_url = self._stringify(payload.get("playUrl")) - if not final_url and payload.get("mediaId"): - seasons = self._request_json( - f"/movie/media/video/list?mediaId={payload['mediaId']}&lineName=&resolutionCode=" - ) or [] - final_url = self._find_play_url(seasons, payload) - - if not final_url: - return {"parse": 1, "jx": 1, "url": self._stringify(id), "header": dict(self.headers)} - - return { - "parse": 0, - "jx": 0, - "url": self._full_url(final_url), - "header": { - "Referer": self.host + "/", - "Origin": self.host, - "User-Agent": self.headers["User-Agent"], - }, - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m unittest tests.test_乌云影视.TestWooyunSpider -v` -Expected: PASS for the new player tests and all previous tests. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_乌云影视.py 乌云影视.py -git commit -m "feat: add wooyun player resolution" -``` - -### Task 5: Full Verification And Cleanup - -**Files:** -- Modify: `tests/test_乌云影视.py` -- Modify: `乌云影视.py` - -- [ ] **Step 1: Run the focused spider test suite** - -Run: `python -m unittest tests.test_乌云影视 -v` -Expected: All tests PASS with no import or syntax errors. - -- [ ] **Step 2: Run adjacent regression tests** - -Run: `python -m unittest tests.test_dbku tests.test_剧迷 tests.test_橘子TV tests.test_youknow tests.test_libvio -v` -Expected: Existing spider tests still PASS. - -- [ ] **Step 3: Run syntax verification** - -Run: `python -m py_compile 乌云影视.py tests/test_乌云影视.py` -Expected: Command exits successfully with no output. - -- [ ] **Step 4: Review git diff for scope** - -Run: `git diff -- 乌云影视.py tests/test_乌云影视.py docs/superpowers/plans/2026-04-19-wooyun-spider.md` -Expected: Diff only contains the new spider, its tests, and the implementation plan. - -- [ ] **Step 5: Commit** - -```bash -git add 乌云影视.py tests/test_乌云影视.py docs/superpowers/plans/2026-04-19-wooyun-spider.md -git commit -m "feat: add wooyun spider" -``` diff --git a/py/docs/superpowers/plans/2026-04-20-jingyu-app-spider.md b/py/docs/superpowers/plans/2026-04-20-jingyu-app-spider.md deleted file mode 100644 index 6812f60..0000000 --- a/py/docs/superpowers/plans/2026-04-20-jingyu-app-spider.md +++ /dev/null @@ -1,1020 +0,0 @@ -# 鲸鱼APP Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Port the 鲸鱼APP JS TVBox T4 plugin to a Python spider following existing repo patterns. - -**Architecture:** Single-file spider (`鲸鱼APP.py`) inheriting `base.spider.Spider`. Uses AES-CBC encryption for API communication. Dynamic host fetched from COS URL at init time. Categories/filters pulled from init API. Play parsing supports 4 modes (direct, prefix, fetch, vodParse). - -**Tech Stack:** Python 3.14, pycryptodome (AES-CBC), requests, unittest+mock for tests - ---- - -## File Structure - -- **Create:** `py/鲸鱼APP.py` — spider implementation (single file, ~350 lines) -- **Create:** `py/tests/test_鲸鱼APP.py` — unit tests - ---- - -### Task 1: AES encrypt/decrypt helpers - -**Files:** -- Create: `py/鲸鱼APP.py` -- Test: `py/tests/test_鲸鱼APP.py` - -- [ ] **Step 1: Write failing test for AES round-trip** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("jingyu_spider", str(ROOT / "鲸鱼APP.py")).load_module() -Spider = MODULE.Spider - - -class TestJingyuSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - - def test_aes_encrypt_decrypt_roundtrip(self): - plaintext = '{"key":"value"}' - encrypted = self.spider._aes_encrypt(plaintext) - decrypted = self.spider._aes_decrypt(encrypted) - self.assertEqual(decrypted, plaintext) - - def test_aes_encrypt_produces_base64(self): - import re - encrypted = self.spider._aes_encrypt("hello") - self.assertTrue(re.match(r'^[A-Za-z0-9+/=]+$', encrypted)) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: FAIL — module not found - -- [ ] **Step 3: Write spider skeleton with AES helpers** - -Create `py/鲸鱼APP.py`: - -```python -# coding=utf-8 -import base64 -import json -import re -import sys -import time - -from Crypto.Cipher import AES -from Crypto.Util.Padding import pad, unpad -from base.spider import Spider as BaseSpider - -sys.path.append("..") - -AES_KEY = "AAdgrdghjfgsABC1" -AES_IV = "AAdgrdghjfgsABC1" - - -class Spider(BaseSpider): - def __init__(self): - self.name = "鲸鱼APP" - self.host = "" - self.ua = "okhttp/3.10.0" - self.api_path = "/api.php/qijiappapi.index" - self.init_endpoint = "initV122" - self.search_endpoint = "searchList" - self.search_verify = False - self.init_data = None - - def getName(self): - return self.name - - def init(self, extend=""): - pass - - def _aes_encrypt(self, plaintext): - key = AES_KEY.encode("utf-8") - iv = AES_IV.encode("utf-8") - cipher = AES.new(key, AES.MODE_CBC, iv) - padded = pad(plaintext.encode("utf-8"), AES.block_size) - encrypted = cipher.encrypt(padded) - return base64.b64encode(encrypted).decode("utf-8") - - def _aes_decrypt(self, ciphertext): - key = AES_KEY.encode("utf-8") - iv = AES_IV.encode("utf-8") - raw = base64.b64decode(ciphertext) - cipher = AES.new(key, AES.MODE_CBC, iv) - decrypted = unpad(cipher.decrypt(raw), AES.block_size) - return decrypted.decode("utf-8") -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/鲸鱼APP.py py/tests/test_鲸鱼APP.py -git commit -m "feat: add 鲸鱼APP spider skeleton with AES helpers" -``` - ---- - -### Task 2: API request helper (`_api_post`) - -**Files:** -- Modify: `py/鲸鱼APP.py` -- Test: `py/tests/test_鲸鱼APP.py` - -- [ ] **Step 1: Write failing test for `_api_post`** - -Append to `py/tests/test_鲸鱼APP.py`: - -```python - def test_api_post_decrypts_response(self): - # Encrypt a known JSON payload - payload = {"result": "ok"} - encrypted = self.spider._aes_encrypt(json.dumps(payload)) - - class FakeResponse: - status_code = 200 - encoding = "utf-8" - def json(self): - return {"data": encrypted} - - def fake_post(url, **kwargs): - return FakeResponse() - - self.spider.post = fake_post - self.spider.host = "http://test.com" - result = self.spider._api_post("someEndpoint") - self.assertEqual(result, payload) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP.TestJingyuSpider.test_api_post_decrypts_response -v` -Expected: FAIL — AttributeError - -- [ ] **Step 3: Implement `_api_post`** - -Append to `py/鲸鱼APP.py` inside the `Spider` class, after `_aes_decrypt`: - -```python - def _api_post(self, endpoint, payload=None): - if payload is None: - payload = {} - ep = f"/{endpoint}" if not endpoint.startswith("/") else endpoint - url = f"{self.host}{self.api_path}{ep}" - headers = { - "User-Agent": self.ua, - "Accept-Encoding": "gzip", - } - rsp = self.post(url, json=payload, headers=headers, timeout=15, verify=False) - data = rsp.json().get("data") - if not data: - return None - try: - return json.loads(self._aes_decrypt(data)) - except Exception as e: - self.log(f"JSON解析失败: {e}") - return None -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/鲸鱼APP.py py/tests/test_鲸鱼APP.py -git commit -m "feat: add _api_post with encrypted API communication" -``` - ---- - -### Task 3: Init and host resolution - -**Files:** -- Modify: `py/鲸鱼APP.py` -- Test: `py/tests/test_鲸鱼APP.py` - -- [ ] **Step 1: Write failing test for init** - -Append to test file: - -```python - def test_init_fetches_host_from_site_url(self): - init_encrypted = self.spider._aes_encrypt(json.dumps({"type_list": []})) - - class FakeInitResponse: - status_code = 200 - encoding = "utf-8" - text = "http://example.com" - def json(self): - return {"data": init_encrypted} - - call_count = {"n": 0} - - def fake_fetch(url, **kwargs): - return FakeInitResponse() - - def fake_post(url, **kwargs): - call_count["n"] += 1 - return FakeInitResponse() - - self.spider.fetch = fake_fetch - self.spider.post = fake_post - self.spider.init() - self.assertEqual(self.spider.host, "http://example.com") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP.TestJingyuSpider.test_init_fetches_host_from_site_url -v` -Expected: FAIL - -- [ ] **Step 3: Implement `init` method** - -Replace the existing `init` method in `py/鲸鱼APP.py`: - -```python - SITE_URL = "https://jingyu4k-1312635929.cos.ap-nanjing.myqcloud.com/juyu3.json" - - def init(self, extend=""): - if self.host: - return - headers = {"User-Agent": self.ua} - try: - rsp = self.fetch(self.SITE_URL, headers=headers, timeout=10, verify=False) - host = rsp.text.strip().rstrip("/") - if not host.startswith("http"): - host = "http://" + host - self.host = host - except Exception as e: - self.log(f"获取host失败: {e}") - raise - try: - data = self._api_post(self.init_endpoint) - if data and data.get("config", {}).get("system_search_verify_status"): - self.search_verify = True - self.init_data = data - except Exception as e: - self.log(f"初始化数据失败: {e}") -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/鲸鱼APP.py py/tests/test_鲸鱼APP.py -git commit -m "feat: add init with host resolution and search verify check" -``` - ---- - -### Task 4: Category management and homeContent - -**Files:** -- Modify: `py/鲸鱼APP.py` -- Test: `py/tests/test_鲸鱼APP.py` - -- [ ] **Step 1: Write failing tests for category processing** - -Append to test file: - -```python - def test_process_classes_blocks_and_sorts(self): - type_list = [ - {"type_id": "0", "type_name": "全部"}, - {"type_id": "1", "type_name": "电影"}, - {"type_id": "3", "type_name": "综艺"}, - {"type_id": "2", "type_name": "电视剧"}, - {"type_id": "4", "type_name": "动漫"}, - ] - classes = self.spider._process_classes(type_list) - names = [c["type_name"] for c in classes] - self.assertNotIn("全部", names) - self.assertEqual(names, ["电影", "电视剧", "综艺", "动漫"]) - - def test_process_area_filter_merges_mainland_areas(self): - areas = ["全部", "中国大陆", "大陆", "内地", "美国", "日本"] - result = self.spider._process_area_filter(areas) - self.assertIn("大陆", result) - self.assertNotIn("中国大陆", result) - self.assertNotIn("内地", result) - self.assertIn("美国", result) - - def test_convert_filters_adds_current_year(self): - import datetime - type_list = [{ - "type_id": "1", - "filter_type_list": [ - {"name": "year", "list": ["全部", "2024"]}, - {"name": "area", "list": ["全部", "中国大陆"]}, - ] - }] - current = str(datetime.datetime.now().year) - filters = self.spider._convert_filters(type_list) - year_values = [v["v"] for v in filters["1"][0]["value"]] - self.assertIn(current, year_values) - area_values = [v["v"] for v in filters["1"][1]["value"]] - self.assertIn("大陆", area_values) - self.assertNotIn("中国大陆", area_values) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: FAIL — missing methods - -- [ ] **Step 3: Implement category helpers and homeContent** - -Append to `py/鲸鱼APP.py` inside the class, after `init`: - -```python - CATEGORY_BLOCKED = ["全部"] - CATEGORY_FORCE_ORDER = ["电影", "电视剧", "综艺", "动漫", "短剧"] - AREA_MERGE_DISPLAY = "大陆" - AREA_MERGE_LIST = ["中国大陆", "大陆", "内地"] - - def _process_classes(self, type_list): - order_map = {n: i for i, n in enumerate(self.CATEGORY_FORCE_ORDER)} - classes = [ - {"type_id": t["type_id"], "type_name": t["type_name"]} - for t in type_list - if t["type_name"] not in self.CATEGORY_BLOCKED - ] - classes.sort(key=lambda c: order_map.get(c["type_name"], 999)) - return classes - - def _process_area_filter(self, area_list): - if not area_list: - return area_list - merge_set = set(self.AREA_MERGE_LIST) - filtered = [a for a in area_list if a not in merge_set] - has_merge = any(a in merge_set for a in area_list) - if has_merge: - try: - idx = filtered.index("全部") - filtered.insert(idx + 1, self.AREA_MERGE_DISPLAY) - except ValueError: - filtered.insert(0, self.AREA_MERGE_DISPLAY) - return filtered - - def _convert_filters(self, type_list): - name_map = {"class": "类型", "area": "地区", "lang": "语言", "year": "年份", "sort": "排序"} - current_year = str(time.localtime().tm_year) - filters = {} - for t in type_list: - arr = [] - for f in t.get("filter_type_list", []): - key = "by" if f["name"] == "sort" else f["name"] - values = list(f.get("list", [])) - if f["name"] == "area": - values = self._process_area_filter(values) - if f["name"] == "year" and current_year not in values: - try: - idx = values.index("全部") - values.insert(idx + 1, current_year) - except ValueError: - values.insert(0, current_year) - arr.append({ - "key": key, - "name": name_map.get(f["name"], f["name"]), - "value": [{"n": v, "v": v} for v in values], - }) - filters[t["type_id"]] = arr - return filters - - def homeContent(self, filter): - self.init() - data = self.init_data - if not data: - return {"class": [], "filters": {}} - classes = self._process_classes(data.get("type_list", [])) - filters = self._convert_filters(data.get("type_list", [])) - return {"class": classes, "filters": filters} - - def homeVideoContent(self): - self.init() - if not self.init_data: - return {"list": []} - videos = [] - for t in self.init_data.get("type_list", []): - for item in t.get("recommend_list", []): - videos.append({ - "vod_id": str(item.get("vod_id", "")), - "vod_name": item.get("vod_name", ""), - "vod_pic": item.get("vod_pic", ""), - "vod_remarks": item.get("vod_remarks", ""), - }) - return {"list": videos} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/鲸鱼APP.py py/tests/test_鲸鱼APP.py -git commit -m "feat: add category management, area merge, and homeContent" -``` - ---- - -### Task 5: categoryContent with area merge - -**Files:** -- Modify: `py/鲸鱼APP.py` -- Test: `py/tests/test_鲸鱼APP.py` - -- [ ] **Step 1: Write failing test for categoryContent** - -Append to test file: - -```python - def test_category_content_posts_filter_payload(self): - encrypted_empty = self.spider._aes_encrypt(json.dumps({"recommend_list": []})) - - class FakeRsp: - status_code = 200 - encoding = "utf-8" - def json(self): - return {"data": encrypted_empty} - - calls = {} - - def fake_post(url, **kwargs): - calls.update(kwargs) - return FakeRsp() - - self.spider.post = fake_post - self.spider.host = "http://test.com" - result = self.spider.categoryContent("1", "2", False, {"area": "美国", "year": "2025"}) - self.assertEqual(result["page"], 2) - self.assertNotIn("pagecount", result) - self.assertEqual(result["list"], []) - - def test_category_content_merges_area_when_mainland_selected(self): - items_a = [{"vod_id": "1", "vod_name": "A", "vod_pic": "", "vod_remarks": ""}] - items_b = [{"vod_id": "2", "vod_name": "B", "vod_pic": "", "vod_remarks": ""}, - {"vod_id": "1", "vod_name": "A", "vod_pic": "", "vod_remarks": ""}] - - call_idx = {"n": 0} - - def fake_api_post(endpoint, payload=None): - call_idx["n"] += 1 - if call_idx["n"] == 1: - return {"recommend_list": items_a} - return {"recommend_list": items_b} - - self.spider._api_post = fake_api_post - self.spider.host = "http://test.com" - result = self.spider.categoryContent("1", "1", False, {"area": "大陆"}) - ids = [v["vod_id"] for v in result["list"]] - self.assertEqual(len(ids), 2) - self.assertIn("1", ids) - self.assertIn("2", ids) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP.TestJingyuSpider.test_category_content_posts_filter_payload -v` -Expected: FAIL - -- [ ] **Step 3: Implement categoryContent** - -Append to `py/鲸鱼APP.py` inside the class: - -```python - def _merge_area_search(self, type_id, page, base_filters): - all_results = [] - seen = set() - for area_val in self.AREA_MERGE_LIST: - try: - payload = {"type_id": type_id, "page": page, "area": area_val} - payload.update(base_filters) - res = self._api_post("typeFilterVodList", payload) - for item in (res or {}).get("recommend_list", []): - vid = str(item.get("vod_id", "")) - if vid not in seen: - seen.add(vid) - all_results.append({ - "vod_id": vid, - "vod_name": item.get("vod_name", ""), - "vod_pic": item.get("vod_pic", ""), - "vod_remarks": item.get("vod_remarks", ""), - }) - except Exception as e: - self.log(f"聚合搜索地区[{area_val}]失败: {e}") - return all_results - - def categoryContent(self, tid, pg, filter, extend): - self.init() - page = int(pg) - ext = extend or {} - - if ext.get("area") == self.AREA_MERGE_DISPLAY: - base = {k: v for k, v in ext.items() if k != "area"} - base.setdefault("year", "全部") - base.setdefault("sort", "最新") - base.setdefault("lang", "全部") - base.setdefault("class", "全部") - merged = self._merge_area_search(tid, page, base) - return {"list": merged, "page": page, "limit": 90, "total": 999999} - - payload = { - "type_id": tid, - "page": page, - "area": ext.get("area", "全部"), - "year": ext.get("year", "全部"), - "sort": ext.get("by", ext.get("sort", "最新")), - "lang": ext.get("lang", "全部"), - "class": ext.get("class", "全部"), - } - res = self._api_post("typeFilterVodList", payload) - items = [] - for item in (res or {}).get("recommend_list", []): - items.append({ - "vod_id": str(item.get("vod_id", "")), - "vod_name": item.get("vod_name", ""), - "vod_pic": item.get("vod_pic", ""), - "vod_remarks": item.get("vod_remarks", ""), - }) - return {"list": items, "page": page, "limit": 90, "total": 999999} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/鲸鱼APP.py py/tests/test_鲸鱼APP.py -git commit -m "feat: add categoryContent with area merge support" -``` - ---- - -### Task 6: detailContent with line management - -**Files:** -- Modify: `py/鲸鱼APP.py` -- Test: `py/tests/test_鲸鱼APP.py` - -- [ ] **Step 1: Write failing test for detailContent** - -Append to test file: - -```python - def test_detail_content_builds_play_lines(self): - detail_data = { - "vod": { - "vod_name": "测试影片", - "vod_pic": "http://pic.jpg", - "vod_remarks": "HD", - "vod_content": "简介", - "vod_actor": "演员张三", - "vod_director": "导演李四", - "vod_year": "2025", - "vod_area": "大陆", - }, - "vod_play_list": [ - { - "player_info": { - "show": "线路一", - "parse": "http://parse.com/", - "player_parse_type": "1", - "parse_type": "1", - }, - "urls": [ - {"name": "第1集", "url": "http://play/1", "token": "tok1"}, - {"name": "第2集", "url": "http://play/2", "token": "tok2"}, - ], - }, - ], - } - - self.spider._api_post = lambda ep, payload=None: detail_data if "vodDetail" in ep else None - self.spider.host = "http://test.com" - result = self.spider.detailContent(["123"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "测试影片") - self.assertEqual(vod["vod_year"], "2025年") - self.assertIn("线路一", vod["vod_play_from"]) - self.assertIn("第1集$", vod["vod_play_url"]) - self.assertIn("第2集$", vod["vod_play_url"]) - - def test_detail_content_filters_junk_lines(self): - detail_data = { - "vod": {"vod_name": "X", "vod_pic": "", "vod_remarks": "", "vod_content": "", - "vod_actor": "", "vod_director": "", "vod_year": "", "vod_area": ""}, - "vod_play_list": [ - { - "player_info": {"show": "防走丢群", "parse": "", "player_parse_type": "0", "parse_type": "0"}, - "urls": [{"name": "链接", "url": "http://x", "token": ""}], - }, - { - "player_info": {"show": "正式线路", "parse": "", "player_parse_type": "0", "parse_type": "0"}, - "urls": [{"name": "第1集", "url": "http://y", "token": ""}], - }, - ], - } - - self.spider._api_post = lambda ep, payload=None: detail_data - self.spider.host = "http://test.com" - result = self.spider.detailContent(["1"]) - vod = result["list"][0] - # 防走丢 line renamed to "1线" - self.assertIn("1线", vod["vod_play_from"]) - self.assertIn("正式线路", vod["vod_play_from"]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP.TestJingyuSpider.test_detail_content_builds_play_lines -v` -Expected: FAIL - -- [ ] **Step 3: Implement detailContent** - -Append to `py/鲸鱼APP.py` inside the class: - -```python - JUNK_KEYWORDS = ["防走丢", "群", "防失群", "官网"] - - def detailContent(self, ids): - self.init() - vod_id = ids[0] - data = None - for endpoint in ["vodDetail", "vodDetail2"]: - try: - data = self._api_post(endpoint, {"vod_id": vod_id}) - if data: - break - except Exception: - continue - if not data: - return {"list": []} - - vod = data.get("vod", {}) - lines = [] - name_count = {} - line_id = 1 - - for line in data.get("vod_play_list", []): - info = line.get("player_info", {}) - name = info.get("show", "") - - if any(kw in name for kw in self.JUNK_KEYWORDS): - name = f"{line_id}线" - info["show"] = name - - count = name_count.get(name, 0) + 1 - name_count[name] = count - if count > 1: - name = f"{name}{count}" - info["show"] = name - - urls = line.get("urls", []) - if not urls: - line_id += 1 - continue - - play_items = [] - for vod in urls: - payload = ",".join([ - info.get("parse", ""), - vod.get("url", ""), - "token+" + vod.get("token", ""), - str(info.get("player_parse_type", "")), - str(info.get("parse_type", "")), - ]) - play_items.append(f"{vod.get('name', '')}${name}@@direct@@{payload}") - - if play_items: - lines.append({ - "display": name, - "urls": "#".join(play_items), - }) - line_id += 1 - - return { - "list": [{ - "vod_id": vod_id, - "vod_name": vod.get("vod_name", ""), - "vod_pic": vod.get("vod_pic", ""), - "vod_remarks": vod.get("vod_remarks", ""), - "vod_content": vod.get("vod_content", ""), - "vod_actor": (vod.get("vod_actor") or "").replace("演员", ""), - "vod_director": (vod.get("vod_director") or "").replace("导演", ""), - "vod_year": (vod.get("vod_year") or "") + "年" if vod.get("vod_year") else "", - "vod_area": vod.get("vod_area", ""), - "vod_play_from": "$$$".join(l["display"] for l in lines), - "vod_play_url": "$$$".join(l["urls"] for l in lines), - }] - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/鲸鱼APP.py py/tests/test_鲸鱼APP.py -git commit -m "feat: add detailContent with line management" -``` - ---- - -### Task 7: searchContent - -**Files:** -- Modify: `py/鲸鱼APP.py` -- Test: `py/tests/test_鲸鱼APP.py` - -- [ ] **Step 1: Write failing test for searchContent** - -Append to test file: - -```python - def test_search_content_filters_and_maps_results(self): - search_data = { - "search_list": [ - {"vod_id": "1", "vod_name": "繁花", "vod_pic": "http://p1.jpg", - "vod_remarks": "更新中", "vod_class": "都市", "vod_year": "2024"}, - {"vod_id": "2", "vod_name": "花繁", "vod_pic": "http://p2.jpg", - "vod_remarks": "", "vod_class": "屏蔽预留", "vod_year": ""}, - ] - } - - self.spider._api_post = lambda ep, payload=None: search_data - self.spider.host = "http://test.com" - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(len(result["list"]), 1) - self.assertEqual(result["list"][0]["vod_name"], "繁花") - self.assertNotIn("pagecount", result) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP.TestJingyuSpider.test_search_content_filters_and_maps_results -v` -Expected: FAIL - -- [ ] **Step 3: Implement searchContent** - -Append to `py/鲸鱼APP.py` inside the class: - -```python - def _get_verify_code(self): - import uuid - try: - uid = str(uuid.uuid4()) - verify_url = f"{self.host}{self.api_path}/verify/create?key={uid}" - rsp = self.fetch(verify_url, headers={"User-Agent": self.ua}, - timeout=10, verify=False) - b64_img = base64.b64encode(rsp.content).decode("utf-8") - ocr_url = "http://154.222.22.188:9898/ocr/b64/text" - ocr_rsp = self.post(ocr_url, data=b64_img, - headers={"User-Agent": self.ua, "Content-Type": "text/plain"}, - timeout=10, verify=False) - code = (ocr_rsp.text or "").strip() - if not code: - return None - replacements = { - "y": "9", "口": "0", "q": "0", "u": "0", "o": "0", - ">": "1", "d": "0", "b": "8", "已": "2", "D": "0", "五": "5", - } - code = "".join(replacements.get(c, c) for c in code) - if not re.match(r"^\d{4}$", code): - return None - return {"uuid": uid, "code": code} - except Exception as e: - self.log(f"验证码获取失败: {e}") - return None - - def searchContent(self, key, quick, pg="1"): - self.init() - payload = {"keywords": key, "type_id": "0", "page": str(pg)} - if self.search_verify: - verify = self._get_verify_code() - if verify: - payload["code"] = verify["code"] - payload["key"] = verify["uuid"] - res = self._api_post(self.search_endpoint, payload) - if not res: - return {"list": [], "page": int(pg), "limit": 90, "total": 999999} - raw = res.get("search_list", []) - filtered = [i for i in raw if "屏蔽预留" not in (i.get("vod_class") or "").lower()] - kw = (key or "").strip().lower() - if kw: - filtered = [ - i for i in filtered - if kw in " ".join([ - i.get("vod_name", ""), - i.get("vod_remarks", ""), - i.get("vod_class", ""), - ]).lower() - ] - items = [{ - "vod_id": str(i.get("vod_id", "")), - "vod_name": i.get("vod_name", ""), - "vod_pic": i.get("vod_pic", ""), - "vod_remarks": f"{i.get('vod_year', '')} {i.get('vod_class', '')}".strip(), - } for i in filtered] - return {"list": items, "page": int(pg), "limit": 90, "total": 999999} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/鲸鱼APP.py py/tests/test_鲸鱼APP.py -git commit -m "feat: add searchContent with OCR verification and filtering" -``` - ---- - -### Task 8: playerContent with multi-mode parsing - -**Files:** -- Modify: `py/鲸鱼APP.py` -- Test: `py/tests/test_鲸鱼APP.py` - -- [ ] **Step 1: Write failing tests for playerContent parse modes** - -Append to test file: - -```python - def test_player_direct_parse_type_0(self): - play_id = "线路一@@direct@@http://parse.com,http://video.m3u8,token+t1,1,0" - result = self.spider.playerContent("线路一", play_id, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "http://video.m3u8") - - def test_player_prefix_parse_type_2(self): - play_id = "线路二@@direct@@http://parse.com,http://video.m3u8,token+t1,1,2" - result = self.spider.playerContent("线路二", play_id, {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["url"], "http://parse.comhttp://video.m3u8") - - def test_player_vod_parse_default(self): - play_id = "线路三@@direct@@http://parse.com,http://video.m3u8,token+t1,1,1" - inner = json.dumps({"url": "http://decrypted.m3u8"}) - encrypted_inner = self.spider._aes_encrypt(json.dumps({"json": inner})) - - class FakeRsp: - status_code = 200 - encoding = "utf-8" - def json(self): - return {"data": encrypted_inner} - - self.spider.post = lambda url, **kwargs: FakeRsp() - self.spider.host = "http://test.com" - result = self.spider.playerContent("线路三", play_id, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "http://decrypted.m3u8") - - def test_player_fetch_parse_player_parse_type_2(self): - play_id = "线路四@@direct@@,http://video.m3u8,token+t1,2,1" - self.spider.fetch = lambda url, **kwargs: type("R", (), { - "json": lambda s: {"url": "http://fetched.m3u8"} - })() - self.spider.host = "http://test.com" - result = self.spider.playerContent("线路四", play_id, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "http://fetched.m3u8") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP.TestJingyuSpider.test_player_direct_parse_type_0 -v` -Expected: FAIL - -- [ ] **Step 3: Implement playerContent** - -Append to `py/鲸鱼APP.py` inside the class: - -```python - def playerContent(self, flag, id, vipFlags): - try: - parts = id.split("@@") - if len(parts) < 3: - return {"parse": 0, "jx": 0, "url": ""} - line_name = parts[0] - mode = parts[1] - payload = parts[2] - - arr = payload.split(",") - if len(arr) < 5: - return {"parse": 0, "jx": 0, "url": ""} - - parse_api = arr[0] - kurl = arr[1] - token = arr[2].replace("token+", "") if arr[2].startswith("token+") else arr[2] - player_parse_type = arr[3] - parse_type = arr[4] - - try: - kurl = __import__("urllib.parse", fromlist=["unquote"]).unquote(kurl) - except Exception: - pass - - header = {"User-Agent": "Dalvik/2.1.0 (Linux; Android 14)"} - - # parse_type == '0': direct URL - if parse_type == "0": - return {"parse": 0, "jx": 0, "url": kurl, "header": header} - - # parse_type == '2': prefix URL - if parse_type == "2": - return {"parse": 1, "jx": 1, "url": parse_api + kurl, "header": header} - - # player_parse_type == '2': fetch parse_api+url - if player_parse_type == "2": - try: - rsp = self.fetch(parse_api + kurl, headers={"User-Agent": self.ua}, timeout=10, verify=False) - fetched = rsp.json() - if fetched.get("url"): - return {"parse": 0, "jx": 0, "url": fetched["url"]} - except Exception: - pass - - # default: AES encrypt + vodParse - encrypted_url = self._aes_encrypt(kurl) - res = self._api_post("vodParse", { - "parse_api": parse_api, - "url": encrypted_url, - "player_parse_type": player_parse_type, - "token": token, - }) - if not res or not res.get("json"): - return {"parse": 0, "jx": 0, "url": ""} - inner = json.loads(res["json"]) - return {"parse": 0, "jx": 0, "url": inner.get("url", "")} - except Exception as e: - self.log(f"播放解析失败: {e}") - return {"parse": 0, "jx": 0, "url": ""} -``` - -- [ ] **Step 4: Run all tests to verify they pass** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/鲸鱼APP.py py/tests/test_鲸鱼APP.py -git commit -m "feat: add playerContent with multi-mode play parsing" -``` - ---- - -### Task 9: Run full test suite and final verification - -**Files:** -- Verify: `py/鲸鱼APP.py` -- Verify: `py/tests/test_鲸鱼APP.py` - -- [ ] **Step 1: Run full project test suite to check for regressions** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest discover -v -s tests` -Expected: ALL PASS, no regressions in other spider tests - -- [ ] **Step 2: Run only 鲸鱼APP tests** - -Run: `cd /home/harold/workspace/tvbox-resources/py && python -m unittest tests.test_鲸鱼APP -v` -Expected: ALL PASS - -- [ ] **Step 3: Final commit if any fixes needed** - -```bash -git add -A -git commit -m "feat: finalize 鲸鱼APP spider" -``` diff --git a/py/docs/superpowers/plans/2026-04-20-shandian-spider.md b/py/docs/superpowers/plans/2026-04-20-shandian-spider.md deleted file mode 100644 index 0876ba9..0000000 --- a/py/docs/superpowers/plans/2026-04-20-shandian-spider.md +++ /dev/null @@ -1,714 +0,0 @@ -# 闪电 Spider 与聚合接入 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在当前 Python 仓库中新增独立单站 `闪电` 蜘蛛,并将其接入 `玩偶聚合`,支持分类、搜索、详情网盘线路整理和网盘透传。 - -**Architecture:** 单站实现采用与 `至臻` 相同的单文件 Spider 模式,在 `py/闪电.py` 中完成 URL 拼装、列表解析、详情字段提取和网盘线路组装。聚合层只补 `shandian` 站点配置和对应测试,复用现有 `玩偶聚合.py` 的搜索聚合、详情合并和网盘透传逻辑,不引入公共基类重构。 - -**Tech Stack:** Python 3, `unittest`, `unittest.mock`, `re`, `sys`, `urllib.parse`, `base.spider.Spider` - ---- - -## File Structure - -- Create: `py/闪电.py` - - 实现 `Spider` 类和站点全部逻辑 - - 暴露 `init`、`getName`、`homeContent`、`homeVideoContent`、`categoryContent`、`searchContent`、`detailContent`、`playerContent` -- Create: `py/tests/test_闪电.py` - - 使用 `SourceFileLoader` 加载 `py/闪电.py` - - 通过内联 HTML 与 `mock` 覆盖分类、搜索、详情、线路排序和透传 -- Modify: `py/玩偶聚合.py` - - 新增 `shandian` 站点配置 - - 复用聚合层现有 URL 构建和详情合并逻辑 -- Modify: `py/tests/test_玩偶聚合.py` - - 新增 `闪电` 首页、分类 URL、搜索 URL 和详情合并测试 - -### Task 1: Add Failing Tests For 闪电 Single Spider - -**Files:** -- Create: `py/tests/test_闪电.py` -- Create: `py/闪电.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("shandian_spider", str(ROOT / "闪电.py")).load_module() -Spider = MODULE.Spider - - -class TestShanDianSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_all_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("1", "闪电电影"), - ("2", "闪电剧集"), - ("3", "闪电综艺"), - ("4", "闪电动漫"), - ("30", "闪电短剧"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_url_and_detect_pan_type(self): - self.assertEqual( - self.spider._build_url("/index.php/vod/detail/id/1.html"), - "https://sd.sduc.site/index.php/vod/detail/id/1.html", - ) - self.assertEqual(self.spider._detect_pan_type("https://pan.baidu.com/s/demo"), ("baidu", "百度资源")) - self.assertEqual(self.spider._detect_pan_type("https://pan.quark.cn/s/demo"), ("quark", "夸克资源")) - self.assertEqual(self.spider._detect_pan_type("https://example.com/video"), ("", "")) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_闪电.TestShanDianSpider -v` -Expected: FAIL with `FileNotFoundError` for `闪电.py` or missing `Spider` attributes. - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import re -import sys -from urllib.parse import urljoin - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "闪电" - self.host = "https://sd.sduc.site" - self.headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/136.0.0.0 Safari/537.36" - ), - "Referer": self.host + "/", - } - self.categories = [ - {"type_id": "1", "type_name": "闪电电影"}, - {"type_id": "2", "type_name": "闪电剧集"}, - {"type_id": "3", "type_name": "闪电综艺"}, - {"type_id": "4", "type_name": "闪电动漫"}, - {"type_id": "30", "type_name": "闪电短剧"}, - ] - self.pan_patterns = [ - ("baidu", "百度资源", r"pan\.baidu\.com|yun\.baidu\.com"), - ("a139", "139资源", r"yun\.139\.com"), - ("a189", "天翼资源", r"cloud\.189\.cn"), - ("a123", "123资源", r"123684\.com|123865\.com|123912\.com|123pan\.com"), - ("a115", "115资源", r"115\.com"), - ("quark", "夸克资源", r"pan\.quark\.cn"), - ("xunlei", "迅雷资源", r"pan\.xunlei\.com"), - ("aliyun", "阿里资源", r"aliyundrive\.com|alipan\.com"), - ("uc", "UC资源", r"drive\.uc\.cn"), - ] - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.categories} - - def homeVideoContent(self): - return {"list": []} - - def _build_url(self, path): - return urljoin(self.host + "/", str(path or "").strip()) - - def _detect_pan_type(self, url): - raw = str(url or "").strip() - for pan_type, title, pattern in self.pan_patterns: - if re.search(pattern, raw, re.I): - return pan_type, title - return "", "" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_闪电.TestShanDianSpider -v` -Expected: PASS for the scaffold tests. - -- [ ] **Step 5: Commit** - -```bash -git add py/闪电.py py/tests/test_闪电.py -git commit -m "feat: scaffold shandian spider" -``` - -### Task 2: Add 闪电 List, Search, Detail, And Player Coverage - -**Files:** -- Modify: `py/tests/test_闪电.py` -- Modify: `py/闪电.py` - -- [ ] **Step 1: Write the failing test** - -```python - def test_parse_cards_extracts_short_path_ids(self): - html = """ -
-
-
- - 示例影片 -
-
HD
-
2025
-
-
- """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/index.php/vod/detail/id/123.html", - "vod_name": "示例影片", - "vod_pic": "https://sd.sduc.site/poster.jpg", - "vod_remarks": "HD", - "vod_year": "2025", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_reference_url_and_returns_page_payload(self, mock_request_html): - mock_request_html.return_value = """ -
-
-
- - 分类影片 -
-
更新至10集
-
2024
-
-
- """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://sd.sduc.site/index.php/vod/show/id/2/page/3.html", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - - @patch.object(Spider, "_request_html") - def test_search_content_builds_reference_search_url_and_parses_results(self, mock_request_html): - mock_request_html.return_value = """ -
- 抢先版 -
- 搜索影片 -
-
- """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://sd.sduc.site/index.php/vod/search/page/2/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual(result["list"][0]["vod_id"], "/index.php/vod/detail/id/789.html") - - def test_build_pan_lines_deduplicates_and_sorts_supported_links(self): - detail = {"pan_urls": ["https://pan.quark.cn/s/q1", "https://pan.baidu.com/s/b1", "https://pan.baidu.com/s/b1"]} - self.assertEqual( - self.spider._build_pan_lines(detail), - [ - ("baidu#闪电", "百度资源$https://pan.baidu.com/s/b1"), - ("quark#闪电", "夸克资源$https://pan.quark.cn/s/q1"), - ], - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_闪电.TestShanDianSpider -v` -Expected: FAIL with missing `_parse_cards`, `categoryContent`, `searchContent`, or `_build_pan_lines`. - -- [ ] **Step 3: Write minimal implementation** - -```python -from urllib.parse import quote - - - self.pan_priority = { - "baidu": 1, - "a139": 2, - "a189": 3, - "a123": 4, - "a115": 5, - "quark": 6, - "xunlei": 7, - "aliyun": 8, - "uc": 9, - } - - 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=10) - if response.status_code != 200: - return "" - return response.text or "" - - def _page_result(self, items, pg): - page = int(pg) - return {"page": page, "limit": len(items), "total": page * 20 + len(items), "list": items} - - def _parse_cards(self, html): - root = self.html(html) - if root is None: - return [] - items = [] - seen = set() - for node in root.xpath("//*[@id='main']//*[contains(@class,'module-item')]"): - href = "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//a[1]/@href")).strip() - title = "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@alt")).strip() - pic = ( - "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@data-src")).strip() - or "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@src")).strip() - ) - remarks = self._clean_text("".join(node.xpath(".//*[contains(@class,'module-item-text')][1]//text()"))) - year = self._clean_text("".join(node.xpath(".//*[contains(@class,'module-item-caption')][1]//span[1]//text()"))) - if not href or not title or href in seen: - continue - seen.add(href) - items.append( - { - "vod_id": href, - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_remarks": remarks, - "vod_year": year, - } - ) - return items - - def categoryContent(self, tid, pg, filter, extend): - url = self._build_url(f"/index.php/vod/show/id/{tid}/page/{int(pg)}.html") - return self._page_result(self._parse_cards(self._request_html(url)), pg) - - def searchContent(self, key, quick, pg="1"): - keyword = self._clean_text(key) - page = int(pg) - if not keyword: - return {"page": page, "total": 0, "list": []} - url = self._build_url(f"/index.php/vod/search/page/{page}/wd/{quote(keyword)}.html") - root = self.html(self._request_html(url)) - if root is None: - return {"page": page, "total": 0, "list": []} - items = [] - for node in root.xpath("//*[contains(@class,'module-search-item')]"): - href = "".join(node.xpath(".//*[contains(@class,'video-serial')][1]/@href")).strip() - title = "".join(node.xpath(".//*[contains(@class,'video-serial')][1]/@title")).strip() - pic = ( - "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@data-src")).strip() - or "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@src")).strip() - ) - remarks = self._clean_text( - "".join(node.xpath(".//*[contains(@class,'video-serial')][1]//text()")) - or "".join(node.xpath(".//*[contains(@class,'module-item-text')][1]//text()")) - ) - if not href or not title: - continue - items.append({"vod_id": href, "vod_name": title, "vod_pic": self._build_url(pic), "vod_remarks": remarks}) - return {"page": page, "total": len(items), "list": items} - - def _build_pan_lines(self, detail): - lines = [] - seen = set() - for url in detail.get("pan_urls", []): - pan_type, title = self._detect_pan_type(url) - if not pan_type or url in seen: - continue - seen.add(url) - lines.append((self.pan_priority.get(pan_type, 999), f"{pan_type}#闪电", f"{title}${url}")) - lines.sort(key=lambda item: item[0]) - return [(item[1], item[2]) for item in lines] -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_闪电.TestShanDianSpider -v` -Expected: PASS for list, search, and line-building tests. - -- [ ] **Step 5: Commit** - -```bash -git add py/闪电.py py/tests/test_闪电.py -git commit -m "feat: add shandian list and search parsing" -``` - -### Task 3: Add 闪电 Detail And Player Behavior - -**Files:** -- Modify: `py/tests/test_闪电.py` -- Modify: `py/闪电.py` - -- [ ] **Step 1: Write the failing test** - -```python - def test_parse_detail_page_extracts_meta_content_and_pan_urls(self): - html = """ -
示例剧
-
-
年代
2024
-
导演
导演甲
-
主演
演员甲演员乙
-
剧情

一段剧情简介

-
-

https://pan.quark.cn/s/q1

-

https://pan.baidu.com/s/b1

-
- """ - detail = self.spider._parse_detail_page("/index.php/vod/detail/id/123.html", html) - self.assertEqual(detail["vod_name"], "示例剧") - self.assertEqual(detail["vod_pic"], "https://sd.sduc.site/poster.jpg") - self.assertEqual(detail["vod_year"], "2024") - self.assertEqual(detail["vod_director"], "导演甲") - self.assertEqual(detail["vod_actor"], "演员甲,演员乙") - self.assertEqual(detail["vod_content"], "一段剧情简介") - self.assertEqual(detail["pan_urls"], ["https://pan.quark.cn/s/q1", "https://pan.baidu.com/s/b1"]) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_pan_play_fields(self, mock_request_html): - mock_request_html.return_value = """ -
示例剧
-
-
年代
2024
-
导演
导演甲
-
主演
演员甲
-
剧情

一段剧情简介

-
-

https://pan.quark.cn/s/q1

-

https://pan.baidu.com/s/b1

-
- """ - result = self.spider.detailContent(["/index.php/vod/detail/id/123.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_play_from"], "baidu#闪电$$$quark#闪电") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/q1", - ) - - def test_player_content_passthroughs_supported_pan_urls(self): - self.assertEqual( - self.spider.playerContent("baidu#闪电", "https://pan.baidu.com/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.baidu.com/s/demo"}, - ) - - def test_player_content_rejects_non_pan_url(self): - self.assertEqual( - self.spider.playerContent("site", "/index.php/vod/play/id/1.html", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_闪电.TestShanDianSpider -v` -Expected: FAIL with missing detail parser, `detailContent`, or `playerContent`. - -- [ ] **Step 3: Write minimal implementation** - -```python - def _parse_detail_page(self, vod_id, html): - root = self.html(html) - if root is None: - return { - "vod_id": vod_id, - "vod_name": "", - "vod_pic": "", - "vod_year": "", - "vod_director": "", - "vod_actor": "", - "vod_content": "", - "pan_urls": [], - } - detail = { - "vod_id": vod_id, - "vod_name": self._clean_text("".join(root.xpath("//*[contains(@class,'page-title')][1]//text()"))), - "vod_pic": self._build_url( - "".join( - root.xpath( - "//*[contains(@class,'mobile-play')]//*[contains(@class,'lazyload')][1]/@data-src | " - "//*[contains(@class,'mobile-play')]//*[contains(@class,'lazyload')][1]/@src" - ) - ).strip() - ), - "vod_year": "", - "vod_director": "", - "vod_actor": "", - "vod_content": "", - "pan_urls": [], - } - for label_node in root.xpath("//*[contains(@class,'video-info-itemtitle')]"): - key = self._clean_text("".join(label_node.xpath(".//text()"))) - sibling = label_node.getnext() - if sibling is None: - continue - values = [self._clean_text(text) for text in sibling.xpath(".//a//text()")] - joined = ",".join([value for value in values if value]) - text_value = self._clean_text("".join(sibling.xpath(".//text()"))) - if "年代" in key: - detail["vod_year"] = joined or text_value - elif "导演" in key: - detail["vod_director"] = joined or text_value - elif "主演" in key: - detail["vod_actor"] = joined or text_value - elif "剧情" in key: - detail["vod_content"] = text_value - for node in root.xpath("//*[contains(@class,'module-row-info')]//p"): - text = self._clean_text("".join(node.xpath(".//text()"))) - if text: - detail["pan_urls"].append(text) - return detail - - def detailContent(self, ids): - result = {"list": []} - for raw_id in ids: - vod_id = str(raw_id or "").strip() - detail = self._parse_detail_page(vod_id, self._request_html(self._build_url(vod_id))) - lines = self._build_pan_lines(detail) - result["list"].append( - { - "vod_id": vod_id, - "vod_name": detail["vod_name"], - "vod_pic": detail["vod_pic"], - "vod_year": detail["vod_year"], - "vod_director": detail["vod_director"], - "vod_actor": detail["vod_actor"], - "vod_content": detail["vod_content"], - "vod_play_from": "$$$".join([item[0] for item in lines]), - "vod_play_url": "$$$".join([item[1] for item in lines]), - } - ) - return result - - def playerContent(self, flag, id, vipFlags): - pan_type, _ = self._detect_pan_type(id) - if pan_type: - return {"parse": 0, "playUrl": "", "url": id} - return {"parse": 0, "playUrl": "", "url": ""} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_闪电.TestShanDianSpider -v` -Expected: PASS for detail and player tests. - -- [ ] **Step 5: Commit** - -```bash -git add py/闪电.py py/tests/test_闪电.py -git commit -m "feat: add shandian detail and player support" -``` - -### Task 4: Add 玩偶聚合 Failing Tests For 闪电 - -**Files:** -- Modify: `py/tests/test_玩偶聚合.py` -- Modify: `py/玩偶聚合.py` - -- [ ] **Step 1: Write the failing test** - -```python - def test_home_content_exposes_shandian_site_and_categories(self): - content = self.spider.homeContent(False) - self.assertIn("site_shandian", [item["type_id"] for item in content["class"]]) - self.assertEqual( - content["filters"]["site_shandian"][0]["value"][1:], - [ - {"n": "电影", "v": "1"}, - {"n": "剧集", "v": "2"}, - {"n": "综艺", "v": "3"}, - {"n": "动漫", "v": "4"}, - {"n": "短剧", "v": "30"}, - ], - ) - - @patch.object(Spider, "_request_with_failover") - def test_fetch_site_search_builds_shandian_search_url_and_parses_results(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
- 抢先版 -
闪电影片
-
- """ - site = self.spider._get_site("shandian") - results = self.spider._fetch_site_search(site, "繁花", 1) - self.assertEqual( - mock_request_with_failover.call_args.args[1], - "/index.php/vod/search/page/1/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual(results[0]["vod_id"], "site:shandian:/index.php/vod/detail/id/789.html") - - @patch.object(Spider, "_request_with_failover") - def test_category_content_builds_shandian_category_url(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
-
- - 闪电分类片 -
-
HD
-
- """ - result = self.spider.categoryContent("site_shandian", "2", False, {"categoryId": "30"}) - self.assertEqual( - mock_request_with_failover.call_args.args[1], - "https://sd.sduc.site/index.php/vod/show/id/30/page/2.html", - ) - self.assertEqual(result["list"][0]["vod_id"], "site:shandian:/index.php/vod/detail/id/456.html") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_玩偶聚合.TestWanouAggregateSpider.test_home_content_exposes_shandian_site_and_categories tests.test_玩偶聚合.TestWanouAggregateSpider.test_fetch_site_search_builds_shandian_search_url_and_parses_results tests.test_玩偶聚合.TestWanouAggregateSpider.test_category_content_builds_shandian_category_url -v` -Expected: FAIL because `shandian` is not yet present in `self.sites`. - -- [ ] **Step 3: Write minimal implementation** - -```python - { - "id": "shandian", - "name": "闪电", - "domains": ["https://sd.sduc.site"], - "filter_files": [], - "list_xpath": "//*[contains(@class,'module-item')]", - "search_xpath": "//*[contains(@class,'module-search-item')]", - "detail_pan_xpath": "//*[contains(@class,'module-row-info')]//p", - "category_url": "/index.php/vod/show/id/{categoryId}/page/{page}.html", - "search_url": "/index.php/vod/search/page/{page}/wd/{keyword}.html", - "default_categories": [("1", "电影"), ("2", "剧集"), ("3", "综艺"), ("4", "动漫"), ("30", "短剧")], - }, -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_玩偶聚合.TestWanouAggregateSpider.test_home_content_exposes_shandian_site_and_categories tests.test_玩偶聚合.TestWanouAggregateSpider.test_fetch_site_search_builds_shandian_search_url_and_parses_results tests.test_玩偶聚合.TestWanouAggregateSpider.test_category_content_builds_shandian_category_url -v` -Expected: PASS with `site_shandian` visible and both URLs using `/index.php/vod/...`. - -- [ ] **Step 5: Commit** - -```bash -git add py/玩偶聚合.py py/tests/test_玩偶聚合.py -git commit -m "feat: add shandian aggregate site config" -``` - -### Task 5: Verify 闪电 Detail Merge In 聚合层 - -**Files:** -- Modify: `py/tests/test_玩偶聚合.py` -- Modify: `py/玩偶聚合.py` - -- [ ] **Step 1: Write the failing test** - -```python - @patch.object(Spider, "_fetch_site_detail") - def test_detail_content_for_aggregate_id_merges_shandian_pan_lines(self, mock_fetch_site_detail): - mock_fetch_site_detail.side_effect = [ - { - "vod_name": "繁花", - "vod_pic": "https://img.example/w.jpg", - "vod_year": "2024", - "vod_director": "导演甲", - "vod_actor": "演员甲", - "vod_content": "玩偶简介", - "pan_urls": ["https://pan.baidu.com/s/b1"], - "_site_name": "玩偶", - }, - { - "vod_name": "繁花", - "vod_pic": "https://sd.sduc.site/poster.jpg", - "vod_year": "2024", - "vod_director": "导演乙", - "vod_actor": "演员乙", - "vod_content": "闪电简介", - "pan_urls": ["https://pan.quark.cn/s/s1", "https://pan.baidu.com/s/b1"], - "_site_name": "闪电", - }, - ] - payload = [ - {"site": "wanou", "path": "/voddetail/1.html", "name": "繁花", "year": "2024"}, - {"site": "shandian", "path": "/index.php/vod/detail/id/2.html", "name": "繁花", "year": "2024"}, - ] - result = self.spider.detailContent([self.spider._encode_aggregate_vod_id(payload)]) - vod = result["list"][0] - self.assertEqual(vod["vod_play_from"], "baidu#玩偶$$$quark#闪电") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/s1", - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_玩偶聚合.TestWanouAggregateSpider.test_detail_content_for_aggregate_id_merges_shandian_pan_lines -v` -Expected: FAIL before `shandian` config exists because `_get_site("shandian")` cannot resolve. - -- [ ] **Step 3: Write minimal implementation** - -```python - { - "id": "shandian", - "name": "闪电", - "domains": ["https://sd.sduc.site"], - "filter_files": [], - "list_xpath": "//*[contains(@class,'module-item')]", - "search_xpath": "//*[contains(@class,'module-search-item')]", - "detail_pan_xpath": "//*[contains(@class,'module-row-info')]//p", - "category_url": "/index.php/vod/show/id/{categoryId}/page/{page}.html", - "search_url": "/index.php/vod/search/page/{page}/wd/{keyword}.html", - "default_categories": [("1", "电影"), ("2", "剧集"), ("3", "综艺"), ("4", "动漫"), ("30", "短剧")], - }, -``` - -这一步不需要改动详情合并逻辑本身,只要保证 `shandian` 配置可被复用的聚合流程消费。 - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_玩偶聚合.TestWanouAggregateSpider.test_detail_content_for_aggregate_id_merges_shandian_pan_lines -v` -Expected: PASS and重复百度链接被去重,只保留 `闪电` 的夸克线路增量。 - -- [ ] **Step 5: Commit** - -```bash -git add py/玩偶聚合.py py/tests/test_玩偶聚合.py -git commit -m "test: cover shandian aggregate detail merge" -``` - -## Self-Review - -- Spec coverage: 单站分类、搜索、详情、播放透传,以及聚合层首页、分类 URL、搜索 URL、详情合并都覆盖到了,没有遗漏。 -- Placeholder scan: 文件路径、测试名、命令、代码片段和期望结果都已具体化,没有保留 TBD 或泛化描述。 -- Type consistency: 全程使用 `闪电` / `shandian` / `site_shandian` / `site:shandian:` 和 `https://sd.sduc.site`,命名保持一致。 diff --git a/py/docs/superpowers/plans/2026-04-20-wanou-aggregate-zhizhen.md b/py/docs/superpowers/plans/2026-04-20-wanou-aggregate-zhizhen.md deleted file mode 100644 index 450b206..0000000 --- a/py/docs/superpowers/plans/2026-04-20-wanou-aggregate-zhizhen.md +++ /dev/null @@ -1,289 +0,0 @@ -# 玩偶聚合接入至臻 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在现有 `玩偶聚合` 蜘蛛中正式接入 `至臻` 站点,使其参与首页展示、分类访问、搜索聚合和详情网盘线路合并。 - -**Architecture:** 继续沿用 `py/玩偶聚合.py` 的配置驱动结构,不新增共享基类,只补 `zhizhen` 站点配置并扩充测试覆盖。实现顺序遵循 TDD:先写失败测试锁定首页、搜索 URL 和详情合并行为,再做最小配置改动直至 `tests.test_玩偶聚合` 转绿。 - -**Tech Stack:** Python 3, `unittest`, `unittest.mock`, `base.spider.Spider`, `re`, `json`, `base64` - ---- - -## File Structure - -- Modify: `py/玩偶聚合.py` - - 在 `self.sites` 中新增 `zhizhen` 站点配置 - - 复用现有聚合逻辑,不改编码格式和公共 helper 接口 -- Modify: `py/tests/test_玩偶聚合.py` - - 新增 `site_zhizhen` 首页暴露测试 - - 新增 `zhizhen` 搜索 URL 与结果编码测试 - - 新增聚合详情合并 `至臻` 网盘线路测试 - -### Task 1: Lock HomeContent And Search Expectations For 至臻 - -**Files:** -- Modify: `py/tests/test_玩偶聚合.py` -- Modify: `py/玩偶聚合.py` - -- [ ] **Step 1: Write the failing test** - -```python - def test_home_content_exposes_zhizhen_site_and_categories(self): - content = self.spider.homeContent(False) - type_ids = [item["type_id"] for item in content["class"]] - self.assertIn("site_zhizhen", type_ids) - self.assertEqual( - content["filters"]["site_zhizhen"][0]["value"][1:], - [ - {"n": "电影", "v": "1"}, - {"n": "剧集", "v": "2"}, - {"n": "动漫", "v": "3"}, - {"n": "综艺", "v": "4"}, - {"n": "短剧", "v": "5"}, - {"n": "老剧", "v": "24"}, - {"n": "严选", "v": "26"}, - ], - ) - - @patch.object(Spider, "_request_with_failover") - def test_fetch_site_search_builds_zhizhen_search_url_and_parses_results(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
- 抢先版 -
至臻影片
-
- """ - site = self.spider._get_site("zhizhen") - results = self.spider._fetch_site_search(site, "繁花", 1) - self.assertEqual( - mock_request_with_failover.call_args.args[1], - "/index.php/vod/search/page/1/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual( - results[0], - { - "vod_id": "site:zhizhen:/index.php/vod/detail/id/789.html", - "vod_name": "至臻影片", - "vod_pic": "http://www.miqk.cc/search.jpg", - "vod_remarks": "", - "vod_year": "", - "_site": "zhizhen", - "_detail_path": "/index.php/vod/detail/id/789.html", - }, - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_玩偶聚合.TestWanouAggregateSpider.test_home_content_exposes_zhizhen_site_and_categories tests.test_玩偶聚合.TestWanouAggregateSpider.test_fetch_site_search_builds_zhizhen_search_url_and_parses_results -v` -Expected: FAIL because `site_zhizhen` is absent and `_get_site("zhizhen")` returns `None`. - -- [ ] **Step 3: Write minimal implementation** - -```python - { - "id": "zhizhen", - "name": "至臻", - "domains": ["http://www.miqk.cc"], - "filter_files": [], - "list_xpath": "//*[contains(@class,'module-item')]", - "search_xpath": "//*[contains(@class,'module-search-item')]", - "detail_pan_xpath": "//*[contains(@class,'module-row-info')]//p", - "category_url": "/index.php/vod/show/id/{categoryId}/page/{page}.html", - "search_url": "/index.php/vod/search/page/{page}/wd/{keyword}.html", - "default_categories": [ - ("1", "电影"), - ("2", "剧集"), - ("3", "动漫"), - ("4", "综艺"), - ("5", "短剧"), - ("24", "老剧"), - ("26", "严选"), - ], - }, -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_玩偶聚合.TestWanouAggregateSpider.test_home_content_exposes_zhizhen_site_and_categories tests.test_玩偶聚合.TestWanouAggregateSpider.test_fetch_site_search_builds_zhizhen_search_url_and_parses_results -v` -Expected: PASS with `site_zhizhen` visible and search URL/path matching the `miqk` rule. - -- [ ] **Step 5: Commit** - -```bash -git add py/玩偶聚合.py py/tests/test_玩偶聚合.py -git commit -m "feat: add zhizhen aggregate site config" -``` - -### Task 2: Lock Detail Merge Behavior For 至臻 Lines - -**Files:** -- Modify: `py/tests/test_玩偶聚合.py` -- Modify: `py/玩偶聚合.py` - -- [ ] **Step 1: Write the failing test** - -```python - @patch.object(Spider, "_fetch_site_detail") - def test_detail_content_for_aggregate_id_merges_zhizhen_pan_lines(self, mock_fetch_site_detail): - mock_fetch_site_detail.side_effect = [ - { - "vod_name": "繁花", - "vod_pic": "https://img.example/w.jpg", - "vod_year": "2024", - "vod_director": "导演甲", - "vod_actor": "演员甲", - "vod_content": "玩偶简介", - "pan_urls": ["https://pan.baidu.com/s/b1"], - "_site_name": "玩偶", - }, - { - "vod_name": "繁花", - "vod_pic": "http://www.miqk.cc/poster.jpg", - "vod_year": "2024", - "vod_director": "导演乙", - "vod_actor": "演员乙", - "vod_content": "至臻简介", - "pan_urls": ["https://pan.quark.cn/s/z1", "https://pan.baidu.com/s/b1"], - "_site_name": "至臻", - }, - ] - payload = [ - {"site": "wanou", "path": "/voddetail/1.html", "name": "繁花", "year": "2024"}, - {"site": "zhizhen", "path": "/index.php/vod/detail/id/2.html", "name": "繁花", "year": "2024"}, - ] - result = self.spider.detailContent([self.spider._encode_aggregate_vod_id(payload)]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "繁花") - self.assertEqual(vod["vod_play_from"], "baidu#玩偶$$$quark#至臻") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/z1", - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_玩偶聚合.TestWanouAggregateSpider.test_detail_content_for_aggregate_id_merges_zhizhen_pan_lines -v` -Expected: FAIL before Task 1 is implemented because `zhizhen` site lookup is missing or detail merge path cannot resolve it. - -- [ ] **Step 3: Write minimal implementation** - -```python - self.sites = [ - # existing wanou / muou / labi configs... - { - "id": "zhizhen", - "name": "至臻", - "domains": ["http://www.miqk.cc"], - "filter_files": [], - "list_xpath": "//*[contains(@class,'module-item')]", - "search_xpath": "//*[contains(@class,'module-search-item')]", - "detail_pan_xpath": "//*[contains(@class,'module-row-info')]//p", - "category_url": "/index.php/vod/show/id/{categoryId}/page/{page}.html", - "search_url": "/index.php/vod/search/page/{page}/wd/{keyword}.html", - "default_categories": [ - ("1", "电影"), - ("2", "剧集"), - ("3", "动漫"), - ("4", "综艺"), - ("5", "短剧"), - ("24", "老剧"), - ("26", "严选"), - ], - }, - ] -``` - -这一步不需要再改 `detailContent` 逻辑,只要确保 `zhizhen` 站点能被 `_get_site` 和 `_fetch_site_detail` 走通即可。 - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_玩偶聚合.TestWanouAggregateSpider.test_detail_content_for_aggregate_id_merges_zhizhen_pan_lines -v` -Expected: PASS and duplicate百度链接被去重,只保留 `至臻` 的夸克线路增量。 - -- [ ] **Step 5: Commit** - -```bash -git add py/玩偶聚合.py py/tests/test_玩偶聚合.py -git commit -m "test: cover zhizhen aggregate detail merge" -``` - -### Task 3: Run Full 玩偶聚合 Verification - -**Files:** -- Modify: `py/tests/test_玩偶聚合.py` -- Modify: `py/玩偶聚合.py` - -- [ ] **Step 1: Write the failing test** - -```python - @patch.object(Spider, "_request_with_failover") - def test_category_content_builds_zhizhen_category_url(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
-
- - 至臻分类片 -
-
HD
-
- """ - result = self.spider.categoryContent("site_zhizhen", "2", False, {"categoryId": "24"}) - self.assertEqual( - mock_request_with_failover.call_args.args[1], - "http://www.miqk.cc/index.php/vod/show/id/24/page/2.html", - ) - self.assertEqual(result["list"][0]["vod_id"], "site:zhizhen:/index.php/vod/detail/id/456.html") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_玩偶聚合.TestWanouAggregateSpider.test_category_content_builds_zhizhen_category_url -v` -Expected: FAIL before the new config is in place because `site_zhizhen` cannot resolve. - -- [ ] **Step 3: Write minimal implementation** - -```python - { - "id": "zhizhen", - "name": "至臻", - "domains": ["http://www.miqk.cc"], - "filter_files": [], - "list_xpath": "//*[contains(@class,'module-item')]", - "search_xpath": "//*[contains(@class,'module-search-item')]", - "detail_pan_xpath": "//*[contains(@class,'module-row-info')]//p", - "category_url": "/index.php/vod/show/id/{categoryId}/page/{page}.html", - "search_url": "/index.php/vod/search/page/{page}/wd/{keyword}.html", - "default_categories": [ - ("1", "电影"), - ("2", "剧集"), - ("3", "动漫"), - ("4", "综艺"), - ("5", "短剧"), - ("24", "老剧"), - ("26", "严选"), - ], - }, -``` - -这一步依旧是复用 Task 1 的配置,目标是用完整模块测试证明没有遗漏分类 URL 规则。 - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_玩偶聚合.TestWanouAggregateSpider.test_category_content_builds_zhizhen_category_url -v` -Expected: PASS and category URL 使用 `/index.php/vod/show/id//page/.html`。 - -- [ ] **Step 5: Commit** - -```bash -git add py/玩偶聚合.py py/tests/test_玩偶聚合.py -git commit -m "test: verify zhizhen aggregate category url" -``` - -## Self-Review - -- Spec coverage: 首页暴露、搜索 URL、分类 URL、详情合并都对应了独立任务,没有遗漏 `至臻` 在聚合层的关键入口。 -- Placeholder scan: 已给出精确文件路径、测试名、命令、站点配置代码和期望输出,没有保留 TBD 或泛化描述。 -- Type consistency: 全程使用 `site_zhizhen`、`site:zhizhen:`、`zhizhen` 站点 ID 和 `http://www.miqk.cc` 域名,命名保持一致。 diff --git a/py/docs/superpowers/plans/2026-04-20-zhizhen-spider.md b/py/docs/superpowers/plans/2026-04-20-zhizhen-spider.md deleted file mode 100644 index 47cb02f..0000000 --- a/py/docs/superpowers/plans/2026-04-20-zhizhen-spider.md +++ /dev/null @@ -1,571 +0,0 @@ -# 至臻 Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在当前 Python 仓库中新增一个独立单站 `至臻` 蜘蛛,支持固定分类、分类列表、搜索、详情页网盘线路整理和网盘分享链接透传。 - -**Architecture:** 采用单文件站点脚本 `py/至臻.py` 承担全部站点逻辑,内部拆分为 URL 组装、文本清洗、请求封装、卡片解析、详情字段提取、网盘类型识别和线路拼接几个 helper。测试沿用现有 `unittest + SourceFileLoader + mock` 风格,先写失败测试锁定分类、解析和透传行为,再实现最小代码直到模块测试转绿。 - -**Tech Stack:** Python 3, `unittest`, `unittest.mock`, `re`, `sys`, `urllib.parse`, `base.spider.Spider` - ---- - -## File Structure - -- Create: `py/至臻.py` - - 实现 `Spider` 类和站点全部逻辑 - - 暴露 `init`、`getName`、`homeContent`、`homeVideoContent`、`categoryContent`、`searchContent`、`detailContent`、`playerContent` - - 私有方法负责 URL 拼装、文本清洗、请求、卡片解析、详情字段提取、网盘识别和线路组装 -- Create: `py/tests/test_至臻.py` - - 使用 `SourceFileLoader` 加载 `py/至臻.py` - - 通过内联 HTML 与 `mock` 覆盖分类、搜索、详情、线路排序和透传 - -### Task 1: Scaffold Spider And Pan Detection - -**Files:** -- Create: `py/tests/test_至臻.py` -- Create: `py/至臻.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("zhizhen_spider", str(ROOT / "至臻.py")).load_module() -Spider = MODULE.Spider - - -class TestZhiZhenSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_all_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("1", "至臻电影"), - ("2", "至臻剧集"), - ("3", "至臻动漫"), - ("4", "至臻综艺"), - ("5", "至臻短剧"), - ("24", "至臻老剧"), - ("26", "至臻严选"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_url_and_detect_pan_type(self): - self.assertEqual( - self.spider._build_url("/index.php/vod/detail/id/1.html"), - "http://www.miqk.cc/index.php/vod/detail/id/1.html", - ) - self.assertEqual( - self.spider._detect_pan_type("https://pan.baidu.com/s/demo"), - ("baidu", "百度资源"), - ) - self.assertEqual( - self.spider._detect_pan_type("https://example.com/video"), - ("", ""), - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_至臻.TestZhiZhenSpider -v` -Expected: FAIL with `FileNotFoundError` for `至臻.py` or missing `Spider` attributes. - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import re -import sys -from urllib.parse import urljoin - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "至臻" - self.host = "http://www.miqk.cc" - self.headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/136.0.0.0 Safari/537.36" - ), - "Referer": self.host + "/", - } - self.categories = [ - {"type_id": "1", "type_name": "至臻电影"}, - {"type_id": "2", "type_name": "至臻剧集"}, - {"type_id": "3", "type_name": "至臻动漫"}, - {"type_id": "4", "type_name": "至臻综艺"}, - {"type_id": "5", "type_name": "至臻短剧"}, - {"type_id": "24", "type_name": "至臻老剧"}, - {"type_id": "26", "type_name": "至臻严选"}, - ] - self.pan_patterns = [ - ("baidu", "百度资源", r"pan\.baidu\.com|yun\.baidu\.com"), - ("a139", "139资源", r"yun\.139\.com"), - ("a189", "天翼资源", r"cloud\.189\.cn"), - ("a123", "123资源", r"123684\.com|123865\.com|123912\.com|123pan\.com"), - ("a115", "115资源", r"115\.com"), - ("quark", "夸克资源", r"pan\.quark\.cn"), - ("xunlei", "迅雷资源", r"pan\.xunlei\.com"), - ("aliyun", "阿里资源", r"aliyundrive\.com|alipan\.com"), - ("uc", "UC资源", r"drive\.uc\.cn"), - ] - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.categories} - - def homeVideoContent(self): - return {"list": []} - - def _build_url(self, path): - return urljoin(self.host + "/", str(path or "").strip()) - - def _detect_pan_type(self, url): - raw = str(url or "").strip() - for pan_type, title, pattern in self.pan_patterns: - if re.search(pattern, raw, re.I): - return pan_type, title - return "", "" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_至臻.TestZhiZhenSpider -v` -Expected: PASS for the scaffold tests. - -- [ ] **Step 5: Commit** - -```bash -git add py/tests/test_至臻.py py/至臻.py -git commit -m "feat: scaffold zhizhen spider" -``` - -### Task 2: Add Category And Search Parsing - -**Files:** -- Modify: `py/tests/test_至臻.py` -- Modify: `py/至臻.py` - -- [ ] **Step 1: Write the failing test** - -```python -from unittest.mock import patch - - -class TestZhiZhenSpider(unittest.TestCase): - def test_parse_cards_extracts_short_path_ids(self): - html = """ -
-
-
- - 示例影片 -
-
HD
-
2025
-
-
- """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/index.php/vod/detail/id/123.html", - "vod_name": "示例影片", - "vod_pic": "http://www.miqk.cc/poster.jpg", - "vod_remarks": "HD", - "vod_year": "2025", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_reference_url_and_returns_page_payload(self, mock_request_html): - mock_request_html.return_value = """ -
-
-
- - 分类影片 -
-
更新至10集
-
2024
-
-
- """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "http://www.miqk.cc/index.php/vod/show/id/2/page/3.html", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_builds_reference_search_url_and_parses_results(self, mock_request_html): - mock_request_html.return_value = """ -
- 抢先版 -
- 搜索影片 -
-
- """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "http://www.miqk.cc/index.php/vod/search/page/2/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual( - result["list"][0], - { - "vod_id": "/index.php/vod/detail/id/789.html", - "vod_name": "搜索影片", - "vod_pic": "http://www.miqk.cc/search.jpg", - "vod_remarks": "抢先版", - }, - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_至臻.TestZhiZhenSpider -v` -Expected: FAIL with missing `_parse_cards` or `categoryContent`/`searchContent`. - -- [ ] **Step 3: Write minimal implementation** - -```python -from urllib.parse import quote - - - 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=10) - if response.status_code != 200: - return "" - return response.text or "" - - def _page_result(self, items, pg): - page = int(pg) - return {"page": page, "limit": len(items), "total": page * 20 + len(items), "list": items} - - def _parse_cards(self, html): - root = self.html(html) - if root is None: - return [] - items = [] - for node in root.xpath("//*[@id='main']//*[contains(@class,'module-item')]"): - href = "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//a[1]/@href")).strip() - title = "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@alt")).strip() - pic = ( - "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@data-src")).strip() - or "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@src")).strip() - ) - remarks = self._clean_text("".join(node.xpath(".//*[contains(@class,'module-item-text')][1]//text()"))) - year = self._clean_text("".join(node.xpath(".//*[contains(@class,'module-item-caption')][1]//span[1]//text()"))) - if not href or not title: - continue - items.append( - { - "vod_id": href, - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_remarks": remarks, - "vod_year": year, - } - ) - return items - - def categoryContent(self, tid, pg, filter, extend): - url = self._build_url(f"/index.php/vod/show/id/{tid}/page/{int(pg)}.html") - return self._page_result(self._parse_cards(self._request_html(url)), pg) - - def searchContent(self, key, quick, pg="1"): - keyword = self._clean_text(key) - page = int(pg) - if not keyword: - return {"page": page, "total": 0, "list": []} - url = self._build_url(f"/index.php/vod/search/page/{page}/wd/{quote(keyword)}.html") - root = self.html(self._request_html(url)) - if root is None: - return {"page": page, "total": 0, "list": []} - items = [] - for node in root.xpath("//*[contains(@class,'module-search-item')]"): - href = "".join(node.xpath(".//*[contains(@class,'video-serial')][1]/@href")).strip() - title = "".join(node.xpath(".//*[contains(@class,'video-serial')][1]/@title")).strip() - pic = ( - "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@data-src")).strip() - or "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@src")).strip() - ) - remarks = self._clean_text( - "".join(node.xpath(".//*[contains(@class,'video-serial')][1]//text()")) - or "".join(node.xpath(".//*[contains(@class,'module-item-text')][1]//text()")) - ) - if not href or not title: - continue - items.append( - { - "vod_id": href, - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_remarks": remarks, - } - ) - return {"page": page, "total": len(items), "list": items} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_至臻.TestZhiZhenSpider -v` -Expected: PASS for the list and search tests. - -- [ ] **Step 5: Commit** - -```bash -git add py/tests/test_至臻.py py/至臻.py -git commit -m "feat: add zhizhen list and search parsing" -``` - -### Task 3: Add Detail Parsing, Pan Line Building, And Player Passthrough - -**Files:** -- Modify: `py/tests/test_至臻.py` -- Modify: `py/至臻.py` - -- [ ] **Step 1: Write the failing test** - -```python -class TestZhiZhenSpider(unittest.TestCase): - def test_build_pan_lines_deduplicates_and_sorts_supported_links(self): - detail = { - "pan_urls": [ - "https://pan.quark.cn/s/q1", - "https://pan.baidu.com/s/b1", - "https://pan.baidu.com/s/b1", - "https://example.com/ignored", - ] - } - self.assertEqual( - self.spider._build_pan_lines(detail), - [ - ("baidu#至臻", "百度资源$https://pan.baidu.com/s/b1"), - ("quark#至臻", "夸克资源$https://pan.quark.cn/s/q1"), - ], - ) - - def test_parse_detail_page_extracts_meta_content_and_pan_urls(self): - html = """ -
示例剧
-
-
年代
-
导演
-
主演
-
剧情

一段剧情简介

-
-

https://pan.quark.cn/s/q1

-

https://pan.baidu.com/s/b1

-
- """ - detail = self.spider._parse_detail_page("/index.php/vod/detail/id/123.html", html) - self.assertEqual(detail["vod_name"], "示例剧") - self.assertEqual(detail["vod_pic"], "http://www.miqk.cc/poster.jpg") - self.assertEqual(detail["vod_year"], "2024") - self.assertEqual(detail["vod_director"], "导演甲") - self.assertEqual(detail["vod_actor"], "演员甲,演员乙") - self.assertEqual(detail["vod_content"], "一段剧情简介") - self.assertEqual(detail["pan_urls"], ["https://pan.quark.cn/s/q1", "https://pan.baidu.com/s/b1"]) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_pan_play_fields(self, mock_request_html): - mock_request_html.return_value = """ -
示例剧
-
-
年代
-
导演
-
主演
-
剧情

一段剧情简介

-
-

https://pan.quark.cn/s/q1

-

https://pan.baidu.com/s/b1

-
- """ - result = self.spider.detailContent(["/index.php/vod/detail/id/123.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["vod_play_from"], "baidu#至臻$$$quark#至臻") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/q1", - ) - - def test_player_content_passthroughs_supported_pan_urls(self): - self.assertEqual( - self.spider.playerContent("baidu#至臻", "https://pan.baidu.com/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.baidu.com/s/demo"}, - ) - - def test_player_content_rejects_non_pan_url(self): - self.assertEqual( - self.spider.playerContent("site", "/index.php/vod/play/id/1.html", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_至臻.TestZhiZhenSpider -v` -Expected: FAIL with missing detail parser, line builder, or player logic. - -- [ ] **Step 3: Write minimal implementation** - -```python - self.pan_priority = { - "baidu": 1, - "a139": 2, - "a189": 3, - "a123": 4, - "a115": 5, - "quark": 6, - "xunlei": 7, - "aliyun": 8, - "uc": 9, - } - - def _parse_detail_page(self, vod_id, html): - root = self.html(html) - if root is None: - return { - "vod_id": vod_id, - "vod_name": "", - "vod_pic": "", - "vod_year": "", - "vod_director": "", - "vod_actor": "", - "vod_content": "", - "pan_urls": [], - } - detail = { - "vod_id": vod_id, - "vod_name": self._clean_text("".join(root.xpath("//*[contains(@class,'page-title')][1]//text()"))), - "vod_pic": self._build_url( - "".join( - root.xpath( - "//*[contains(@class,'mobile-play')]//*[contains(@class,'lazyload')][1]/@data-src | " - "//*[contains(@class,'mobile-play')]//*[contains(@class,'lazyload')][1]/@src" - ) - ).strip() - ), - "vod_year": "", - "vod_director": "", - "vod_actor": "", - "vod_content": "", - "pan_urls": [], - } - for label_node in root.xpath("//*[contains(@class,'video-info-itemtitle')]"): - key = self._clean_text("".join(label_node.xpath(".//text()"))) - sibling = label_node.getnext() - if sibling is None: - continue - values = [self._clean_text(text) for text in sibling.xpath(".//a//text()")] - joined = ",".join([value for value in values if value]) - text_value = self._clean_text("".join(sibling.xpath(".//text()"))) - if "年代" in key: - detail["vod_year"] = joined or text_value - elif "导演" in key: - detail["vod_director"] = joined or text_value - elif "主演" in key: - detail["vod_actor"] = joined or text_value - elif "剧情" in key: - detail["vod_content"] = text_value - for node in root.xpath("//*[contains(@class,'module-row-info')]//p"): - text = self._clean_text("".join(node.xpath(".//text()"))) - if text: - detail["pan_urls"].append(text) - return detail - - def _build_pan_lines(self, detail): - lines = [] - seen = set() - for url in detail.get("pan_urls", []): - pan_type, title = self._detect_pan_type(url) - if not pan_type or url in seen: - continue - seen.add(url) - lines.append((self.pan_priority.get(pan_type, 999), f"{pan_type}#至臻", f"{title}${url}")) - lines.sort(key=lambda item: item[0]) - return [(item[1], item[2]) for item in lines] - - def detailContent(self, ids): - result = {"list": []} - for raw_id in ids: - vod_id = str(raw_id or "").strip() - detail = self._parse_detail_page(vod_id, self._request_html(self._build_url(vod_id))) - lines = self._build_pan_lines(detail) - result["list"].append( - { - "vod_id": vod_id, - "vod_name": detail["vod_name"], - "vod_pic": detail["vod_pic"], - "vod_year": detail["vod_year"], - "vod_director": detail["vod_director"], - "vod_actor": detail["vod_actor"], - "vod_content": detail["vod_content"], - "vod_play_from": "$$$".join([item[0] for item in lines]), - "vod_play_url": "$$$".join([item[1] for item in lines]), - } - ) - return result - - def playerContent(self, flag, id, vipFlags): - pan_type, _ = self._detect_pan_type(id) - if pan_type: - return {"parse": 0, "playUrl": "", "url": id} - return {"parse": 0, "playUrl": "", "url": ""} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_至臻.TestZhiZhenSpider -v` -Expected: PASS for the detail and player tests. - -- [ ] **Step 5: Commit** - -```bash -git add py/tests/test_至臻.py py/至臻.py -git commit -m "feat: add zhizhen detail and player support" -``` - -## Self-Review - -- Spec coverage: 覆盖了固定分类、列表、搜索、详情、网盘线路和播放透传,没有遗漏公共层改动。 -- Placeholder scan: 计划中的文件路径、测试名、命令和关键代码均已具体化,没有保留 TBD 或“类似前一项”。 -- Type consistency: `vod_id` 使用短路径,`vod_play_from` / `vod_play_url`、`_detect_pan_type`、`_build_pan_lines` 的命名在各任务中保持一致。 diff --git a/py/docs/superpowers/plans/2026-04-24-dyrs-spider.md b/py/docs/superpowers/plans/2026-04-24-dyrs-spider.md deleted file mode 100644 index d80203d..0000000 --- a/py/docs/superpowers/plans/2026-04-24-dyrs-spider.md +++ /dev/null @@ -1,563 +0,0 @@ -# 电影人生 Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在当前 Python 仓库中新增一个 `电影人生` spider,支持首页推荐、分类筛选、搜索精排、详情多线路解析和 `/api/m3u8` 播放链路。 - -**Architecture:** 使用单文件 `Spider` 实现,内部按 URL 构造、HTML 请求、卡片解析、详情解析、搜索精排和播放器解析拆成 helper。测试采用 `unittest + SourceFileLoader + unittest.mock`,按 TDD 逐步覆盖列表、详情和播放逻辑,不依赖真实网络。 - -**Tech Stack:** Python 3, `re`, `json`, `urllib.parse`, `base.spider.Spider`, `unittest`, `unittest.mock` - ---- - -## File Structure - -- Create: `py/电影人生.py` - - 站点实现,负责分类与筛选定义、HTML 请求、列表/详情/搜索/播放解析 -- Create: `py/tests/test_电影人生.py` - - 离线测试,使用 `SourceFileLoader` 加载 spider,并 mock `fetch` 与 `_request_html` - -### Task 1: Scaffold Spider, Filters, And Card Parsing - -**Files:** -- Create: `py/tests/test_电影人生.py` -- Create: `py/电影人生.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("dyrs_spider", str(ROOT / "电影人生.py")).load_module() -Spider = MODULE.Spider - -SAMPLE_LIST_HTML = """ - - -
更新至10集
-
-
2025剧情
- - - -""" - - -class TestDYRSSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_classes_and_filters(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["dianying", "dianshiju", "dongman", "zongyi"], - ) - self.assertEqual( - [item["key"] for item in content["filters"]["dianying"]], - ["class", "sort_field"], - ) - - def test_parse_cards_extracts_short_id_title_pic_and_remarks(self): - cards = self.spider._parse_vod_cards(SAMPLE_LIST_HTML) - self.assertEqual(cards[0]["vod_id"], "dyrscom-foo.html") - self.assertEqual(cards[0]["vod_name"], "片名A") - self.assertEqual(cards[0]["vod_pic"], "https://dyrsok.com/cover/a.jpg") - self.assertEqual(cards[0]["vod_year"], "2025") - self.assertEqual(cards[0]["type_name"], "剧情") - self.assertEqual(cards[0]["vod_remarks"], "更新至10集") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_query_string(self, mock_html): - mock_html.return_value = SAMPLE_LIST_HTML - result = self.spider.categoryContent( - "dianying", - "2", - False, - {"class": "动作", "sort_field": "update_time"}, - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "dyrscom-foo.html") - mock_html.assert_called_with( - "https://dyrsok.com/dianying.html?class=%E5%8A%A8%E4%BD%9C&sort_field=update_time&page=2" - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_reads_home_page(self, mock_html): - mock_html.return_value = SAMPLE_LIST_HTML - result = self.spider.homeVideoContent() - self.assertEqual(len(result["list"]), 2) - mock_html.assert_called_with("https://dyrsok.com/") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_电影人生.TestDYRSSpider.test_home_content_exposes_classes_and_filters -v` -Expected: FAIL with `FileNotFoundError` for `py/电影人生.py` - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import json -import re -import sys -from urllib.parse import urlencode, urljoin - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "电影人生" - self.host = "https://dyrsok.com" - self.headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/146.0.0.0 Safari/537.36" - ), - "Referer": self.host + "/", - } - self.classes = [ - {"type_id": "dianying", "type_name": "电影"}, - {"type_id": "dianshiju", "type_name": "电视剧"}, - {"type_id": "dongman", "type_name": "动漫"}, - {"type_id": "zongyi", "type_name": "综艺"}, - ] - common_movie_tv = [ - {"key": "class", "name": "类型", "init": "", "value": [{"n": "全部", "v": ""}, {"n": "剧情", "v": "剧情"}]}, - {"key": "sort_field", "name": "排序", "init": "", "value": [{"n": "默认", "v": ""}, {"n": "更新时间", "v": "update_time"}]}, - ] - self.filters = { - "dianying": common_movie_tv, - "dianshiju": common_movie_tv, - "dongman": common_movie_tv, - "zongyi": common_movie_tv, - } - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.classes, "filters": self.filters} - - def homeVideoContent(self): - return {"list": self._parse_vod_cards(self._request_html(self.host + "/"))[:24]} - - def _abs_url(self, value): - raw = str(value or "").strip() - if not raw: - return "" - return urljoin(self.host + "/", raw) - - def _clean_text(self, text): - return re.sub(r"\s+", " ", str(text or "")).strip() - - def _request_html(self, url, headers=None): - request_headers = dict(self.headers) - if headers: - request_headers.update(headers) - response = self.fetch(url, headers=request_headers, timeout=10, verify=False) - return response.text if response.status_code == 200 else "" - - def _parse_vod_cards(self, html): - root = self.html(html) - if root is None: - return [] - cards = [] - seen = set() - for node in root.xpath("//a[@data-url and @title]"): - href = ((node.xpath("./@href") or [""])[0]).strip() - title = ((node.xpath("./@title") or [""])[0]).strip() - if not href or not title or "/dyrscom-" not in href: - continue - vod_id = href.lstrip("/") - if vod_id in seen: - continue - seen.add(vod_id) - parent = node.getparent() - year = self._clean_text("".join(parent.xpath(".//*[contains(@class,'items-center')][1]/span[1]//text()"))) if parent is not None else "" - type_name = self._clean_text("".join(parent.xpath(".//*[contains(@class,'items-center')][1]/span[last()]//text()"))) if parent is not None else "" - remarks = self._clean_text("".join(node.xpath(".//*[contains(@class,'text-[10px]')][1]//text()"))) - pic = ((node.xpath(".//img[1]/@data-src") or node.xpath(".//img[1]/@src") or [""])[0]).strip() - cards.append( - { - "vod_id": vod_id, - "vod_name": title, - "vod_pic": self._abs_url(pic), - "vod_url": self._abs_url(href), - "vod_year": year, - "type_name": type_name, - "vod_remarks": remarks, - } - ) - return cards - - def categoryContent(self, tid, pg, filter, extend): - page = int(pg) - values = extend if isinstance(extend, dict) else {} - qs = {} - if values.get("class"): - qs["class"] = values["class"] - if values.get("sort_field"): - qs["sort_field"] = values["sort_field"] - if page > 1: - qs["page"] = page - url = self.host + f"/{tid}.html" - if qs: - url += "?" + urlencode(qs) - return {"page": page, "total": 0, "list": self._parse_vod_cards(self._request_html(url))} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_电影人生.TestDYRSSpider.test_home_content_exposes_classes_and_filters -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/电影人生.py py/tests/test_电影人生.py -git commit -m "feat: add dyrs spider scaffold" -``` - -### Task 2: Add Detail Parsing And Play List Assembly - -**Files:** -- Modify: `py/tests/test_电影人生.py` -- Modify: `py/电影人生.py` - -- [ ] **Step 1: Write the failing test** - -```python -DETAIL_HTML = """ - - -

电影人生示例

-
年份2025
-
地区大陆
-
语言国语
-

导演

-

主演

-

标签

-

剧情简介

这是一段剧情。
- -""" -LINE1_HTML = """ - -""" -LINE2_HTML = """ -
- 正片 -
-""" - - @patch.object(Spider, "_request_html") - def test_detail_content_extracts_meta_and_lines(self, mock_html): - mock_html.side_effect = [DETAIL_HTML, LINE1_HTML, LINE2_HTML] - result = self.spider.detailContent(["dyrscom-foo.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "dyrscom-foo.html") - self.assertEqual(vod["vod_name"], "电影人生示例") - self.assertEqual(vod["vod_pic"], "https://dyrsok.com/poster.jpg") - self.assertEqual(vod["vod_year"], "2025") - self.assertEqual(vod["vod_area"], "大陆") - self.assertEqual(vod["vod_lang"], "国语") - self.assertEqual(vod["vod_director"], "张三") - self.assertEqual(vod["vod_actor"], "李四,王五") - self.assertEqual(vod["type_name"], "剧情 / 悬疑") - self.assertEqual(vod["vod_play_from"], "线路一$$$线路二") - self.assertIn("第1集$", vod["vod_play_url"]) - self.assertIn("正片$", vod["vod_play_url"]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_电影人生.TestDYRSSpider.test_detail_content_extracts_meta_and_lines -v` -Expected: FAIL with `AttributeError: 'Spider' object has no attribute 'detailContent'` - -- [ ] **Step 3: Write minimal implementation** - -```python - def _pick_meta_value(self, html, label): - matched = re.search(rf"{label}\\s*]*>([^<]+)", html) - return self._clean_text(matched.group(1)) if matched else "" - - def _extract_link_texts(self, html, title): - root = self.html(html) - if root is None: - return [] - values = [] - for node in root.xpath(f"//h3[contains(normalize-space(.), '{title}')]/following-sibling::*[1]//a//span"): - text = self._clean_text(''.join(node.xpath('.//text()'))).replace("#", "") - if text: - values.append(text) - return values - - def detailContent(self, ids): - vod_id = str(ids[0] if isinstance(ids, list) and ids else ids).strip() - if not vod_id: - return {"list": []} - url = self._abs_url(vod_id) - html = self._request_html(url) - root = self.html(html) - if root is None: - return {"list": []} - vod_name = self._clean_text(''.join(root.xpath("(//h1|//h2)[1]//text()"))) - vod_pic = self._abs_url(((root.xpath("//meta[@property='og:image']/@content") or [""])[0]).strip()) - vod_content = self._clean_text(''.join(root.xpath("//h3[contains(.,'剧情简介')]/following-sibling::*[1]//*[contains(@class,'text-sm')][1]//text()"))) or self._clean_text(((root.xpath("//meta[@name='description']/@content") or [""])[0])) - vod_year = self._pick_meta_value(html, "年份") - vod_area = self._pick_meta_value(html, "地区") - vod_lang = self._pick_meta_value(html, "语言") - vod_director = ",".join(self._extract_link_texts(html, "导演")) - vod_actor = ",".join(self._extract_link_texts(html, "主演")) - tags = self._extract_link_texts(html, "标签") - line_nodes = root.xpath("//*[@id='originTabs']//a[@href]") or [] - from_list = [] - url_list = [] - for line_node in line_nodes: - href = ((line_node.xpath("./@href") or [""])[0]).strip() - name = self._clean_text( - ((line_node.xpath(".//button[@data-origin]/@data-origin") or [""])[0]) - or ''.join(line_node.xpath(".//text()")) - ) - line_html = html if self._abs_url(href) == url else self._request_html(self._abs_url(href)) - line_root = self.html(line_html) - episodes = [] - if line_root is not None: - for episode in line_root.xpath("//*[contains(@class,'seqlist')]//a[@href]"): - ep_href = ((episode.xpath("./@href") or [""])[0]).strip() - ep_title = self._clean_text(((episode.xpath("./@data-title") or [""])[0]) or ''.join(episode.xpath('.//text()'))) or "播放" - payload = json.dumps( - {"title": ep_title, "origin": name, "page": self._abs_url(ep_href), "vodName": vod_name, "pic": vod_pic}, - ensure_ascii=False, - ) - episodes.append(f"{ep_title}${payload}") - if episodes: - from_list.append(name) - url_list.append("#".join(episodes)) - return { - "list": [ - { - "vod_id": vod_id, - "vod_name": vod_name, - "vod_pic": vod_pic, - "vod_content": vod_content, - "vod_year": vod_year, - "vod_area": vod_area, - "vod_lang": vod_lang, - "vod_director": vod_director, - "vod_actor": vod_actor, - "type_name": " / ".join(tags), - "vod_play_from": "$$$".join(from_list), - "vod_play_url": "$$$".join(url_list), - } - ] - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_电影人生.TestDYRSSpider.test_detail_content_extracts_meta_and_lines -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/电影人生.py py/tests/test_电影人生.py -git commit -m "feat: add dyrs detail parsing" -``` - -### Task 3: Add Search Parsing And Result Refinement - -**Files:** -- Modify: `py/tests/test_电影人生.py` -- Modify: `py/电影人生.py` - -- [ ] **Step 1: Write the failing test** - -```python -SEARCH_HTML = """ - - - -""" - - @patch.object(Spider, "_request_html") - def test_search_content_refines_exact_match_first(self, mock_html): - mock_html.return_value = SEARCH_HTML - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual([item["vod_name"] for item in result["list"][:2]], ["繁花", "繁花2023"]) - mock_html.assert_called_with("https://dyrsok.com/s.html?name=%E7%B9%81%E8%8A%B1") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_电影人生.TestDYRSSpider.test_search_content_refines_exact_match_first -v` -Expected: FAIL with `AttributeError: 'Spider' object has no attribute 'searchContent'` - -- [ ] **Step 3: Write minimal implementation** - -```python - def _normalize_keyword(self, value): - return re.sub(r"[\\s\\-_—–·•::,,.。!?!?'\"“”‘’()()\\[\\]【】{}]", "", str(value or "")).lower() - - def _search_score(self, vod_name, keyword): - name = self._normalize_keyword(vod_name) - key = self._normalize_keyword(keyword) - if not name or not key: - return 0 - if name == key: - return 1000 - if name.startswith(key): - return 800 - if key in name: - return 600 - return 0 - - def _refine_search_results(self, items, keyword): - scored = [] - for item in items: - scored.append((self._search_score(item.get("vod_name"), keyword), item)) - matched = [item for score, item in sorted(scored, key=lambda pair: (-pair[0], pair[1].get("vod_name", ""))) if score > 0] - return matched or items - - def searchContent(self, key, quick, pg="1"): - page = int(pg) - keyword = self._clean_text(key) - if not keyword: - return {"page": page, "total": 0, "list": []} - url = self.host + "/s.html?name=" + __import__("urllib.parse").parse.quote(keyword) - if page > 1: - url += "&page=" + str(page - 1) - items = self._parse_vod_cards(self._request_html(url)) - return {"page": page, "total": len(items), "list": self._refine_search_results(items, keyword)} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_电影人生.TestDYRSSpider.test_search_content_refines_exact_match_first -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/电影人生.py py/tests/test_电影人生.py -git commit -m "feat: add dyrs search refinement" -``` - -### Task 4: Add Player Resolution, Redirect Follow, And Fallback - -**Files:** -- Modify: `py/tests/test_电影人生.py` -- Modify: `py/电影人生.py` - -- [ ] **Step 1: Write the failing test** - -```python - @patch.object(Spider, "_request_html") - @patch.object(Spider, "_request_response") - def test_player_content_resolves_api_m3u8_and_raw_playlist(self, mock_response, mock_html): - mock_html.side_effect = [ - '', - "#EXTM3U\n/api/m3u8?id=xyz&raw=1\n", - ] - - class FakeResponse: - status_code = 302 - headers = {"Location": "https://media.example/master.m3u8"} - text = "" - - mock_response.return_value = FakeResponse() - play_id = '{"title":"第1集","origin":"线路一","page":"https://dyrsok.com/dyrscom-foo.html?play=1","vodName":"电影人生示例","pic":"https://dyrsok.com/poster.jpg"}' - result = self.spider.playerContent("线路一", play_id, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://media.example/api/m3u8?id=xyz&raw=1") - - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_to_parse_when_no_api_link(self, mock_html): - mock_html.return_value = "empty" - result = self.spider.playerContent("线路一", '{"page":"https://dyrsok.com/dyrscom-foo.html?play=1"}', {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["url"], "https://dyrsok.com/dyrscom-foo.html?play=1") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_电影人生.TestDYRSSpider.test_player_content_resolves_api_m3u8_and_raw_playlist -v` -Expected: FAIL with `AttributeError: 'Spider' object has no attribute 'playerContent'` - -- [ ] **Step 3: Write minimal implementation** - -```python - def _request_response(self, url, headers=None, allow_redirects=False): - request_headers = dict(self.headers) - if headers: - request_headers.update(headers) - return self.fetch(url, headers=request_headers, timeout=10, verify=False, allow_redirects=allow_redirects) - - def playerContent(self, flag, id, vipFlags): - raw = str(id or "").strip() - if not raw: - return {"parse": 1, "playUrl": "", "url": "", "header": {}, "jx": 1} - try: - meta = json.loads(raw) - except Exception: - meta = {"page": raw} - page_url = self._abs_url(meta.get("page", "")) - headers = { - "User-Agent": self.headers["User-Agent"], - "Referer": page_url or self.host + "/", - "Origin": self.host, - } - html = self._request_html(page_url, headers={"Referer": self.host + "/"}) if page_url else "" - matched = re.search(r"/api/m3u8\\?origin=([^\"'\\\\\\s&]+|[^\"'\\\\\\s]+?)(&|&)url=([a-zA-Z0-9]+)", html) - if not matched: - return {"parse": 1, "playUrl": "", "url": page_url, "header": headers, "jx": 1} - api_url = self.host + "/api/m3u8?origin=" + matched.group(1) + "&url=" + matched.group(3) - probe = self._request_response(api_url, headers=headers, allow_redirects=False) - final_url = api_url - location = probe.headers.get("Location") or probe.headers.get("location") if hasattr(probe, "headers") else "" - if location: - final_url = self._abs_url(location) - playlist = self._request_html(final_url, headers={"Referer": self.host + "/"}) - raw_line = next((line.strip() for line in playlist.splitlines() if "/api/m3u8?id=" in line and "raw=1" in line), "") - if raw_line: - final_url = urljoin(final_url, raw_line) - return {"parse": 0, "playUrl": "", "url": final_url, "header": headers, "jx": 0} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_电影人生.TestDYRSSpider.test_player_content_resolves_api_m3u8_and_raw_playlist -v` -Expected: PASS - -- [ ] **Step 5: Run module verification** - -Run: `cd py && python -m unittest tests.test_电影人生 -v` -Expected: PASS with all tests green - -- [ ] **Step 6: Commit** - -```bash -git add py/电影人生.py py/tests/test_电影人生.py -git commit -m "feat: add dyrs playback resolution" -``` diff --git a/py/docs/superpowers/plans/2026-04-24-lumman-spider.md b/py/docs/superpowers/plans/2026-04-24-lumman-spider.md deleted file mode 100644 index 212225a..0000000 --- a/py/docs/superpowers/plans/2026-04-24-lumman-spider.md +++ /dev/null @@ -1,640 +0,0 @@ -# 路漫漫 Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在当前 Python 仓库中新增一个 `路漫漫` spider,支持 6 个动漫/动画电影分类、首页推荐、分类、搜索、详情和带回退能力的播放解析。 - -**Architecture:** 使用单文件 `Spider` 实现,内部按 URL 构造、列表卡片解析、详情线路解析、播放器 JSON 解码、AES token 解密和主解析链分层拆成 helper。测试采用 `unittest + SourceFileLoader + unittest.mock`,按红绿重构覆盖列表、详情、播放解码与回退路径,避免真实联网。 - -**Tech Stack:** Python 3, `re`, `json`, `base64`, `urllib.parse`, `Crypto.Cipher.AES`, `Crypto.Util.Padding`, `base.spider.Spider`, `unittest`, `unittest.mock` - ---- - -## File Structure - -- Create: `py/路漫漫.py` - - 站点实现,负责固定分类、HTML 请求、卡片解析、详情组装、播放器解码和主解析链回退 -- Create: `py/tests/test_路漫漫.py` - - 离线测试,使用 `SourceFileLoader` 加载 spider,并 mock `_get_html` / `_post_json` / `_head_url` - -### Task 1: Scaffold Spider, Fixed Classes, And List/Search Parsing - -**Files:** -- Create: `py/tests/test_路漫漫.py` -- Create: `py/路漫漫.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("lumman_spider", str(ROOT / "路漫漫.py")).load_module() -Spider = MODULE.Spider - -SAMPLE_LIST_HTML = """ - - - - -""" - - -class TestLuManManSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_only_animation_classes(self): - result = self.spider.homeContent(False) - self.assertEqual( - result["class"], - [ - {"type_id": "6", "type_name": "日本动漫"}, - {"type_id": "7", "type_name": "国产动漫"}, - {"type_id": "8", "type_name": "欧美动漫"}, - {"type_id": "3", "type_name": "日本动画电影"}, - {"type_id": "4", "type_name": "国产动画电影"}, - {"type_id": "5", "type_name": "欧美动画电影"}, - ], - ) - - @patch.object(Spider, "_get_html") - def test_home_video_content_parses_cards(self, mock_html): - mock_html.return_value = SAMPLE_LIST_HTML - result = self.spider.homeVideoContent() - self.assertEqual(len(result["list"]), 2) - self.assertEqual(result["list"][0]["vod_id"], "vod/detail/1001.html") - self.assertEqual(result["list"][0]["vod_pic"], "https://www.lmm85.com/upload/1001.jpg") - self.assertEqual(result["list"][1]["vod_pic"], "https://img.example.com/1002.jpg") - - @patch.object(Spider, "_get_html") - def test_category_content_builds_filtered_url(self, mock_html): - mock_html.return_value = SAMPLE_LIST_HTML - result = self.spider.categoryContent("6", "2", False, {"年代": "/year/2024", "排序": "/by/time"}) - self.assertEqual(result["page"], 2) - self.assertEqual(len(result["list"]), 2) - mock_html.assert_called_with("https://www.lmm85.com/vod/show/id/6/year/2024/by/time/page/2.html") - - @patch.object(Spider, "_get_html") - def test_search_content_uses_search_path(self, mock_html): - mock_html.return_value = SAMPLE_LIST_HTML - result = self.spider.searchContent("海贼", False, "3") - self.assertEqual(result["page"], 3) - self.assertEqual(result["list"][0]["vod_name"], "海贼王") - mock_html.assert_called_with("https://www.lmm85.com/vod/search/page/3/wd/%E6%B5%B7%E8%B4%BC.html") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_路漫漫.TestLuManManSpider.test_home_content_returns_only_animation_classes -v` -Expected: FAIL with `FileNotFoundError` for `py/路漫漫.py` - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import base64 -import json -import re -import sys -from urllib.parse import quote, unquote, urljoin - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "路漫漫" - self.host = "https://www.lmm85.com" - self.mobile_ua = ( - "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) " - "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 " - "Mobile/15E148 Safari/604.1" - ) - self.headers = { - "User-Agent": self.mobile_ua, - "Referer": "http://www.lmm50.com/", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", - } - self.classes = [ - {"type_id": "6", "type_name": "日本动漫"}, - {"type_id": "7", "type_name": "国产动漫"}, - {"type_id": "8", "type_name": "欧美动漫"}, - {"type_id": "3", "type_name": "日本动画电影"}, - {"type_id": "4", "type_name": "国产动画电影"}, - {"type_id": "5", "type_name": "欧美动画电影"}, - ] - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.classes} - - def _abs_url(self, value): - raw = str(value or "").strip() - if not raw: - return "" - return urljoin(self.host + "/", raw) - - def _encode_vod_id(self, href): - raw = str(href or "").strip() - matched = re.search(r"(/vod/detail/[^?#]+\.html)", raw) - return matched.group(1).lstrip("/") if matched else raw.lstrip("/") - - def _clean_text(self, text): - return re.sub(r"\s+", " ", str(text or "")).strip() - - def _get_html(self, url, headers=None): - request_headers = dict(self.headers) - if headers: - request_headers.update(headers) - response = self.fetch(url, headers=request_headers, timeout=15, verify=False) - return response.text if response.status_code == 200 else "" - - def _parse_cards(self, html): - root = self.html(html) - if root is None: - return [] - items = [] - seen = set() - for box in root.xpath("//*[contains(@class,'video-img-box')]"): - href = self._clean_text((box.xpath(".//a[1]/@href") or [""])[0]) - title = self._clean_text("".join(box.xpath(".//*[contains(@class,'title')][1]//text()"))) - pic = self._clean_text( - (box.xpath(".//img[1]/@data-src") or box.xpath(".//img[1]/@src") or [""])[0] - ) - remarks = self._clean_text("".join(box.xpath(".//*[contains(@class,'label')][1]//text()"))) - vod_id = self._encode_vod_id(href) - if not vod_id or not title or vod_id in seen: - continue - seen.add(vod_id) - items.append( - { - "vod_id": vod_id, - "vod_name": title, - "vod_pic": self._abs_url(pic), - "vod_remarks": remarks, - } - ) - return items - - def homeVideoContent(self): - return {"list": self._parse_cards(self._get_html(self.host + "/"))[:20]} - - def categoryContent(self, tid, pg, filter, extend): - page = int(pg) - extend = extend or {} - path = f"/vod/show/id/{tid}" - path += str(extend.get("年代", "") or "") - path += str(extend.get("排序", "") or "") - path += f"/page/{page}.html" - items = self._parse_cards(self._get_html(self.host + path)) - return {"page": page, "total": len(items), "list": items} - - def searchContent(self, key, quick, pg="1"): - page = int(pg) - keyword = self._clean_text(key) - if not keyword: - return {"page": page, "total": 0, "list": []} - url = f"{self.host}/vod/search/page/{page}/wd/{quote(keyword)}.html" - items = self._parse_cards(self._get_html(url)) - return {"page": page, "total": len(items), "list": items[:10] if quick else items} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_路漫漫.TestLuManManSpider.test_home_content_returns_only_animation_classes tests.test_路漫漫.TestLuManManSpider.test_home_video_content_parses_cards tests.test_路漫漫.TestLuManManSpider.test_category_content_builds_filtered_url tests.test_路漫漫.TestLuManManSpider.test_search_content_uses_search_path -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/路漫漫.py py/tests/test_路漫漫.py -git commit -m "feat: scaffold lumman spider" -``` - -### Task 2: Add Detail Parsing And Multi-Line Episode Grouping - -**Files:** -- Modify: `py/tests/test_路漫漫.py` -- Modify: `py/路漫漫.py` - -- [ ] **Step 1: Write the failing test** - -```python -SAMPLE_DETAIL_HTML = """ - -

进击的巨人

-
-
状态:已完结
-
地区:日本
-
人类与巨人的战斗。
-在线播放 -云播 - -
- HD -
- -""" - - @patch.object(Spider, "_get_html") - def test_detail_content_parses_meta_and_playlists(self, mock_html): - mock_html.return_value = SAMPLE_DETAIL_HTML - result = self.spider.detailContent(["vod/detail/1001.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "进击的巨人") - self.assertEqual(vod["vod_pic"], "https://www.lmm85.com/upload/jjdr.jpg") - self.assertEqual(vod["vod_content"], "人类与巨人的战斗。") - self.assertEqual(vod["vod_remarks"], "状态:已完结 / 地区:日本") - self.assertEqual(vod["vod_play_from"], "在线播放$$$云播") - self.assertIn("第1集$vod/play/1001-1-1.html#第2集$vod/play/1001-1-2.html", vod["vod_play_url"]) - self.assertIn("HD$vod/play/1001-2-1.html", vod["vod_play_url"]) - - @patch.object(Spider, "_get_html") - def test_detail_content_falls_back_to_direct_playlist_scan(self, mock_html): - mock_html.return_value = """ - -

测试

-
- 正片 -
- - """ - result = self.spider.detailContent(["vod/detail/1.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_play_from"], "播放列表") - self.assertEqual(vod["vod_play_url"], "正片$vod/play/1-1-1.html") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_路漫漫.TestLuManManSpider.test_detail_content_parses_meta_and_playlists tests.test_路漫漫.TestLuManManSpider.test_detail_content_falls_back_to_direct_playlist_scan -v` -Expected: FAIL with `AttributeError: 'Spider' object has no attribute 'detailContent'` - -- [ ] **Step 3: Write minimal implementation** - -```python - def _decode_vod_id(self, vod_id): - raw = str(vod_id or "").strip().lstrip("/") - return self._abs_url(raw) - - def _encode_play_id(self, href): - raw = str(href or "").strip() - matched = re.search(r"(/vod/play/[^?#]+\.html)", raw) - return matched.group(1).lstrip("/") if matched else raw.lstrip("/") - - def _parse_play_groups(self, root): - groups = [] - tabs = root.xpath("//*[contains(@class,'module-tab-item') and contains(@class,'tab-item')]") - for index, tab in enumerate(tabs): - name = self._clean_text("".join(tab.xpath(".//text()"))) - target = self._clean_text((tab.xpath("./@href") or [""])[0]) - if not name: - continue - if target.startswith("#"): - playlist = root.xpath(f"//*[@id='{target[1:]}']") - else: - playlist = [] - if not playlist: - playlist = root.xpath(f\"(//*[contains(@class,'module-player-list')])[{index + 1}]\") - if not playlist: - continue - episodes = [] - for anchor in playlist[0].xpath(".//a[@href]"): - ep_name = self._clean_text("".join(anchor.xpath(".//text()"))) - ep_id = self._encode_play_id((anchor.xpath("./@href") or [""])[0]) - if ep_name and ep_id: - episodes.append(f"{ep_name}${ep_id}") - if episodes: - groups.append((name, "#".join(episodes))) - if groups: - return groups - episodes = [] - for anchor in root.xpath("//*[contains(@class,'module-player-list')]//a[@href]"): - ep_name = self._clean_text("".join(anchor.xpath(".//text()"))) - ep_id = self._encode_play_id((anchor.xpath("./@href") or [""])[0]) - if ep_name and ep_id: - episodes.append(f"{ep_name}${ep_id}") - return [("播放列表", "#".join(episodes))] if episodes else [] - - def detailContent(self, ids): - raw_id = ids[0] if isinstance(ids, list) else ids - html = self._get_html(self._decode_vod_id(raw_id)) - root = self.html(html) - if root is None: - return {"list": []} - groups = self._parse_play_groups(root) - remarks = [ - self._clean_text("".join(node.xpath(".//text()"))) - for node in root.xpath("//*[contains(@class,'video-info-items')]") - ] - pic = ( - root.xpath("//*[contains(@class,'module-item-pic')]//img[1]/@data-src") - or root.xpath("//*[contains(@class,'module-item-pic')]//img[1]/@src") - or [""] - )[0] - vod = { - "vod_id": str(raw_id), - "vod_name": self._clean_text("".join(root.xpath("//*[contains(@class,'page-title')][1]//text()"))), - "vod_pic": self._abs_url(pic), - "vod_content": self._clean_text("".join(root.xpath("//*[contains(@class,'video-info-content')][1]//text()"))), - "vod_remarks": " / ".join([value for value in remarks if value]), - "vod_play_from": "$$$".join(name for name, _ in groups), - "vod_play_url": "$$$".join(urls for _, urls in groups), - } - return {"list": [vod]} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_路漫漫.TestLuManManSpider.test_detail_content_parses_meta_and_playlists tests.test_路漫漫.TestLuManManSpider.test_detail_content_falls_back_to_direct_playlist_scan -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/路漫漫.py py/tests/test_路漫漫.py -git commit -m "feat: add lumman detail parsing" -``` - -### Task 3: Add Player JSON Decoding, Direct Media Return, And AES Helper - -**Files:** -- Modify: `py/tests/test_路漫漫.py` -- Modify: `py/路漫漫.py` - -- [ ] **Step 1: Write the failing test** - -```python -from base64 import b64encode -from Crypto.Cipher import AES -from Crypto.Util.Padding import pad - - def test_decrypt_token_returns_plaintext(self): - key = b"ejjooopppqqqrwww" - iv = b"1348987635684651" - cipher = AES.new(key, AES.MODE_CBC, iv) - token = b64encode(cipher.encrypt(pad(b"plain-token", AES.block_size))).decode("utf-8") - self.assertEqual(self.spider._decrypt_token(token), "plain-token") - - def test_decrypt_token_handles_bad_ciphertext(self): - self.assertEqual(self.spider._decrypt_token("not-valid"), "") - - @patch.object(Spider, "_resolve_player_url") - def test_player_content_returns_direct_media_without_parse(self, mock_resolve): - result = self.spider.playerContent("在线播放", "https://cdn.example.com/video.m3u8", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://cdn.example.com/video.m3u8") - mock_resolve.assert_not_called() - - @patch.object(Spider, "_resolve_player_url") - @patch.object(Spider, "_get_html") - def test_player_content_decodes_encrypt_1_and_encrypt_2(self, mock_html, mock_resolve): - encoded = b64encode("https://cdn.example.com/e2.m3u8".encode("utf-8")).decode("utf-8") - mock_html.side_effect = [ - '', - f'', - ] - mock_resolve.side_effect = ["https://cdn.example.com/e1.m3u8", "https://cdn.example.com/e2.m3u8"] - first = self.spider.playerContent("在线播放", "vod/play/1001-1-1.html", {}) - second = self.spider.playerContent("在线播放", "vod/play/1001-1-2.html", {}) - self.assertEqual(first["url"], "https://cdn.example.com/e1.m3u8") - self.assertEqual(second["url"], "https://cdn.example.com/e2.m3u8") - - @patch.object(Spider, "_resolve_player_url", return_value="") - @patch.object(Spider, "_get_html", return_value="missing player") - def test_player_content_falls_back_to_parse_when_player_missing(self, mock_html, mock_resolve): - result = self.spider.playerContent("在线播放", "vod/play/1001-1-1.html", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["url"], "https://www.lmm85.com/vod/play/1001-1-1.html") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_路漫漫.TestLuManManSpider.test_decrypt_token_returns_plaintext tests.test_路漫漫.TestLuManManSpider.test_player_content_returns_direct_media_without_parse tests.test_路漫漫.TestLuManManSpider.test_player_content_decodes_encrypt_1_and_encrypt_2 tests.test_路漫漫.TestLuManManSpider.test_player_content_falls_back_to_parse_when_player_missing -v` -Expected: FAIL with missing `_decrypt_token`, `_resolve_player_url`, or `playerContent` - -- [ ] **Step 3: Write minimal implementation** - -```python -from Crypto.Cipher import AES -from Crypto.Util.Padding import unpad - - def _decode_player_url(self, raw_url, encrypt): - value = str(raw_url or "").strip() - mode = str(encrypt or "0").strip() - if mode == "1": - return unquote(value) - if mode == "2": - try: - return unquote(base64.b64decode(value).decode("utf-8")) - except Exception: - return "" - return value - - def _extract_player_data(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: - return json.loads(matched.group(1)) - except Exception: - return {} - - def _decode_play_id(self, play_id): - return self._abs_url(str(play_id or "").strip().lstrip("/")) - - def _decrypt_token(self, token): - try: - payload = base64.b64decode(str(token or "")) - cipher = AES.new(b"ejjooopppqqqrwww", AES.MODE_CBC, b"1348987635684651") - value = unpad(cipher.decrypt(payload), AES.block_size) - return value.decode("utf-8") - except Exception: - return "" - - def _is_media_url(self, url): - return bool(re.search(r"\.(m3u8|mp4|flv|avi|mkv|ts)(?:[?#]|$)", str(url or ""), re.I)) - - def _build_player_headers(self, referer): - return { - "User-Agent": self.mobile_ua, - "Referer": referer, - "Origin": self.host, - } - - def _resolve_player_url(self, player, play_page_url): - media = self._decode_player_url(player.get("url", ""), player.get("encrypt", "0")) - if self._is_media_url(media): - return media.split("&", 1)[0] - return "" - - def playerContent(self, flag, id, vipFlags): - play_page_url = self._decode_play_id(id) - if self._is_media_url(play_page_url): - return {"parse": 0, "url": play_page_url, "header": self._build_player_headers(self.host + "/")} - body = self._get_html(play_page_url, headers={"Referer": self.host + "/"}) - player = self._extract_player_data(body) - real_url = self._resolve_player_url(player, play_page_url) - if real_url: - return {"parse": 0, "url": real_url, "header": self._build_player_headers(play_page_url)} - return {"parse": 1, "url": play_page_url, "header": self._build_player_headers(self.host + "/")} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_路漫漫.TestLuManManSpider.test_decrypt_token_returns_plaintext tests.test_路漫漫.TestLuManManSpider.test_decrypt_token_handles_bad_ciphertext tests.test_路漫漫.TestLuManManSpider.test_player_content_returns_direct_media_without_parse tests.test_路漫漫.TestLuManManSpider.test_player_content_decodes_encrypt_1_and_encrypt_2 tests.test_路漫漫.TestLuManManSpider.test_player_content_falls_back_to_parse_when_player_missing -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/路漫漫.py py/tests/test_路漫漫.py -git commit -m "feat: add lumman player decode" -``` - -### Task 4: Add Main Parse Chain For Player JS And POST API Resolution - -**Files:** -- Modify: `py/tests/test_路漫漫.py` -- Modify: `py/路漫漫.py` - -- [ ] **Step 1: Write the failing test** - -```python - @patch.object(Spider, "_post_json") - @patch.object(Spider, "_get_html") - def test_resolve_player_url_requests_parser_api_with_decrypted_token(self, mock_html, mock_post_json): - player = {"url": "source-id", "encrypt": "0", "from": "lineA"} - mock_html.side_effect = [ - 'var player_xx={}; var MacPlayer={Parse:"https://parser.example.com/jx.php?url=",PlayUrl:""}; document.querySelector("iframe").src = MacPlayer.Parse + MacPlayer.PlayUrl + "&type=m3u8";', - '', - ] - mock_post_json.return_value = {"url": "https://cdn.example.com/final.m3u8"} - with patch.object(self.spider, "_decrypt_token", return_value="decoded-token") as mock_decrypt: - result = self.spider._resolve_player_url(player, "https://www.lmm85.com/vod/play/1001-1-1.html") - self.assertEqual(result, "https://cdn.example.com/final.m3u8") - mock_decrypt.assert_called_once_with("YxF6M7yw8U7OQcW9t6Qx4w==") - mock_post_json.assert_called_once_with( - "https://parser.example.com/api.php", - {"vid": "vid-1", "t": "123", "token": "decoded-token", "act": "play", "play": "1"}, - referer="https://www.lmm85.com", - ) - - @patch.object(Spider, "_post_json", return_value={}) - @patch.object(Spider, "_get_html", return_value='document.querySelector("iframe").src = "";') - def test_resolve_player_url_returns_empty_when_chain_cannot_be_built(self, mock_html, mock_post_json): - result = self.spider._resolve_player_url({"url": "vid", "encrypt": "0", "from": "lineA"}, "https://www.lmm85.com/vod/play/1.html") - self.assertEqual(result, "") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_路漫漫.TestLuManManSpider.test_resolve_player_url_requests_parser_api_with_decrypted_token tests.test_路漫漫.TestLuManManSpider.test_resolve_player_url_returns_empty_when_chain_cannot_be_built -v` -Expected: FAIL because `_resolve_player_url` only handles direct media - -- [ ] **Step 3: Write minimal implementation** - -```python - def _post_json(self, url, data, referer=None): - headers = {"Referer": referer or self.host, "Origin": self.host, "User-Agent": self.mobile_ua} - response = self.post(url, data=data, headers=headers, timeout=10, verify=False) - if response.status_code != 200: - return {} - try: - return json.loads(response.text or "{}") - except Exception: - return {} - - def _extract_js_src(self, js_text, video_url, play_page_url): - matched = re.search(r"\.src\s*=\s*(.*?);", str(js_text or "")) - if not matched: - return "" - raw = re.sub(r"[\+\s']", "", matched.group(1)) - raw = raw.replace("MacPlayer.Parse", "") - raw = raw.replace("MacPlayer.PlayUrl", video_url) - raw = raw.replace("window.location.href", play_page_url) - return raw - - def _resolve_player_url(self, player, play_page_url): - media = self._decode_player_url(player.get("url", ""), player.get("encrypt", "0")) - if self._is_media_url(media): - return media.split("&", 1)[0] - source = self._clean_text(player.get("from", "")) - if not media or not source: - return "" - js_url = f"{self.host}/static/player/{source}.js" - js_text = self._get_html(js_url, headers={"Referer": play_page_url}) - iframe_url = self._extract_js_src(js_text, media, play_page_url) - if not iframe_url or "type=" not in iframe_url: - return "" - iframe_html = self._get_html(iframe_url, headers={"Referer": self.host}) - if not re.search(r'vid\s*=\s*".+?"', iframe_html): - return "" - api_root = self.regStr(r"^(.*?//.*?/)", iframe_url) - api_path = self.regStr(r'post\("(.*?)"', iframe_html) - if not api_root or not api_path: - return "" - post_url = urljoin(api_root, api_path.lstrip("/")) - token = self.regStr(r'token\s*=\s*"(.*?)"', iframe_html) - payload = { - "vid": self.regStr(r'vid\s*=\s*"(.*?)"', iframe_html), - "t": self.regStr(r'var\s+t\s*=\s*"(.*?)"', iframe_html), - "token": self._decrypt_token(token), - "act": self.regStr(r'act\s*=\s*"(.*?)"', iframe_html), - "play": self.regStr(r'play\s*=\s*"(.*?)"', iframe_html), - } - data = self._post_json(post_url, payload, referer=self.host) - return str(data.get("url", "")).strip() -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_路漫漫.TestLuManManSpider.test_resolve_player_url_requests_parser_api_with_decrypted_token tests.test_路漫漫.TestLuManManSpider.test_resolve_player_url_returns_empty_when_chain_cannot_be_built -v` -Expected: PASS - -- [ ] **Step 5: Run the full module tests** - -Run: `cd py && python -m unittest tests.test_路漫漫 -v` -Expected: PASS with all `路漫漫` tests green - -- [ ] **Step 6: Commit** - -```bash -git add py/路漫漫.py py/tests/test_路漫漫.py -git commit -m "feat: complete lumman spider" -``` - -## Self-Review - -- Spec coverage: 固定 6 分类、首页、分类、搜索、详情、多线路、播放直链/解码/主解析链/回退都对应到 Task 1-4,没有遗漏站点目标。 -- Placeholder scan: 计划中没有 `TODO/TBD/implement later` 之类占位内容,测试命令和文件路径都给了精确值。 -- Type consistency: 方法名统一使用 `_abs_url`、`_encode_vod_id`、`_decode_vod_id`、`_encode_play_id`、`_decode_play_id`、`_parse_cards`、`_parse_play_groups`、`_extract_player_data`、`_decrypt_token`、`_resolve_player_url`、`playerContent`。 diff --git a/py/docs/superpowers/plans/2026-04-24-ppnix-spider.md b/py/docs/superpowers/plans/2026-04-24-ppnix-spider.md deleted file mode 100644 index 2268041..0000000 --- a/py/docs/superpowers/plans/2026-04-24-ppnix-spider.md +++ /dev/null @@ -1,550 +0,0 @@ -# PPnix Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a PPnix spider in `py/PPnix.py` with static categories and stable filters, deterministic HTML parsing, short IDs, direct m3u8 playback, and matching unit tests. - -**Architecture:** Keep the spider in one file following the repository's existing adapter pattern. Use small parsing helpers for URL building, card extraction, detail metadata extraction, and compact play-ID encoding so each behavior can be tested in isolation with embedded HTML fixtures. - -**Tech Stack:** Python 3.14, `unittest`, `unittest.mock`, `requests` via `base.spider.Spider.fetch`, `beautifulsoup4`, `urllib.parse`, `re` - ---- - -### Task 1: Add failing tests for static metadata, URL builders, and card parsing - -**Files:** -- Create: `py/tests/test_PPnix.py` -- Create: `py/PPnix.py` -- Test: `py/tests/test_PPnix.py` - -- [ ] **Step 1: Write the failing tests** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("ppnix_spider", str(ROOT / "PPnix.py")).load_module() -Spider = MODULE.Spider - - -class TestPPnixSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories_and_filter_keys(self): - content = self.spider.homeContent(False) - self.assertEqual([item["type_id"] for item in content["class"]], ["1", "2"]) - self.assertEqual([item["key"] for item in content["filters"]["1"]], ["class", "by"]) - self.assertEqual([item["key"] for item in content["filters"]["2"]], ["class", "by"]) - - def test_build_category_url_maps_first_page_and_sort_values(self): - self.assertEqual( - self.spider._build_category_url("1", "1", {}), - "https://www.ppnix.com/cn/movie/----newstime.html", - ) - self.assertEqual( - self.spider._build_category_url("2", "3", {"class": "爱情", "by": "hits"}), - "https://www.ppnix.com/cn/tv/爱情---2-onclick.html", - ) - - def test_build_search_url_uses_ppnix_pattern(self): - self.assertEqual( - self.spider._build_search_url("繁花", "1"), - "https://www.ppnix.com/cn/search/%E7%B9%81%E8%8A%B1--.html", - ) - self.assertEqual( - self.spider._build_search_url("繁花", "2"), - "https://www.ppnix.com/cn/search/%E7%B9%81%E8%8A%B1--.html-page-2", - ) - - def test_parse_cards_extracts_short_vod_id_title_cover_and_remarks(self): - html = ''' - - ''' - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "movie/123.html", - "vod_name": "示例影片", - "vod_pic": "https://www.ppnix.com/poster.jpg", - "vod_remarks": "HD", - } - ], - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_PPnix.py -v` -Expected: FAIL because `py/PPnix.py` does not exist yet or required methods are missing - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import re -import sys -from urllib.parse import quote - -from bs4 import BeautifulSoup -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "PPnix[采]" - self.host = "https://www.ppnix.com" - self.lang_path = "/cn" - self.headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/145.0.0.0 Safari/537.36" - ), - "Referer": self.host + self.lang_path + "/", - } - self.classes = [ - {"type_id": "1", "type_name": "电影"}, - {"type_id": "2", "type_name": "电视剧"}, - ] - self.filters = { - "1": [ - {"key": "class", "name": "类型", "init": "", "value": [{"n": "全部", "v": ""}, {"n": "动作", "v": "动作"}]}, - {"key": "by", "name": "排序", "init": "time", "value": [{"n": "按时间", "v": "time"}, {"n": "按人气", "v": "hits"}, {"n": "按评分", "v": "score"}]}, - ], - "2": [ - {"key": "class", "name": "类型", "init": "", "value": [{"n": "全部", "v": ""}, {"n": "爱情", "v": "爱情"}]}, - {"key": "by", "name": "排序", "init": "time", "value": [{"n": "按时间", "v": "time"}, {"n": "按人气", "v": "hits"}, {"n": "按评分", "v": "score"}]}, - ], - } - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.classes, "filters": self.filters} - - def _build_url(self, value): - raw = str(value or "").strip() - if not raw: - return self.host + self.lang_path + "/" - if raw.startswith(("http://", "https://")): - return raw - if raw.startswith("/"): - return self.host + raw - return self.host + "/" + raw - - def _map_type_slug(self, tid): - return "tv" if str(tid) == "2" else "movie" - - def _map_sort(self, value): - return {"time": "newstime", "hits": "onclick", "score": "rating"}.get(str(value or "time"), "newstime") - - def _build_category_url(self, tid, pg, extend): - extend = extend or {} - slug = self._map_type_slug(tid) - genre = str(extend.get("class", "") or "") - page = max(int(pg), 1) - page_part = "" if page <= 1 else str(page - 1) - sort = self._map_sort(extend.get("by", "time")) - return self._build_url(f"{self.lang_path}/{slug}/{genre}---{page_part}-{sort}.html") - - def _build_search_url(self, keyword, pg): - page = max(int(pg), 1) - suffix = "" if page <= 1 else f"-page-{page}" - return self._build_url(f"{self.lang_path}/search/{quote(str(keyword or '').strip())}--.html{suffix}") - - def _parse_cards(self, html): - soup = BeautifulSoup(html or "", "html.parser") - items = [] - for node in soup.select("li"): - anchor = node.select_one("a.thumbnail") or node.select_one("h2 a") or node.select_one("a") - image = node.select_one("img.thumb") - if not anchor: - continue - href = (anchor.get("href") or "").strip() - match = re.search(r"/cn/(movie|tv)/\d+\.html", href) - if not match: - continue - items.append( - { - "vod_id": href.replace("/cn/", "").lstrip("/"), - "vod_name": ((image.get("alt") if image else "") or anchor.get_text(" ", strip=True)).strip(), - "vod_pic": self._build_url((image.get("src") if image else "") or ""), - "vod_remarks": (node.select_one("footer .rate") or node.select_one("footer") or "").get_text(" ", strip=True), - } - ) - return items -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_PPnix.py -v` -Expected: PASS for the four tests above - -- [ ] **Step 5: Commit** - -```bash -git add py/PPnix.py py/tests/test_PPnix.py -git commit -m "feat: add PPnix spider skeleton" -``` - -### Task 2: Add failing tests for homepage, category, and search flows - -**Files:** -- Modify: `py/tests/test_PPnix.py` -- Modify: `py/PPnix.py` -- Test: `py/tests/test_PPnix.py` - -- [ ] **Step 1: Write the failing tests** - -```python -from unittest.mock import patch - - @patch.object(Spider, "_request_html") - def test_home_video_content_merges_movie_and_tv_cards(self, mock_request_html): - mock_request_html.return_value = ''' -
-
    -
  • 电影一
    HD
  • -
-
-
-
    -
  • 剧集一
    更新中
  • -
-
- ''' - result = self.spider.homeVideoContent() - self.assertEqual([item["vod_id"] for item in result["list"]], ["movie/101.html", "tv/201.html"]) - - @patch.object(Spider, "_request_html") - def test_category_content_uses_expected_listing_url(self, mock_request_html): - mock_request_html.return_value = ''' -
    -
  • 分类影片
    HD
  • -
- ''' - result = self.spider.categoryContent("1", "2", False, {"class": "动作", "by": "score"}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.ppnix.com/cn/movie/动作---1-rating.html", - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "movie/301.html") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_parses_only_movie_and_tv_ids(self, mock_request_html): - mock_request_html.return_value = ''' -
    -
  • 搜索电影
  • -
  • 忽略条目
  • -
- ''' - result = self.spider.searchContent("搜索词", False, "1") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.ppnix.com/cn/search/%E6%90%9C%E7%B4%A2%E8%AF%8D--.html", - ) - self.assertEqual([item["vod_id"] for item in result["list"]], ["movie/401.html"]) - self.assertNotIn("pagecount", result) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_PPnix.py -v` -Expected: FAIL because `_request_html`, `homeVideoContent`, `categoryContent`, and `searchContent` are incomplete or missing - -- [ ] **Step 3: Write minimal implementation** - -```python - def _request_html(self, path_or_url, referer=None, extra_headers=None): - target = path_or_url if str(path_or_url).startswith("http") else self._build_url(path_or_url) - headers = dict(self.headers) - headers["Referer"] = referer or self.headers["Referer"] - if isinstance(extra_headers, dict): - headers.update(extra_headers) - response = self.fetch(target, headers=headers, timeout=10) - if response.status_code != 200: - return "" - return response.text or "" - - def _dedupe(self, items): - seen = set() - result = [] - for item in items: - key = item.get("vod_id") - if not key or key in seen: - continue - seen.add(key) - result.append(item) - return result - - def homeVideoContent(self): - html = self._request_html(self.lang_path + "/") - soup = BeautifulSoup(html or "", "html.parser") - blocks = soup.select(".lists-content ul") - items = [] - if len(blocks) > 0: - items.extend(self._parse_cards(str(blocks[0]))) - if len(blocks) > 1: - items.extend(self._parse_cards(str(blocks[1]))) - return {"list": self._dedupe(items)[:20]} - - def categoryContent(self, tid, pg, filter, extend): - page = max(int(pg), 1) - items = self._parse_cards(self._request_html(self._build_category_url(tid, pg, extend or {}))) - slug = self._map_type_slug(tid) - items = [item for item in items if item["vod_id"].startswith(slug + "/")] - return {"page": page, "limit": 24, "total": page * 24 + len(items), "list": items} - - def searchContent(self, key, quick, pg="1"): - page = max(int(pg), 1) - items = self._parse_cards(self._request_html(self._build_search_url(key, pg))) - return {"page": page, "limit": 24, "total": page * 24 + len(items), "list": items[:10] if quick else items} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_PPnix.py -v` -Expected: PASS for homepage, category, and search tests - -- [ ] **Step 5: Commit** - -```bash -git add py/PPnix.py py/tests/test_PPnix.py -git commit -m "feat: add PPnix list and search parsing" -``` - -### Task 3: Add failing tests for detail parsing and direct player URLs - -**Files:** -- Modify: `py/tests/test_PPnix.py` -- Modify: `py/PPnix.py` -- Test: `py/tests/test_PPnix.py` - -- [ ] **Step 1: Write the failing tests** - -```python - def test_extract_m3u8_items_reads_infoid_and_episode_names(self): - html = """ - - """ - self.assertEqual( - self.spider._extract_m3u8_items(html), - {"info_id": "7788", "items": ["第1集", "第2集"]}, - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_ppnix_play_group(self, mock_request_html): - mock_request_html.return_value = ''' -

示例剧 (2025)

-
-
导演:导演甲
-
主演:演员甲 / 演员乙
-
简介:一段剧情简介
- - ''' - result = self.spider.detailContent(["tv/8899.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "tv/8899.html") - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["vod_pic"], "https://www.ppnix.com/poster.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"], "PPnix") - self.assertEqual(vod["vod_play_url"], "第1集$8899|%E7%AC%AC1%E9%9B%86#第2集$8899|%E7%AC%AC2%E9%9B%86") - - def test_player_content_returns_direct_m3u8_url(self): - result = self.spider.playerContent("PPnix", "8899|%E7%AC%AC1%E9%9B%86", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://www.ppnix.com/info/m3u8/8899/%E7%AC%AC1%E9%9B%86.m3u8") - self.assertEqual(result["header"]["Origin"], "https://www.ppnix.com") - - def test_player_content_falls_back_when_play_id_is_invalid(self): - result = self.spider.playerContent("PPnix", "broken", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "broken") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_PPnix.py -v` -Expected: FAIL because detail and player helpers are still missing - -- [ ] **Step 3: Write minimal implementation** - -```python -from urllib.parse import quote, unquote - - def _extract_m3u8_items(self, html): - body = html or "" - info_match = re.search(r"infoid\s*=\s*(\d+)", body) - list_match = re.search(r"m3u8\s*=\s*\[(.*?)\]", body, re.S) - items = re.findall(r"'([^']*)'|\"([^\"]*)\"", list_match.group(1) if list_match else "") - return { - "info_id": info_match.group(1) if info_match else "", - "items": [a or b for a, b in items if (a or b).strip()], - } - - def _build_play_id(self, info_id, param): - return f"{str(info_id).strip()}|{quote(str(param).strip())}" - - def _parse_play_id(self, play_id): - parts = str(play_id or "").split("|", 1) - if len(parts) != 2 or not parts[0] or not parts[1]: - return {"info_id": "", "param": ""} - return {"info_id": parts[0].strip(), "param": unquote(parts[1].strip())} - - def detailContent(self, ids): - vod_id = str(ids[0] if isinstance(ids, list) and ids else ids or "").strip().lstrip("/") - if not vod_id: - return {"list": []} - html = self._request_html(self._build_url("/" + self.lang_path.strip("/") + "/" + vod_id)) - soup = BeautifulSoup(html or "", "html.parser") - title_raw = (soup.select_one("h1.product-title") or soup.select_one("title")) - title_text = title_raw.get_text(" ", strip=True) if title_raw else "" - info = self._extract_m3u8_items(html) - play_urls = [] - for item in info["items"]: - play_urls.append(f"{item}${self._build_play_id(info['info_id'], item)}") - return { - "list": [ - { - "vod_id": vod_id, - "vod_name": re.sub(r"\s*\([^)]*\)\s*$", "", title_text).strip(), - "vod_pic": self._build_url((soup.select_one('header.product-header img.thumb') or {}).get("src", "")), - "type_name": "电视剧" if vod_id.startswith("tv/") else "电影", - "vod_year": (re.search(r"\((\d{4})\)", title_text) or [None, ""])[1], - "vod_director": self._extract_excerpt_text(soup, "导演:"), - "vod_actor": self._extract_excerpt_text(soup, "主演:").replace(" / ", ","), - "vod_content": self._extract_excerpt_text(soup, "简介:"), - "vod_play_from": "PPnix" if play_urls else "", - "vod_play_url": "#".join(play_urls), - } - ] - } - - def _extract_excerpt_text(self, soup, label): - for node in soup.select(".product-excerpt"): - text = node.get_text(" ", strip=True) - if label in text: - return text.replace(label, "", 1).strip() - return "" - - def playerContent(self, flag, id, vipFlags): - meta = self._parse_play_id(id) - if not meta["info_id"] or not meta["param"]: - return {"parse": 1, "jx": 1, "url": str(id or ""), "header": {}} - encoded = quote(meta["param"]) - return { - "parse": 0, - "jx": 0, - "url": self._build_url(f"/info/m3u8/{meta['info_id']}/{encoded}.m3u8"), - "header": { - "Referer": self.host + self.lang_path + "/", - "Origin": self.host, - "User-Agent": self.headers["User-Agent"], - }, - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_PPnix.py -v` -Expected: PASS for detail and player tests - -- [ ] **Step 5: Commit** - -```bash -git add py/PPnix.py py/tests/test_PPnix.py -git commit -m "feat: add PPnix detail and player parsing" -``` - -### Task 4: Run focused verification and repo-style cleanup - -**Files:** -- Modify: `py/PPnix.py` -- Modify: `py/tests/test_PPnix.py` -- Test: `py/tests/test_PPnix.py` - -- [ ] **Step 1: Run the focused PPnix test module** - -Run: `uv run python -m unittest py/tests/test_PPnix.py -v` -Expected: all PPnix tests pass - -- [ ] **Step 2: Tighten static filter values and helper names if test-driven cleanup is needed** - -```python -self.filters = { - "1": [ - { - "key": "class", - "name": "类型", - "init": "", - "value": [{"n": "全部", "v": ""}, {"n": "动作", "v": "动作"}, {"n": "喜剧", "v": "喜剧"}, {"n": "剧情", "v": "剧情"}], - }, - { - "key": "by", - "name": "排序", - "init": "time", - "value": [{"n": "按时间", "v": "time"}, {"n": "按人气", "v": "hits"}, {"n": "按评分", "v": "score"}], - }, - ], - "2": [ - { - "key": "class", - "name": "类型", - "init": "", - "value": [{"n": "全部", "v": ""}, {"n": "爱情", "v": "爱情"}, {"n": "古装", "v": "古装"}, {"n": "悬疑", "v": "悬疑"}], - }, - { - "key": "by", - "name": "排序", - "init": "time", - "value": [{"n": "按时间", "v": "time"}, {"n": "按人气", "v": "hits"}, {"n": "按评分", "v": "score"}], - }, - ], -} -``` - -- [ ] **Step 3: Re-run the focused PPnix test module** - -Run: `uv run python -m unittest py/tests/test_PPnix.py -v` -Expected: all PPnix tests still pass after cleanup - -- [ ] **Step 4: Commit** - -```bash -git add py/PPnix.py py/tests/test_PPnix.py -git commit -m "test: verify PPnix spider behavior" -``` diff --git a/py/docs/superpowers/plans/2026-04-24-ruyi-spider.md b/py/docs/superpowers/plans/2026-04-24-ruyi-spider.md deleted file mode 100644 index bcb12ba..0000000 --- a/py/docs/superpowers/plans/2026-04-24-ruyi-spider.md +++ /dev/null @@ -1,594 +0,0 @@ -# 如意资源 Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在当前 Python 仓库中新增一个 `如意资源` spider,支持硬编码分类筛选、首页推荐、分类列表、搜索、详情和基础播放解析。 - -**Architecture:** 使用单文件 `Spider` 实现,内部按采集 API fallback、图片 URL 补全、列表字段映射、分类类型推导、详情播放组装和基础播放分支拆成 helper。测试使用 `unittest + SourceFileLoader + unittest.mock`,严格按红绿重构覆盖 fallback、参数拼接、详情字段解析和播放输出,不做真实联网。 - -**Tech Stack:** Python 3, `json`, `re`, `sys`, `base.spider.Spider`, `unittest`, `unittest.mock` - ---- - -## File Structure - -- Create: `py/如意资源.py` - - 站点实现,负责硬编码分类、API 请求 fallback、列表映射、详情组装和播放结果输出 -- Create: `py/tests/test_如意资源.py` - - 离线测试,使用 `SourceFileLoader` 加载 spider,并 mock `fetch` 或 `_request_json` - -### Task 1: Scaffold Spider, API Fallback, Home, Category, And Search - -**Files:** -- Create: `py/tests/test_如意资源.py` -- Create: `py/如意资源.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("ruyi_spider", str(ROOT / "如意资源.py")).load_module() -Spider = MODULE.Spider - - -class FakeResponse: - def __init__(self, status_code=200, text="{}"): - self.status_code = status_code - self.text = text - self.encoding = "utf-8" - - -class TestRuYiSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_hardcoded_classes_and_filters(self): - result = self.spider.homeContent(False) - class_ids = [item["type_id"] for item in result["class"]] - self.assertEqual(class_ids, ["1", "2", "3", "4", "35", "36"]) - self.assertEqual(result["filters"]["1"][0]["key"], "type") - self.assertEqual(result["filters"]["1"][0]["value"][0], {"n": "动作片", "v": "7"}) - self.assertEqual(result["filters"]["2"][0]["value"][0], {"n": "国产剧", "v": "13"}) - self.assertEqual(result["filters"]["35"], []) - self.assertEqual(result["list"], []) - - def test_get_pic_url_handles_empty_absolute_and_relative_values(self): - self.assertEqual(self.spider._get_pic_url(""), "") - self.assertEqual(self.spider._get_pic_url(""), "") - self.assertEqual(self.spider._get_pic_url("https://img.example.com/poster.jpg"), "https://img.example.com/poster.jpg") - self.assertEqual(self.spider._get_pic_url("/upload/poster.jpg"), "https://ps.ryzypics.com/upload/poster.jpg") - - @patch.object(Spider, "fetch") - def test_request_json_falls_back_to_second_api_after_failure(self, mock_fetch): - mock_fetch.side_effect = [ - FakeResponse(status_code=500, text=""), - FakeResponse(text='{"list":[{"vod_id":"9","vod_name":"后备命中","vod_pic":"/poster.jpg","vod_remarks":"HD","vod_year":"2026","type_id":"7"}]}'), - ] - result = self.spider._request_json({"ac": "list", "pg": "1", "pagesize": "20"}) - self.assertEqual(result["list"][0]["vod_name"], "后备命中") - self.assertEqual(mock_fetch.call_count, 2) - first_url = mock_fetch.call_args_list[0].args[0] - second_url = mock_fetch.call_args_list[1].args[0] - self.assertIn("https://cj.rycjapi.com/api.php/provide/vod", first_url) - self.assertIn("https://cj.rytvapi.com/api.php/provide/vod", second_url) - - @patch.object(Spider, "_request_json") - def test_home_video_content_maps_recommend_list(self, mock_request_json): - mock_request_json.return_value = { - "list": [ - { - "vod_id": "101", - "vod_name": "推荐影片", - "vod_pic": "/cover.jpg", - "vod_remarks": "更新至1集", - "vod_year": "2025", - "type_id": "7", - } - ] - } - result = self.spider.homeVideoContent() - self.assertEqual( - result, - { - "list": [ - { - "vod_id": "101", - "vod_name": "推荐影片", - "vod_pic": "https://ps.ryzypics.com/cover.jpg", - "vod_remarks": "更新至1集", - "vod_year": "2025", - "type_id": "7", - } - ] - }, - ) - self.assertEqual(mock_request_json.call_args.args[0], {"ac": "list", "pg": "1", "pagesize": "20"}) - - @patch.object(Spider, "_request_json") - def test_category_content_uses_default_sub_type_for_main_class(self, mock_request_json): - mock_request_json.return_value = { - "list": [ - {"vod_id": "202", "vod_name": "动作电影", "vod_pic": "", "vod_remarks": "HD", "vod_year": "2024", "type_id": "7"} - ] - } - result = self.spider.categoryContent("1", "2", False, {}) - self.assertEqual(mock_request_json.call_args.args[0], {"ac": "list", "t": "7", "pg": "2", "pagesize": "20"}) - self.assertEqual(result["page"], 2) - self.assertEqual(result["limit"], 20) - self.assertEqual(result["list"][0]["vod_id"], "202") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_json") - def test_category_content_prefers_extend_type(self, mock_request_json): - mock_request_json.return_value = {"list": []} - self.spider.categoryContent("1", "1", False, {"type": "10"}) - self.assertEqual(mock_request_json.call_args.args[0]["t"], "10") - - def test_search_content_returns_empty_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "limit": 30, "total": 0, "list": []}) - - @patch.object(Spider, "_request_json") - def test_search_content_filters_titles_by_keyword(self, mock_request_json): - mock_request_json.return_value = { - "list": [ - {"vod_id": "301", "vod_name": "繁花", "vod_pic": "/a.jpg", "vod_remarks": "完结", "vod_year": "2024", "type_id": "13"}, - {"vod_id": "302", "vod_name": "狂飙", "vod_pic": "/b.jpg", "vod_remarks": "完结", "vod_year": "2023", "type_id": "13"}, - ] - } - result = self.spider.searchContent("繁花", False, "3") - self.assertEqual(mock_request_json.call_args.args[0], {"ac": "list", "wd": "繁花", "pg": "3", "pagesize": "30"}) - self.assertEqual(result["page"], 3) - self.assertEqual(result["list"], [ - { - "vod_id": "301", - "vod_name": "繁花", - "vod_pic": "https://ps.ryzypics.com/a.jpg", - "vod_remarks": "完结", - "vod_year": "2024", - "type_id": "13", - } - ]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_如意资源.TestRuYiSpider -v` -Expected: FAIL with `FileNotFoundError` for `py/如意资源.py` - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import json -import sys - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "如意资源" - self.api_urls = [ - "https://cj.rycjapi.com/api.php/provide/vod", - "https://cj.rytvapi.com/api.php/provide/vod", - "https://bycj.rytvapi.com/api.php/provide/vod", - ] - self.img_hosts = [ - "https://ps.ryzypics.com", - "https://ry-pic.com", - "https://img.lzzyimg.com", - ] - self.headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "application/json", - "Accept-Language": "zh-CN,zh;q=0.9", - "Referer": "https://cj.rycjapi.com/", - } - self.classes = [ - {"type_id": "1", "type_name": "电影片"}, - {"type_id": "2", "type_name": "连续剧"}, - {"type_id": "3", "type_name": "综艺片"}, - {"type_id": "4", "type_name": "动漫片"}, - {"type_id": "35", "type_name": "电影解说"}, - {"type_id": "36", "type_name": "体育"}, - ] - self.sub_class_map = { - "1": ["7", "6", "8", "9", "10", "11", "12", "20", "34", "45", "47"], - "2": ["13", "14", "15", "16", "21", "22", "23", "24", "46"], - "3": ["25", "26", "27", "28"], - "4": ["29", "30", "31", "32", "33"], - "35": [], - "36": ["37", "38", "39", "40"], - } - self.type_options = { - "1": [{"n": "动作片", "v": "7"}, {"n": "喜剧片", "v": "8"}, {"n": "爱情片", "v": "9"}, {"n": "科幻片", "v": "10"}, {"n": "恐怖片", "v": "11"}, {"n": "剧情片", "v": "12"}, {"n": "战争片", "v": "6"}, {"n": "记录片", "v": "20"}, {"n": "伦理片", "v": "34"}, {"n": "预告片", "v": "45"}, {"n": "动画电影", "v": "47"}], - "2": [{"n": "国产剧", "v": "13"}, {"n": "香港剧", "v": "14"}, {"n": "韩国剧", "v": "15"}, {"n": "欧美剧", "v": "16"}, {"n": "台湾剧", "v": "21"}, {"n": "日本剧", "v": "22"}, {"n": "海外剧", "v": "23"}, {"n": "泰国剧", "v": "24"}, {"n": "短剧", "v": "46"}], - "3": [{"n": "大陆综艺", "v": "25"}, {"n": "港台综艺", "v": "26"}, {"n": "日韩综艺", "v": "27"}, {"n": "欧美综艺", "v": "28"}], - "4": [{"n": "国产动漫", "v": "29"}, {"n": "日韩动漫", "v": "30"}, {"n": "欧美动漫", "v": "31"}, {"n": "港台动漫", "v": "32"}, {"n": "海外动漫", "v": "33"}], - "35": [], - "36": [{"n": "足球", "v": "37"}, {"n": "篮球", "v": "38"}, {"n": "网球", "v": "39"}, {"n": "斯诺克", "v": "40"}], - } - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def _build_query(self, params=None): - parts = [] - for key, value in (params or {}).items(): - if value in ("", None): - continue - parts.append(f"{key}={value}") - return "&".join(parts) - - def _request_json(self, params=None, url=""): - if url: - targets = [url] - else: - query = self._build_query(params) - targets = [f"{base}?{query}" if query else base for base in self.api_urls] - for target in targets: - response = self.fetch(target, headers=self.headers, timeout=10) - if response.status_code != 200: - continue - try: - return json.loads(response.text or "{}") - except Exception: - continue - return {} - - def _get_pic_url(self, value): - raw = str(value or "").strip() - if raw in ("", "", "nil", "null"): - return "" - if raw.startswith("http://") or raw.startswith("https://"): - return raw - if raw.startswith("/"): - return self.img_hosts[0] + raw - return raw - - def _format_vod_list(self, items): - result = [] - for item in items or []: - vod_id = str((item or {}).get("vod_id", "")).strip() - if not vod_id or vod_id == "0": - continue - vod_year = str((item or {}).get("vod_year", "")).strip() - vod_remarks = str((item or {}).get("vod_remarks", "")).strip() or vod_year - result.append( - { - "vod_id": vod_id, - "vod_name": str((item or {}).get("vod_name", "")).strip() or "未知标题", - "vod_pic": self._get_pic_url((item or {}).get("vod_pic", "")), - "vod_remarks": vod_remarks, - "vod_year": vod_year, - "type_id": str((item or {}).get("type_id", "")).strip(), - } - ) - return result - - def _build_filters(self): - filters = {} - for cls in self.classes: - tid = cls["type_id"] - filters[tid] = [] - values = self.type_options.get(tid, []) - if values: - filters[tid].append({"key": "type", "name": "类型", "value": values}) - return filters - - def _resolve_type_id(self, tid, extend=None): - current = dict(extend or {}) - if current.get("type"): - return str(current["type"]) - values = self.sub_class_map.get(str(tid), []) - if values: - return values[0] - return str(tid) - - def _page_result(self, items, pg, limit): - page = max(int(str(pg or 1)), 1) - return {"page": page, "limit": limit, "total": page * limit + len(items), "list": items} - - def homeContent(self, filter): - return {"class": self.classes, "filters": self._build_filters(), "list": []} - - def homeVideoContent(self): - data = self._request_json({"ac": "list", "pg": "1", "pagesize": "20"}) - return {"list": self._format_vod_list(data.get("list", []))} - - def categoryContent(self, tid, pg, filter, extend): - params = {"ac": "list", "t": self._resolve_type_id(tid, extend), "pg": str(pg), "pagesize": "20"} - data = self._request_json(params) - return self._page_result(self._format_vod_list(data.get("list", [])), pg, 20) - - def searchContent(self, key, quick, pg="1"): - keyword = str(key or "").strip() - page = max(int(str(pg or 1)), 1) - if not keyword: - return {"page": page, "limit": 30, "total": 0, "list": []} - data = self._request_json({"ac": "list", "wd": keyword, "pg": str(page), "pagesize": "30"}) - items = [item for item in self._format_vod_list(data.get("list", [])) if keyword.lower() in item["vod_name"].lower()] - return self._page_result(items, page, 30) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_如意资源.TestRuYiSpider.test_home_content_returns_hardcoded_classes_and_filters tests.test_如意资源.TestRuYiSpider.test_get_pic_url_handles_empty_absolute_and_relative_values tests.test_如意资源.TestRuYiSpider.test_request_json_falls_back_to_second_api_after_failure tests.test_如意资源.TestRuYiSpider.test_home_video_content_maps_recommend_list tests.test_如意资源.TestRuYiSpider.test_category_content_uses_default_sub_type_for_main_class tests.test_如意资源.TestRuYiSpider.test_category_content_prefers_extend_type tests.test_如意资源.TestRuYiSpider.test_search_content_returns_empty_for_blank_keyword tests.test_如意资源.TestRuYiSpider.test_search_content_filters_titles_by_keyword -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/如意资源.py py/tests/test_如意资源.py -git commit -m "feat: scaffold ruyi spider" -``` - -### Task 2: Add Detail Parsing And Base Player Branches - -**Files:** -- Modify: `py/tests/test_如意资源.py` -- Modify: `py/如意资源.py` - -- [ ] **Step 1: Write the failing test** - -```python - @patch.object(Spider, "_request_json") - def test_detail_content_maps_metadata_and_play_groups(self, mock_request_json): - mock_request_json.return_value = { - "list": [ - { - "vod_id": "888", - "vod_name": "示例剧", - "vod_pic": "/detail.jpg", - "type_name": "国产剧", - "vod_year": "2026", - "vod_area": "大陆", - "vod_remarks": "更新至2集", - "vod_actor": "张三,李四", - "vod_director": "导演甲", - "vod_content": "一段简介", - "vod_play_from": "如意线路,备用线路", - "vod_play_url": "第1集$https://cdn.example.com/1.m3u8#第2集$https://parser.example.com/play/2", - } - ] - } - result = self.spider.detailContent(["888"]) - vod = result["list"][0] - self.assertEqual(mock_request_json.call_args.args[0], {"ac": "videolist", "ids": "888"}) - self.assertEqual(vod["vod_id"], "888") - self.assertEqual(vod["vod_pic"], "https://ps.ryzypics.com/detail.jpg") - self.assertEqual(vod["type_name"], "国产剧") - self.assertEqual(vod["vod_play_from"], "如意线路$$$备用线路") - self.assertEqual( - vod["vod_play_url"], - "第1集$https://cdn.example.com/1.m3u8#第2集$https://parser.example.com/play/2$$$第1集$https://cdn.example.com/1.m3u8#第2集$https://parser.example.com/play/2", - ) - - def test_detail_content_returns_empty_for_blank_id(self): - self.assertEqual(self.spider.detailContent([""]), {"list": []}) - - def test_parse_play_groups_skips_invalid_entries_and_fills_missing_names(self): - play_from, play_url = self.spider._parse_play_groups("主线", "$https://cdn.example.com/a.m3u8#预告$#https://cdn.example.com/b.mp4") - self.assertEqual(play_from, "主线") - self.assertEqual(play_url, "第1集$https://cdn.example.com/a.m3u8#第3集$https://cdn.example.com/b.mp4") - - def test_player_content_returns_direct_media_url(self): - result = self.spider.playerContent("如意线路", "https://cdn.example.com/1.m3u8", {}) - self.assertEqual(result, {"parse": 0, "playUrl": "", "url": "https://cdn.example.com/1.m3u8", "header": {}}) - - def test_player_content_returns_parser_url_for_non_media_link(self): - result = self.spider.playerContent("如意线路", "https://parser.example.com/play/2", {}) - self.assertEqual(result, {"parse": 1, "playUrl": "", "url": "https://parser.example.com/play/2", "header": {}}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_如意资源.TestRuYiSpider.test_detail_content_maps_metadata_and_play_groups tests.test_如意资源.TestRuYiSpider.test_detail_content_returns_empty_for_blank_id tests.test_如意资源.TestRuYiSpider.test_parse_play_groups_skips_invalid_entries_and_fills_missing_names tests.test_如意资源.TestRuYiSpider.test_player_content_returns_direct_media_url tests.test_如意资源.TestRuYiSpider.test_player_content_returns_parser_url_for_non_media_link -v` -Expected: FAIL with `AttributeError` for missing `detailContent`, `_parse_play_groups`, or `playerContent` - -- [ ] **Step 3: Write minimal implementation** - -```python - def _parse_play_groups(self, play_from, play_url): - groups = [] - episodes = [] - for index, item in enumerate(str(play_url or "").split("#"), start=1): - raw = str(item or "").strip() - if not raw: - continue - if "$" in raw: - name, url = raw.split("$", 1) - else: - name, url = "", raw - name = str(name or "").strip() or f"第{index}集" - url = str(url or "").strip() - if not url: - continue - episodes.append(f"{name}${url}") - line_names = [value.strip() for value in str(play_from or "").split(",") if value.strip()] - if not line_names: - line_names = ["如意资源"] - for line_name in line_names: - groups.append({"from": line_name, "urls": "#".join(episodes)}) - return "$$$".join(group["from"] for group in groups), "$$$".join(group["urls"] for group in groups) - - def detailContent(self, ids): - vod_id = str(ids[0] if isinstance(ids, list) and ids else ids or "").strip() - if not vod_id: - return {"list": []} - data = self._request_json({"ac": "videolist", "ids": vod_id}) - items = data.get("list", []) - if not items: - return {"list": []} - item = items[0] or {} - play_from, play_url = self._parse_play_groups(item.get("vod_play_from", ""), item.get("vod_play_url", "")) - return { - "list": [ - { - "vod_id": str(item.get("vod_id", vod_id)), - "vod_name": str(item.get("vod_name", "")), - "vod_pic": self._get_pic_url(item.get("vod_pic", "")), - "type_name": str(item.get("type_name", "")), - "vod_year": str(item.get("vod_year", "")), - "vod_area": str(item.get("vod_area", "")), - "vod_remarks": str(item.get("vod_remarks", "")), - "vod_actor": str(item.get("vod_actor", "")), - "vod_director": str(item.get("vod_director", "")), - "vod_content": str(item.get("vod_content", "")).strip(), - "vod_play_from": play_from, - "vod_play_url": play_url, - } - ] - } - - def _is_direct_media_url(self, value): - raw = str(value or "").lower() - for suffix in (".m3u8", ".mp4", ".flv", ".avi", ".mkv", ".ts"): - if suffix in raw: - return True - return False - - def playerContent(self, flag, id, vipFlags): - play_id = str(id or "").strip() - if not play_id: - return {"parse": 0, "playUrl": "", "url": "", "header": {}} - if self._is_direct_media_url(play_id): - return {"parse": 0, "playUrl": "", "url": play_id, "header": {}} - return {"parse": 1, "playUrl": "", "url": play_id, "header": {}} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_如意资源.TestRuYiSpider.test_detail_content_maps_metadata_and_play_groups tests.test_如意资源.TestRuYiSpider.test_detail_content_returns_empty_for_blank_id tests.test_如意资源.TestRuYiSpider.test_parse_play_groups_skips_invalid_entries_and_fills_missing_names tests.test_如意资源.TestRuYiSpider.test_player_content_returns_direct_media_url tests.test_如意资源.TestRuYiSpider.test_player_content_returns_parser_url_for_non_media_link -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/如意资源.py py/tests/test_如意资源.py -git commit -m "feat: add ruyi detail and player parsing" -``` - -### Task 3: Harden Empty Paths, Invalid JSON, And Final Verification - -**Files:** -- Modify: `py/tests/test_如意资源.py` -- Modify: `py/如意资源.py` - -- [ ] **Step 1: Write the failing test** - -```python - @patch.object(Spider, "fetch") - def test_request_json_returns_empty_dict_when_all_apis_fail(self, mock_fetch): - mock_fetch.side_effect = [ - FakeResponse(status_code=500, text=""), - FakeResponse(status_code=200, text="{bad json"), - FakeResponse(status_code=404, text=""), - ] - self.assertEqual(self.spider._request_json({"ac": "list"}), {}) - - @patch.object(Spider, "_request_json") - def test_home_video_content_filters_invalid_vod_rows(self, mock_request_json): - mock_request_json.return_value = { - "list": [ - {"vod_id": "0", "vod_name": "无效", "vod_pic": "", "vod_remarks": "", "vod_year": "", "type_id": ""}, - {"vod_id": "", "vod_name": "空ID", "vod_pic": "", "vod_remarks": "", "vod_year": "", "type_id": ""}, - {"vod_id": "501", "vod_name": "", "vod_pic": "null", "vod_remarks": "", "vod_year": "2022", "type_id": "7"}, - ] - } - result = self.spider.homeVideoContent() - self.assertEqual(result["list"], [ - { - "vod_id": "501", - "vod_name": "未知标题", - "vod_pic": "", - "vod_remarks": "2022", - "vod_year": "2022", - "type_id": "7", - } - ]) - - def test_player_content_returns_empty_payload_for_blank_id(self): - self.assertEqual( - self.spider.playerContent("如意线路", "", {}), - {"parse": 0, "playUrl": "", "url": "", "header": {}}, - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd py && python -m unittest tests.test_如意资源.TestRuYiSpider.test_request_json_returns_empty_dict_when_all_apis_fail tests.test_如意资源.TestRuYiSpider.test_home_video_content_filters_invalid_vod_rows tests.test_如意资源.TestRuYiSpider.test_player_content_returns_empty_payload_for_blank_id -v` -Expected: FAIL if `_request_json` leaks exceptions or `_format_vod_list` keeps invalid rows - -- [ ] **Step 3: Write minimal implementation** - -```python - def _request_json(self, params=None, url=""): - if url: - targets = [url] - else: - query = self._build_query(params) - targets = [f"{base}?{query}" if query else base for base in self.api_urls] - for target in targets: - try: - response = self.fetch(target, headers=self.headers, timeout=10) - except Exception: - continue - if response.status_code != 200: - continue - try: - return json.loads(response.text or "{}") - except Exception: - continue - return {} - - def _format_vod_list(self, items): - result = [] - for item in items or []: - current = item or {} - vod_id = str(current.get("vod_id", "")).strip() - if not vod_id or vod_id == "0": - continue - vod_year = str(current.get("vod_year", "")).strip() - result.append( - { - "vod_id": vod_id, - "vod_name": str(current.get("vod_name", "")).strip() or "未知标题", - "vod_pic": self._get_pic_url(current.get("vod_pic", "")), - "vod_remarks": str(current.get("vod_remarks", "")).strip() or vod_year, - "vod_year": vod_year, - "type_id": str(current.get("type_id", "")).strip(), - } - ) - return result -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd py && python -m unittest tests.test_如意资源.TestRuYiSpider.test_request_json_returns_empty_dict_when_all_apis_fail tests.test_如意资源.TestRuYiSpider.test_home_video_content_filters_invalid_vod_rows tests.test_如意资源.TestRuYiSpider.test_player_content_returns_empty_payload_for_blank_id -v` -Expected: PASS - -- [ ] **Step 5: Run full module test suite** - -Run: `cd py && python -m unittest tests.test_如意资源 -v` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add py/如意资源.py py/tests/test_如意资源.py -git commit -m "feat: finalize ruyi spider" -``` diff --git a/py/docs/superpowers/plans/2026-04-24-tingyoufm-spider.md b/py/docs/superpowers/plans/2026-04-24-tingyoufm-spider.md deleted file mode 100644 index 7007ec3..0000000 --- a/py/docs/superpowers/plans/2026-04-24-tingyoufm-spider.md +++ /dev/null @@ -1,290 +0,0 @@ -# 听友FM Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 为 `https://tingyou.fm` 新增一个可离线测试的 Python Spider,覆盖首页、分类、搜索、详情和播放主链路。 - -**Architecture:** 站点文件 `py/听友FM.py` 采用“页面解析为主、播放接口为主链”的结构。列表和详情统一复用 HTML/Nuxt 解析 helper,播放链路单独收敛到 payload 加解密和直链提取 helper,并在失败时回退到播放页 URL。 - -**Tech Stack:** Python, `unittest`, `unittest.mock`, `json`, `re`, `base.spider.Spider`, `Crypto.Cipher.AES` - ---- - -### Task 1: Scaffold Spider And Red Tests - -**Files:** -- Create: `py/听友FM.py` -- Create: `py/tests/test_听友FM.py` -- Test: `py/tests/test_听友FM.py` - -- [ ] **Step 1: Write the failing test** - -```python -def test_home_content_extracts_categories_and_album_cards(self): - result = self.spider.homeContent(False) - self.assertEqual(result["class"][0], {"type_id": "46", "type_name": "有声小说"}) - self.assertEqual(result["list"][0]["vod_id"], "1001") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest tests.test_听友FM.TestTingYouFMSpider.test_home_content_extracts_categories_and_album_cards -v` -Expected: FAIL with missing module `听友FM.py` or missing `homeContent` - -- [ ] **Step 3: Write minimal implementation** - -```python -class Spider(BaseSpider): - def __init__(self): - self.name = "听友FM" - self.host = "https://tingyou.fm" - self.classes = [{"type_id": "46", "type_name": "有声小说"}] - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": list(self.classes), "list": []} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest tests.test_听友FM.TestTingYouFMSpider.test_home_content_extracts_categories_and_album_cards -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/听友FM.py py/tests/test_听友FM.py -git commit -m "feat: scaffold tingyoufm spider" -``` - -### Task 2: Implement HTML And Nuxt Parsing - -**Files:** -- Modify: `py/听友FM.py` -- Modify: `py/tests/test_听友FM.py` -- Test: `py/tests/test_听友FM.py` - -- [ ] **Step 1: Write the failing test** - -```python -@patch.object(Spider, "fetch") -def test_category_and_search_prefer_nuxt_and_fallback_to_dom(self, mock_fetch): - mock_fetch.side_effect = [ - SimpleNamespace(status_code=200, text=CATEGORY_HTML_WITH_NUXT), - SimpleNamespace(status_code=200, text=SEARCH_HTML_WITHOUT_NUXT), - ] - category = self.spider.categoryContent("46", "2", False, {}) - search = self.spider.searchContent("鬼吹灯", False, "1") - self.assertEqual(category["page"], 2) - self.assertEqual(category["list"][0]["vod_id"], "2001") - self.assertEqual(search["list"][0]["vod_id"], "3001") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest tests.test_听友FM.TestTingYouFMSpider.test_category_and_search_prefer_nuxt_and_fallback_to_dom -v` -Expected: FAIL with missing `categoryContent`, `searchContent`, or wrong parsed fields - -- [ ] **Step 3: Write minimal implementation** - -```python -def _decode_nuxt_value(self, table, node, seen=None): - if seen is None: - seen = {} - return node - -def _parse_album_card(self, html): - return {"vod_id": "2001", "vod_name": "示例专辑", "vod_pic": "", "vod_remarks": ""} - -def categoryContent(self, tid, pg, filter, extend): - html = self._get_html(f"/categories/{tid}?sort=comprehensive&page={pg}") - data = self._parse_category_nuxt(html, tid) - items = data or [] - return {"page": int(pg), "limit": len(items), "total": len(items), "list": items} - -def searchContent(self, key, quick, pg="1"): - if not str(key).strip(): - return {"page": 1, "limit": 0, "total": 0, "list": []} - html = self._get_html(f"/search?q={quote(key)}") - items = self._parse_search_nuxt(html) or self._parse_search_dom(html) - return {"page": int(pg), "limit": len(items), "total": len(items), "list": items} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest tests.test_听友FM.TestTingYouFMSpider.test_category_and_search_prefer_nuxt_and_fallback_to_dom -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/听友FM.py py/tests/test_听友FM.py -git commit -m "feat: add tingyoufm list and search parsing" -``` - -### Task 3: Implement Detail Parsing - -**Files:** -- Modify: `py/听友FM.py` -- Modify: `py/tests/test_听友FM.py` -- Test: `py/tests/test_听友FM.py` - -- [ ] **Step 1: Write the failing test** - -```python -@patch.object(Spider, "fetch") -def test_detail_content_extracts_album_and_playlist(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=DETAIL_HTML) - result = self.spider.detailContent(["1001"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "鬼吹灯") - self.assertEqual(vod["vod_play_from"], "听友FM") - self.assertEqual(vod["vod_play_url"], "第1集$1001|1#第2集$1001|2") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest tests.test_听友FM.TestTingYouFMSpider.test_detail_content_extracts_album_and_playlist -v` -Expected: FAIL with missing `detailContent` or empty playlist - -- [ ] **Step 3: Write minimal implementation** - -```python -def detailContent(self, ids): - album_id = str((ids or [""])[0]) - html = self._get_html(f"/albums/{album_id}") - vod = self._parse_detail_page(html, album_id) - return {"list": [vod] if vod else []} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest tests.test_听友FM.TestTingYouFMSpider.test_detail_content_extracts_album_and_playlist -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/听友FM.py py/tests/test_听友FM.py -git commit -m "feat: add tingyoufm detail parsing" -``` - -### Task 4: Implement Payload Crypto Helpers - -**Files:** -- Modify: `py/听友FM.py` -- Modify: `py/tests/test_听友FM.py` -- Test: `py/tests/test_听友FM.py` - -- [ ] **Step 1: Write the failing test** - -```python -def test_encrypt_payload_prefixes_version_and_decrypts_v1_payload(self): - payload = self.spider._encrypt_payload('{"album_id":1001,"chapter_idx":1}') - self.assertTrue(payload.startswith("01")) - plain = self.spider._decrypt_payload(self._build_v1_response('{"url":"https://a.test/1.m4a"}')) - self.assertEqual(plain, '{"url":"https://a.test/1.m4a"}') - -def test_decrypt_v2_payload_reverses_cipher_before_decoder(self): - self.spider._xchacha_decrypt = lambda key, nonce, cipher: self.assertEqual(cipher, b"abc") or b'{"url":"https://a.test/2.m4a"}' - raw_hex = self._build_v2_response_with_reversed_cipher() - plain = self.spider._decrypt_payload(raw_hex) - self.assertIn("2.m4a", plain) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest tests.test_听友FM.TestTingYouFMSpider.test_encrypt_payload_prefixes_version_and_decrypts_v1_payload tests.test_听友FM.TestTingYouFMSpider.test_decrypt_v2_payload_reverses_cipher_before_decoder -v` -Expected: FAIL with missing crypto helper behavior - -- [ ] **Step 3: Write minimal implementation** - -```python -def _encrypt_payload(self, plain_text): - iv = bytes(range(12)) - return "01" + iv.hex() + plain_text.encode("utf-8").hex() - -def _decrypt_payload(self, hex_text): - version = raw[0] - if version == 1: - iv = raw[1:13] - cipher = raw[13:] - return self._decrypt_v1(iv, cipher) - if version == 2: - nonce = raw[1:25] - cipher = raw[25:][::-1] - return self._xchacha_decrypt(self.payload_key, nonce, cipher).decode("utf-8") -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest tests.test_听友FM.TestTingYouFMSpider.test_encrypt_payload_prefixes_version_and_decrypts_v1_payload tests.test_听友FM.TestTingYouFMSpider.test_decrypt_v2_payload_reverses_cipher_before_decoder -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add py/听友FM.py py/tests/test_听友FM.py -git commit -m "feat: add tingyoufm payload crypto helpers" -``` - -### Task 5: Implement Player Fallback And Final Verification - -**Files:** -- Modify: `py/听友FM.py` -- Modify: `py/tests/test_听友FM.py` -- Test: `py/tests/test_听友FM.py` - -- [ ] **Step 1: Write the failing test** - -```python -@patch.object(Spider, "_api_post") -def test_player_content_prefers_api_url_and_falls_back_to_audio_page(self, mock_api_post): - mock_api_post.return_value = {"payload": self._build_v1_response('{"url":"https://audio.test/play.m4a"}')} - api_result = self.spider.playerContent("听友FM", "1001|1", {}) - self.assertEqual(api_result["parse"], 0) - self.assertEqual(api_result["url"], "https://audio.test/play.m4a") - - mock_api_post.side_effect = RuntimeError("boom") - fallback_result = self.spider.playerContent("听友FM", "1001|1", {}) - self.assertEqual(fallback_result["parse"], 1) - self.assertEqual(fallback_result["url"], "https://tingyou.fm/audios/1001/1") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest tests.test_听友FM.TestTingYouFMSpider.test_player_content_prefers_api_url_and_falls_back_to_audio_page -v` -Expected: FAIL with missing player resolution or fallback behavior - -- [ ] **Step 3: Write minimal implementation** - -```python -def playerContent(self, flag, id, vipFlags): - album_id, chapter_idx = str(id).split("|", 1) - fallback = f"{self.host}/audios/{album_id}/{chapter_idx}" - try: - payload = self._api_post("/api/play_token", {"album_id": int(album_id), "chapter_idx": int(chapter_idx)}) - play_url = self._extract_play_url(payload) - if play_url: - return {"parse": 0, "url": play_url, "header": self._get_headers()} - except Exception: - pass - return {"parse": 1, "url": fallback, "header": self._get_headers()} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest tests.test_听友FM -v` -Expected: PASS for all `TestTingYouFMSpider` tests - -- [ ] **Step 5: Commit** - -```bash -git add py/听友FM.py py/tests/test_听友FM.py -git commit -m "feat: complete tingyoufm spider" -``` diff --git a/py/docs/superpowers/plans/2026-04-26-feikuai-tv-spider.md b/py/docs/superpowers/plans/2026-04-26-feikuai-tv-spider.md deleted file mode 100644 index a2651ea..0000000 --- a/py/docs/superpowers/plans/2026-04-26-feikuai-tv-spider.md +++ /dev/null @@ -1,575 +0,0 @@ -# 飞快TV Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 新增一个固定站点 `https://feikuai.tv` 的 Python Spider,支持分类、搜索、详情、站内播放和网盘分组。 - -**Architecture:** 采用单文件 Spider 实现,所有站点逻辑放在 `py/飞快TV.py`。列表和搜索直接解析静态 HTML,详情页同时提取站内播放分组和网盘分组,播放页只覆盖 `player_aaaa` 的 `encrypt=1/2` 两条解链路径,失败时回退解析页。 - -**Tech Stack:** Python 3、`base.spider.Spider`、`unittest`、`unittest.mock`、内置 `json` / `re` / `base64` / `urllib.parse` - ---- - -### Task 1: 搭建 Spider 骨架与基础测试 - -**Files:** -- Create: `py/飞快TV.py` -- Create: `py/tests/test_飞快TV.py` -- Test: `py/tests/test_飞快TV.py` - -- [ ] **Step 1: Write the failing tests** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("feikuai_spider", str(ROOT / "飞快TV.py")).load_module() -Spider = MODULE.Spider - - -class TestFeikuaiSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_expected_classes(self): - result = self.spider.homeContent(False) - self.assertEqual( - result["class"], - [ - {"type_id": "1", "type_name": "电影"}, - {"type_id": "2", "type_name": "剧集"}, - {"type_id": "3", "type_name": "综艺"}, - {"type_id": "4", "type_name": "动漫"}, - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_飞快TV.py -v` -Expected: FAIL with `FileNotFoundError` or import failure because `py/飞快TV.py` does not exist yet. - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import sys - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "飞快TV" - self.host = "https://feikuai.tv" - self.user_agent = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/130.0.0.0 Safari/537.36" - ) - self.headers = { - "User-Agent": self.user_agent, - "Referer": self.host + "/", - "Origin": self.host, - } - self.classes = [ - {"type_id": "1", "type_name": "电影"}, - {"type_id": "2", "type_name": "剧集"}, - {"type_id": "3", "type_name": "综艺"}, - {"type_id": "4", "type_name": "动漫"}, - ] - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.classes} - - def homeVideoContent(self): - return {"list": []} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_飞快TV.py -v` -Expected: PASS for `test_home_content_returns_expected_classes` and `test_home_video_content_returns_empty_list`. - -- [ ] **Step 5: Commit** - -```bash -git add py/飞快TV.py py/tests/test_飞快TV.py -git commit -m "feat: add feikuai spider skeleton" -``` - -### Task 2: 实现分类与搜索解析 - -**Files:** -- Modify: `py/飞快TV.py` -- Modify: `py/tests/test_飞快TV.py` -- Test: `py/tests/test_飞快TV.py` - -- [ ] **Step 1: Write the failing tests** - -```python -from unittest.mock import patch - - @patch.object(Spider, "_request_html") - def test_category_content_parses_short_vod_id(self, mock_request_html): - mock_request_html.return_value = """ - - -
更新至10集
-
- """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://feikuai.tv/vodshow/2--------3---.html", - ) - self.assertEqual( - result["list"], - [ - { - "vod_id": "/voddetail/12345.html", - "vod_name": "分类影片", - "vod_pic": "https://feikuai.tv/cover.jpg", - "vod_remarks": "更新至10集", - } - ], - ) - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_parses_cards_and_blank_keyword(self, mock_request_html): - blank = self.spider.searchContent("", False, "1") - self.assertEqual(blank, {"page": 1, "limit": 0, "total": 0, "list": []}) - mock_request_html.assert_not_called() - - mock_request_html.return_value = """ -
- -
-
搜索命中
-
HD
-
- """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://feikuai.tv/label/search_ajax.html?wd=%E7%B9%81%E8%8A%B1&by=time&order=desc&page=2", - ) - self.assertEqual(result["list"][0]["vod_id"], "/voddetail/67890.html") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_飞快TV.py -v` -Expected: FAIL with `AttributeError` for missing `_request_html`, `categoryContent`, or `searchContent`. - -- [ ] **Step 3: Write minimal implementation** - -```python -import re -from urllib.parse import quote, urljoin - - def _build_url(self, value): - raw = str(value 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 _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=self.headers, timeout=10) - if response.status_code != 200: - return "" - return str(response.text or "") - - def _parse_category_cards(self, html): - root = self.html(html or "") - if root is None: - return [] - items = [] - for node in root.xpath("//a[contains(@class,'module-poster-item')]"): - vod_id = self._clean_text("".join(node.xpath("./@href"))) - vod_name = self._clean_text("".join(node.xpath("./@title"))) or self._clean_text( - "".join(node.xpath(".//*[contains(@class,'module-poster-item-title')][1]//text()")) - ) - vod_pic = self._clean_text("".join(node.xpath(".//img[contains(@class,'lazy')][1]/@data-original"))) - vod_remarks = self._clean_text("".join(node.xpath(".//*[contains(@class,'module-item-note')][1]//text()"))) - if vod_id and vod_name: - items.append( - { - "vod_id": vod_id, - "vod_name": vod_name, - "vod_pic": self._build_url(vod_pic), - "vod_remarks": vod_remarks, - } - ) - 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,'module-card-item') and contains(@class,'module-item')]"): - vod_id = self._clean_text("".join(node.xpath(".//a[contains(@class,'module-card-item-poster')][1]/@href"))) - vod_name = self._clean_text("".join(node.xpath(".//*[contains(@class,'module-card-item-title')][1]//strong/text()"))) - vod_pic = self._clean_text("".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@data-original"))) - vod_remarks = self._clean_text("".join(node.xpath(".//*[contains(@class,'module-item-note')][1]//text()"))) - if vod_id and vod_name: - items.append( - { - "vod_id": vod_id, - "vod_name": vod_name, - "vod_pic": self._build_url(vod_pic), - "vod_remarks": vod_remarks, - } - ) - return items - - def categoryContent(self, tid, pg, filter, extend): - page = max(1, int(pg)) - url = self.host + f"/vodshow/{tid}--------{page}---.html" - items = self._parse_category_cards(self._request_html(url)) - return {"page": page, "limit": len(items), "total": len(items), "list": items} - - def searchContent(self, key, quick, pg="1"): - page = max(1, int(pg)) - keyword = self._clean_text(key) - if not keyword: - return {"page": page, "limit": 0, "total": 0, "list": []} - url = self.host + "/label/search_ajax.html?wd=" + quote(keyword) + f"&by=time&order=desc&page={page}" - items = self._parse_search_cards(self._request_html(url)) - return {"page": page, "limit": len(items), "total": len(items), "list": items} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_飞快TV.py -v` -Expected: PASS for home, category, and search tests. - -- [ ] **Step 5: Commit** - -```bash -git add py/飞快TV.py py/tests/test_飞快TV.py -git commit -m "feat: add feikuai category and search parsing" -``` - -### Task 3: 实现详情页在线播放与网盘分组 - -**Files:** -- Modify: `py/飞快TV.py` -- Modify: `py/tests/test_飞快TV.py` -- Test: `py/tests/test_飞快TV.py` - -- [ ] **Step 1: Write the failing test** - -```python - @patch.object(Spider, "_request_html") - def test_detail_content_merges_online_and_pan_groups(self, mock_request_html): - mock_request_html.return_value = """ -

示例影片

-
-
这里是简介
-
-
线路A
-
线路B
-
- -
- 第1集 -
-
-
-

夸克资源@分享一

-

https://pan.quark.cn/s/demo1

-
-
-

百度合集@分享二

-

https://pan.baidu.com/s/demo2

-
-
- """ - result = self.spider.detailContent(["/voddetail/1.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "/voddetail/1.html") - self.assertEqual(vod["vod_name"], "示例影片") - self.assertEqual(vod["vod_pic"], "https://feikuai.tv/detail.jpg") - self.assertEqual(vod["vod_content"], "这里是简介") - self.assertEqual(vod["vod_play_from"], "线路A$$$线路B$$$quark$$$baidu") - self.assertEqual( - vod["vod_play_url"], - "第1集$/vodplay/1-1-1.html#第2集$/vodplay/1-1-2.html$$$第1集$/vodplay/1-2-1.html$$$夸克资源$https://pan.quark.cn/s/demo1$$$百度合集$https://pan.baidu.com/s/demo2", - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_飞快TV.py -v` -Expected: FAIL with missing `detailContent` or empty result assertions. - -- [ ] **Step 3: Write minimal implementation** - -```python - def _detect_pan_type(self, url): - value = str(url or "").strip() - if "pan.quark.cn" in value: - return "quark" - if "drive.uc.cn" in value: - return "uc" - if "alipan.com" in value or "aliyundrive.com" in value: - return "aliyun" - if "pan.baidu.com" in value: - return "baidu" - return "pan" - - def _join_group_urls(self, groups): - return "$$$".join("#".join(group) for group in groups if group) - - def detailContent(self, ids): - vod_id = str(ids[0] if isinstance(ids, list) and ids else ids or "").strip() - if not vod_id: - return {"list": []} - html = self._request_html(self.host + vod_id) - root = self.html(html or "") - if root is None: - return {"list": []} - - title = self._clean_text("".join(root.xpath("//h1[1]//text()"))) - pic = self._clean_text( - "".join(root.xpath("//*[contains(@class,'module-item-pic')]//img[1]/@data-original")) - ) - content = self._clean_text( - "".join( - root.xpath( - "//*[contains(@class,'module-info-introduction-content')][1]//text()" - ) - ) - ) - - play_from = [] - play_urls = [] - online_names = [ - self._clean_text("".join(node.xpath(".//text()"))) - for node in root.xpath( - "//div[contains(@class,'module-tab-items-box')]/*[contains(@class,'module-tab-item')][not(@onclick)]" - ) - ] - online_lists = root.xpath( - "//div[contains(@class,'module-list') and contains(@class,'tab-list')][not(contains(@class,'module-downlist'))]" - ) - for index, node in enumerate(online_lists): - episodes = [] - for item in node.xpath(".//a[contains(@class,'module-play-list-link')]"): - name = self._clean_text("".join(item.xpath(".//text()"))) - href = self._clean_text("".join(item.xpath("./@href"))) - if name and href: - episodes.append(f\"{name}${href}\") - if episodes: - play_from.append(online_names[index] if index < len(online_names) and online_names[index] else f\"线路{index + 1}\") - play_urls.append(episodes) - - pan_groups = {} - for node in root.xpath("//div[contains(@class,'module-list')]/*[contains(@class,'tab-content')]"): - raw_name = self._clean_text("".join(node.xpath(".//h4[1]//text()"))) - pan_name = raw_name.split("@", 1)[0].strip() if raw_name else "网盘资源" - pan_url = self._clean_text("".join(node.xpath(".//p[1]//text()"))) - if not pan_url.startswith("http"): - continue - pan_type = self._detect_pan_type(pan_url) - pan_groups.setdefault(pan_type, []).append(f\"{pan_name}${pan_url}\") - - for key, values in pan_groups.items(): - play_from.append(key) - play_urls.append(values) - - vod = { - "vod_id": vod_id, - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_content": content, - "vod_remarks": "", - "vod_play_from": "$$$".join(play_from), - "vod_play_url": self._join_group_urls(play_urls), - } - return {"list": [vod]} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_飞快TV.py -v` -Expected: PASS for detail merge test and previous tests. - -- [ ] **Step 5: Commit** - -```bash -git add py/飞快TV.py py/tests/test_飞快TV.py -git commit -m "feat: add feikuai detail and pan groups" -``` - -### Task 4: 实现播放页直链解析与回退 - -**Files:** -- Modify: `py/飞快TV.py` -- Modify: `py/tests/test_飞快TV.py` -- Test: `py/tests/test_飞快TV.py` - -- [ ] **Step 1: Write the failing tests** - -```python - def test_base64decode_decodes_fixture(self): - self.assertEqual(self.spider._base64decode("aHR0cHM6Ly9jZG4uZXhhbXBsZS5jb20vdjIubTN1OA=="), "https://cdn.example.com/v2.m3u8") - - @patch.object(Spider, "_request_html") - def test_player_content_supports_encrypt_1_and_encrypt_2(self, mock_request_html): - mock_request_html.side_effect = [ - '', - '', - ] - direct = self.spider.playerContent("feikuai", "/vodplay/1-1-1.html", {}) - encoded = self.spider.playerContent("feikuai", "/vodplay/1-1-2.html", {}) - self.assertEqual(direct["parse"], 0) - self.assertEqual(direct["url"], "https://cdn.example.com/v1.m3u8") - self.assertEqual(encoded["parse"], 0) - self.assertEqual(encoded["url"], "https://cdn.example.com/v2.m3u8") - - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_when_script_missing(self, mock_request_html): - mock_request_html.return_value = "empty" - result = self.spider.playerContent("feikuai", "/vodplay/1-1-3.html", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://feikuai.tv/vodplay/1-1-3.html") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_飞快TV.py -v` -Expected: FAIL with missing `_base64decode` / `playerContent` or incorrect assertions. - -- [ ] **Step 3: Write minimal implementation** - -```python -import base64 -from urllib.parse import unquote - - def _base64decode(self, value): - try: - return base64.b64decode(str(value or "")).decode("utf-8") - except Exception: - return "" - - def _extract_player_data(self, html): - matched = re.search(r"player_aaaa\s*=\s*(\{[\s\S]*?\})\s*", str(html or "")) - if not matched: - return {} - try: - return json.loads(matched.group(1)) - except Exception: - return {} - - def playerContent(self, flag, id, vipFlags): - target = self.host + str(id or "") - if str(id or "").startswith(("http://", "https://")) and any( - str(id).lower().split("?")[0].endswith(ext) for ext in [".m3u8", ".mp4", ".flv"] - ): - return {"parse": 0, "jx": 0, "url": id} - - data = self._extract_player_data(self._request_html(target)) - raw_url = str(data.get("url") or "") - encrypt = str(data.get("encrypt") or "") - media_url = "" - if encrypt == "1": - media_url = unquote(raw_url) - elif encrypt == "2": - media_url = unquote(self._base64decode(raw_url)) - elif raw_url: - media_url = raw_url - - if media_url.startswith("http"): - return {"parse": 0, "jx": 0, "url": media_url} - return {"parse": 1, "jx": 1, "url": target} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_飞快TV.py -v` -Expected: PASS for all tests in `py/tests/test_飞快TV.py`. - -- [ ] **Step 5: Commit** - -```bash -git add py/飞快TV.py py/tests/test_飞快TV.py -git commit -m "feat: add feikuai player parsing" -``` - -### Task 5: 验证与收尾 - -**Files:** -- Modify: `py/飞快TV.py` -- Modify: `py/tests/test_飞快TV.py` -- Test: `py/tests/test_飞快TV.py` - -- [ ] **Step 1: Run the focused module test suite** - -Run: `uv run python -m unittest py/tests/test_飞快TV.py -v` -Expected: PASS with all新增测试通过,无网络访问。 - -- [ ] **Step 2: Refine code only if test output exposes duplication or brittle parsing** - -```python -# 只允许整理已通过测试覆盖的辅助方法,例如: -def _parse_text(self, node, xpath): - return self._clean_text("".join(node.xpath(xpath))) -``` - -- [ ] **Step 3: Re-run the focused module test suite** - -Run: `uv run python -m unittest py/tests/test_飞快TV.py -v` -Expected: PASS again after any small refactor. - -- [ ] **Step 4: Commit final cleanup if Step 2 changed code** - -```bash -git add py/飞快TV.py py/tests/test_飞快TV.py -git commit -m "refactor: tidy feikuai spider helpers" -``` - -## Self-Review - -- Spec coverage: - - 固定站点、四个分类、空首页视频由 Task 1 覆盖。 - - 分类页和搜索页短路径解析由 Task 2 覆盖。 - - 详情页站内播放与网盘分组由 Task 3 覆盖。 - - `player_aaaa` 的 `encrypt=1/2` 与回退路径由 Task 4 覆盖。 -- Placeholder scan: - - 计划中没有 `TODO`、`TBD`、`implement later` 之类占位词。 - - 所有测试步骤都给出具体命令与预期结果。 -- Type consistency: - - Spider 方法名统一使用 `homeContent`、`homeVideoContent`、`categoryContent`、`searchContent`、`detailContent`、`playerContent`。 - - 测试里的 `vod_id`、`vod_play_from`、`vod_play_url` 与实现步骤保持一致。 diff --git a/py/docs/superpowers/plans/2026-04-26-maitian-spider.md b/py/docs/superpowers/plans/2026-04-26-maitian-spider.md deleted file mode 100644 index 7e188e1..0000000 --- a/py/docs/superpowers/plans/2026-04-26-maitian-spider.md +++ /dev/null @@ -1,558 +0,0 @@ -# 麦田影院 Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 新增一个固定站点 `https://www.mtyy5.com` 的 Python Spider,支持固定分类、分类列表、详情分组、播放页解链,并保持无搜索行为。 - -**Architecture:** 采用单文件 Spider 加单测文件的实现方式,站点逻辑全部放在 `py/麦田影院.py`。分类和详情直接解析静态 HTML,播放页先解析 `player_data`,非直链时通过 `art.php?get_signed_url=1` 和 `art.php` 两步拿到最终 `jmurl`,失败时回退解析页。 - -**Tech Stack:** Python 3、`base.spider.Spider`、`unittest`、`unittest.mock`、内置 `json` / `re` / `urllib.parse` - ---- - -### Task 1: 建立骨架与基础行为测试 - -**Files:** -- Create: `py/麦田影院.py` -- Create: `py/tests/test_麦田影院.py` -- Test: `py/tests/test_麦田影院.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("maitian_spider", str(ROOT / "麦田影院.py")).load_module() -Spider = MODULE.Spider - - -class TestMaiTianSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_expected_classes(self): - result = self.spider.homeContent(False) - self.assertEqual( - result["class"], - [ - {"type_id": "1", "type_name": "电影"}, - {"type_id": "2", "type_name": "电视剧"}, - {"type_id": "4", "type_name": "动漫"}, - {"type_id": "3", "type_name": "综艺"}, - {"type_id": "26", "type_name": "短剧"}, - {"type_id": "25", "type_name": "少儿"}, - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_麦田影院.py -v` -Expected: FAIL with `FileNotFoundError` or import failure because `py/麦田影院.py` does not exist yet. - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import sys - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - def __init__(self): - self.name = "麦田影院" - self.host = "https://www.mtyy5.com" - self.user_agent = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/130.0.0.0 Safari/537.36" - ) - self.headers = { - "User-Agent": self.user_agent, - "Referer": self.host + "/", - "Origin": self.host, - } - self.classes = [ - {"type_id": "1", "type_name": "电影"}, - {"type_id": "2", "type_name": "电视剧"}, - {"type_id": "4", "type_name": "动漫"}, - {"type_id": "3", "type_name": "综艺"}, - {"type_id": "26", "type_name": "短剧"}, - {"type_id": "25", "type_name": "少儿"}, - ] - - def init(self, extend=""): - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": self.classes} - - def homeVideoContent(self): - return {"list": []} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_麦田影院.py -v` -Expected: PASS for `test_home_content_returns_expected_classes` and `test_home_video_content_returns_empty_list`. - -- [ ] **Step 5: Commit** - -```bash -git add py/麦田影院.py py/tests/test_麦田影院.py -git commit -m "feat: add maitian spider skeleton" -``` - -### Task 2: 实现分类解析与无搜索行为 - -**Files:** -- Modify: `py/麦田影院.py` -- Modify: `py/tests/test_麦田影院.py` -- Test: `py/tests/test_麦田影院.py` - -- [ ] **Step 1: Write the failing tests** - -```python -from unittest.mock import patch - - @patch.object(Spider, "_request_html") - def test_category_content_builds_url_and_parses_cards(self, mock_request_html): - mock_request_html.return_value = """ -
- - - - 更新至10集 -
- """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.mtyy5.com/vodshow/2--------3---.html", - ) - self.assertEqual( - result["list"], - [ - { - "vod_id": "/voddetail/123.html", - "vod_name": "分类影片", - "vod_pic": "https://www.mtyy5.com/cover.jpg", - "vod_remarks": "更新至10集", - } - ], - ) - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_returns_empty_result_without_network(self, mock_request_html): - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual(result, {"page": 2, "limit": 0, "total": 0, "list": []}) - mock_request_html.assert_not_called() -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_麦田影院.py -v` -Expected: FAIL with `AttributeError` for missing `_request_html`, `categoryContent`, or `searchContent`. - -- [ ] **Step 3: Write minimal implementation** - -```python -import re -from urllib.parse import urljoin - - def _stringify(self, value): - return "" if value is None else str(value) - - def _clean_text(self, text): - raw = self._stringify(text).replace(" ", " ").replace("\xa0", " ") - return re.sub(r"\s+", " ", raw).strip() - - def _build_url(self, value): - raw = self._stringify(value).strip() - if not raw: - return "" - if raw.startswith(("http://", "https://")): - return raw - if raw.startswith("//"): - return "https:" + raw - return urljoin(self.host + "/", raw) - - def _request_html(self, path_or_url, referer=None): - target = path_or_url if self._stringify(path_or_url).startswith("http") else self._build_url(path_or_url) - headers = dict(self.headers) - headers["Referer"] = referer or self.headers["Referer"] - response = self.fetch(target, headers=headers, timeout=10) - if response.status_code != 200: - return "" - return response.text or "" - - def _parse_cards(self, html): - root = self.html(self._stringify(html)) - if root is None: - return [] - items = [] - for node in root.xpath("//div[contains(@class,'public-list-box')]"): - href = self._clean_text("".join(node.xpath(".//a[contains(@class,'public-list-exp')][1]/@href"))) - title = self._clean_text("".join(node.xpath(".//a[contains(@class,'public-list-exp')][1]/@title"))) - pic = self._clean_text( - "".join(node.xpath(".//a[contains(@class,'public-list-exp')]//img[1]/@data-src")) - ) or self._clean_text( - "".join(node.xpath(".//a[contains(@class,'public-list-exp')]//img[1]/@src")) - ) - remarks = self._clean_text("".join(node.xpath(".//*[contains(@class,'public-list-prb')][1]//text()"))) - if href and title: - items.append( - { - "vod_id": href, - "vod_name": title, - "vod_pic": self._build_url(pic), - "vod_remarks": remarks, - } - ) - return items - - def categoryContent(self, tid, pg, filter, extend): - page = max(int(self._stringify(pg) or "1"), 1) - url = self.host + f"/vodshow/{tid}--------{page}---.html" - items = self._parse_cards(self._request_html(url)) - return {"page": page, "limit": len(items), "total": len(items), "list": items} - - def searchContent(self, key, quick, pg="1"): - page = max(int(self._stringify(pg) or "1"), 1) - return {"page": page, "limit": 0, "total": 0, "list": []} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_麦田影院.py -v` -Expected: PASS for fixed classes, empty home video, category parsing, and no-network search behavior. - -- [ ] **Step 5: Commit** - -```bash -git add py/麦田影院.py py/tests/test_麦田影院.py -git commit -m "feat: add maitian list parsing" -``` - -### Task 3: 实现详情分组解析 - -**Files:** -- Modify: `py/麦田影院.py` -- Modify: `py/tests/test_麦田影院.py` -- Test: `py/tests/test_麦田影院.py` - -- [ ] **Step 1: Write the failing test** - -```python - @patch.object(Spider, "_request_html") - def test_detail_content_parses_meta_and_play_groups(self, mock_request_html): - mock_request_html.return_value = """ - -

示例影片

-
-
这里是简介
- 线路1 - 线路2 -
- -
-
- -
- - """ - result = self.spider.detailContent(["/voddetail/1.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "/voddetail/1.html") - self.assertEqual(vod["vod_name"], "示例影片") - self.assertEqual(vod["vod_pic"], "https://www.mtyy5.com/detail.jpg") - self.assertEqual(vod["vod_content"], "这里是简介") - self.assertEqual(vod["vod_play_from"], "线路$$$线路") - self.assertEqual( - vod["vod_play_url"], - "第1集$/vodplay/1-1-1.html#第2集$/vodplay/1-1-2.html$$$正片$/vodplay/1-2-1.html", - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_麦田影院.py -v` -Expected: FAIL because `detailContent` does not exist yet or returns no grouped播放数据。 - -- [ ] **Step 3: Write minimal implementation** - -```python - def _extract_detail_pic(self, root): - return self._clean_text( - "".join(root.xpath("//*[contains(@class,'detail-pic') or contains(@class,'vod-img')]//img[1]/@data-src")) - ) or self._clean_text( - "".join(root.xpath("//*[contains(@class,'detail-pic') or contains(@class,'vod-img')]//img[1]/@src")) - ) - - def _extract_detail_content(self, root): - candidates = [ - "//*[contains(@class,'vod_content')][1]//text()", - "//*[contains(@class,'detail-content')][1]//text()", - "//*[contains(@class,'switch-box')][1]//text()", - ] - for xpath in candidates: - text = self._clean_text("".join(root.xpath(xpath))) - if text: - return text - return "" - - def _parse_play_groups(self, root): - names = [] - for node in root.xpath("//a[contains(@class,'swiper-slide')]"): - label = re.sub(r"\d", "", self._clean_text("".join(node.xpath(".//text()")))).strip() - names.append(label) - groups = [] - for index, box in enumerate(root.xpath("//div[contains(@class,'anthology-list-box')]")): - episodes = [] - for item in box.xpath(".//ul[contains(@class,'anthology-list-play')]/li"): - name = self._clean_text("".join(item.xpath(".//text()"))) - href = self._clean_text("".join(item.xpath("./*[1]/@href"))) - if name and href: - episodes.append(f"{name}${href}") - if episodes: - title = names[index] if index < len(names) and names[index] else f"线路{index + 1}" - groups.append((title, "#".join(episodes))) - return groups - - def detailContent(self, ids): - vod_id = self._clean_text(ids[0] if isinstance(ids, list) and ids else ids) - if not vod_id: - return {"list": []} - root = self.html(self._request_html(self.host + vod_id)) - if root is None: - return {"list": []} - groups = self._parse_play_groups(root) - vod = { - "vod_id": vod_id, - "vod_name": self._clean_text("".join(root.xpath("//h1[1]//text()"))), - "vod_pic": self._build_url(self._extract_detail_pic(root)), - "vod_content": self._extract_detail_content(root), - "vod_remarks": "", - "vod_play_from": "$$$".join(item[0] for item in groups), - "vod_play_url": "$$$".join(item[1] for item in groups), - } - return {"list": [vod]} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_麦田影院.py -v` -Expected: PASS including grouped detail parsing with short play IDs. - -- [ ] **Step 5: Commit** - -```bash -git add py/麦田影院.py py/tests/test_麦田影院.py -git commit -m "feat: add maitian detail parsing" -``` - -### Task 4: 实现播放页直链与 `art.php` 解签 - -**Files:** -- Modify: `py/麦田影院.py` -- Modify: `py/tests/test_麦田影院.py` -- Test: `py/tests/test_麦田影院.py` - -- [ ] **Step 1: Write the failing tests** - -```python - @patch.object(Spider, "_request_json") - @patch.object(Spider, "_request_html") - def test_player_content_returns_direct_url(self, mock_request_html, mock_request_json): - mock_request_html.return_value = """ - - """ - result = self.spider.playerContent("线路", "/vodplay/1-1-1.html", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://cdn.example.com/direct.m3u8") - mock_request_json.assert_not_called() - - @patch.object(Spider, "_request_json") - @patch.object(Spider, "_request_html") - def test_player_content_resolves_signed_art_url(self, mock_request_html, mock_request_json): - mock_request_html.return_value = """ - - """ - mock_request_json.side_effect = [ - {"signed_url": "?url=/signed/abc"}, - {"jmurl": "https://cdn.example.com/final.m3u8"}, - ] - result = self.spider.playerContent("线路", "/vodplay/1-1-2.html", {}) - self.assertEqual(result["url"], "https://cdn.example.com/final.m3u8") - self.assertEqual( - mock_request_json.call_args_list[0].args[0], - "https://www.mtyy5.com/static/player/art.php?get_signed_url=1&url=/api.php?id=1", - ) - self.assertEqual( - mock_request_json.call_args_list[1].args[0], - "https://www.mtyy5.com/static/player/art.php?url=/signed/abc", - ) - - @patch.object(Spider, "_request_json") - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_when_player_data_missing(self, mock_request_html, mock_request_json): - mock_request_html.return_value = "empty" - result = self.spider.playerContent("线路", "/vodplay/1-1-3.html", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://www.mtyy5.com/vodplay/1-1-3.html") - mock_request_json.assert_not_called() -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run python -m unittest py/tests/test_麦田影院.py -v` -Expected: FAIL because `_request_json`, `playerContent`, or `player_data` extraction logic is missing. - -- [ ] **Step 3: Write minimal implementation** - -```python -import json -from urllib.parse import unquote - - def _request_json(self, url, referer=None): - target = self._build_url(url) - headers = dict(self.headers) - headers["Referer"] = referer or self.headers["Referer"] - response = self.fetch(target, headers=headers, timeout=10) - if response.status_code != 200: - return {} - try: - return json.loads(response.text or "") - except Exception: - return {} - - def _build_player_headers(self, referer): - return { - "User-Agent": self.user_agent, - "Referer": referer, - } - - def _extract_player_data(self, html): - matched = re.search(r"player_data\s*=\s*(\{.*?\})\s*", self._stringify(html), re.S) - if not matched: - return {} - try: - return json.loads(matched.group(1)) - except Exception: - return {} - - def playerContent(self, flag, id, vipFlags): - play_id = self._clean_text(id) - play_url = self.host + play_id if play_id.startswith("/") else self._build_url(play_id) - player_data = self._extract_player_data(self._request_html(play_url, referer=self.host + "/")) - direct_url = self._clean_text(player_data.get("url")) - if direct_url.startswith(("http://", "https://")): - return { - "parse": 0, - "jx": 0, - "playUrl": "", - "url": direct_url, - "header": self._build_player_headers(play_url), - } - - decoded = unquote(direct_url) - if decoded: - signed = self._request_json( - self.host + f"/static/player/art.php?get_signed_url=1&url={decoded}", - referer=play_url, - ) - signed_url = self._clean_text(signed.get("signed_url")) - if signed_url: - final_data = self._request_json( - self.host + f"/static/player/art.php{signed_url}", - referer=play_url, - ) - final_url = self._clean_text(final_data.get("jmurl")) - if final_url: - return { - "parse": 0, - "jx": 0, - "playUrl": "", - "url": final_url, - "header": self._build_player_headers(play_url), - } - - return { - "parse": 1, - "jx": 1, - "playUrl": "", - "url": play_url, - "header": self._build_player_headers(play_url), - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run python -m unittest py/tests/test_麦田影院.py -v` -Expected: PASS for direct player URL, signed `art.php` resolution, and fallback behavior. - -- [ ] **Step 5: Commit** - -```bash -git add py/麦田影院.py py/tests/test_麦田影院.py -git commit -m "feat: add maitian player parsing" -``` - -### Task 5: 完整验证与收尾 - -**Files:** -- Modify: `py/麦田影院.py` -- Modify: `py/tests/test_麦田影院.py` -- Test: `py/tests/test_麦田影院.py` - -- [ ] **Step 1: Run focused test suite** - -Run: `uv run python -m unittest py/tests/test_麦田影院.py -v` -Expected: all tests PASS. - -- [ ] **Step 2: Run syntax check** - -Run: `uv run python -m py_compile py/麦田影院.py py/tests/test_麦田影院.py` -Expected: no output. - -- [ ] **Step 3: Review scope** - -```python -# Confirm the spider still has no search network behavior, keeps short IDs, -# and does not add pagecount to category or search payloads. -``` - -- [ ] **Step 4: Re-run verification** - -Run: `uv run python -m unittest py/tests/test_麦田影院.py -v` -Expected: all tests still PASS. - -- [ ] **Step 5: Commit** - -```bash -git add py/麦田影院.py py/tests/test_麦田影院.py -git commit -m "feat: add maitian spider" -``` diff --git a/py/docs/superpowers/plans/2026-04-29-shuangxing-spider.md b/py/docs/superpowers/plans/2026-04-29-shuangxing-spider.md deleted file mode 100644 index b9b467e..0000000 --- a/py/docs/superpowers/plans/2026-04-29-shuangxing-spider.md +++ /dev/null @@ -1,479 +0,0 @@ -# 双星 Spider Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在当前 Python 仓库中新增独立单站 `双星` 蜘蛛,支持固定分类、cookie 初始化、分类列表、搜索、详情页网盘线路整理和网盘分享链接透传。 - -**Architecture:** 采用单文件站点脚本 `py/双星.py` 承担全部站点逻辑,内部拆分为 cookie 初始化、请求头生成、HTML 请求、卡片解析、网盘识别和线路组装几个 helper。测试沿用现有 `unittest + SourceFileLoader + mock` 风格,先锁定分类与 cookie 行为,再覆盖列表、搜索、详情和 `playerContent`,全程按 TDD 推进。 - -**Tech Stack:** Python 3, `unittest`, `unittest.mock`, `sys`, `re`, `urllib.parse`, `base.spider.Spider` - ---- - -## File Structure - -- Create: `py/双星.py` - - 实现 `Spider` 类和站点全部逻辑 - - 暴露 `init`、`getName`、`homeContent`、`homeVideoContent`、`categoryContent`、`searchContent`、`detailContent`、`playerContent` - - 私有方法负责 cookie 初始化、请求头构建、HTML 请求、文本清洗、卡片解析、网盘识别和播放线路拼装 -- Create: `py/tests/test_双星.py` - - 使用 `SourceFileLoader` 加载 `py/双星.py` - - 通过内联 HTML 与 `mock` 覆盖分类、cookie、列表、搜索、详情、线路排序和播放透传 - -### Task 1: Scaffold Spider, Categories, Cookie Init, And Pan Detection - -**Files:** -- Create: `py/tests/test_双星.py` -- Create: `py/双星.py` - -- [ ] **Step 1: Write the failing test** - -```python -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("shuangxing_spider", str(ROOT / "双星.py")).load_module() -Spider = MODULE.Spider - - -class FakeResponse: - def __init__(self, status_code=200, text="", cookies=None): - self.status_code = status_code - self.text = text - self.cookies = cookies or {} - self.headers = {} - self.url = "" - - -class TestShuangXingSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - - def test_home_content_exposes_reference_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("ju", "国剧"), - ("zy", "综艺"), - ("mv", "电影"), - ("rh", "日韩"), - ("ym", "英美"), - ("wj", "外剧"), - ("dm", "动漫"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - @patch.object(Spider, "fetch") - def test_init_collects_cookie_pairs_and_headers_include_cookie(self, mock_fetch): - mock_fetch.return_value = FakeResponse(cookies={"foo": "bar", "token": "xyz"}) - self.spider.init() - self.assertEqual(self.spider.cookie, "foo=bar; token=xyz") - self.assertEqual(self.spider._headers()["cookie"], "foo=bar; token=xyz") - - def test_headers_without_cookie_keep_base_headers_only(self): - self.assertEqual( - self.spider._headers(), - { - "User-Agent": Spider.UA, - "Referer": Spider.BASE_URL, - }, - ) - - def test_detect_pan_type_returns_expected_keys(self): - self.assertEqual(self.spider._detect_pan_type("https://pan.quark.cn/s/demo"), "quark") - self.assertEqual(self.spider._detect_pan_type("https://www.alipan.com/s/demo"), "ali") - self.assertEqual(self.spider._detect_pan_type("https://example.com/video"), "") - - -if __name__ == "__main__": - unittest.main() -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_双星.TestShuangXingSpider -v` -Expected: FAIL with `FileNotFoundError` for `双星.py` or missing `Spider` attributes. - -- [ ] **Step 3: Write minimal implementation** - -```python -# coding=utf-8 -import re -import sys - -from base.spider import Spider as BaseSpider - -sys.path.append("..") - - -class Spider(BaseSpider): - BASE_URL = "https://1.star2.cn" - UA = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0" - ) - CATEGORIES = [ - ("ju", "国剧"), - ("zy", "综艺"), - ("mv", "电影"), - ("rh", "日韩"), - ("ym", "英美"), - ("wj", "外剧"), - ("dm", "动漫"), - ] - - def __init__(self): - self.name = "双星" - self.cookie = "" - - def init(self, extend=""): - response = self.fetch( - self.BASE_URL, - headers={"User-Agent": self.UA, "Referer": self.BASE_URL}, - allow_redirects=False, - timeout=15, - ) - if response.status_code != 200: - return None - self.cookie = "; ".join([f"{name}={value}" for name, value in dict(response.cookies).items()]) - return None - - def getName(self): - return self.name - - def homeContent(self, filter): - return {"class": [{"type_id": type_id, "type_name": type_name} for type_id, type_name in self.CATEGORIES]} - - def homeVideoContent(self): - return {"list": []} - - def _headers(self): - headers = {"User-Agent": self.UA, "Referer": self.BASE_URL} - if self.cookie: - headers["cookie"] = self.cookie - return headers - - def _detect_pan_type(self, link): - text = str(link or "").strip().lower() - if "quark" in text: - return "quark" - if "115.com" in text: - return "115" - if "cloud.189.cn" in text: - return "tianyi" - if "drive.uc.cn" in text or "uc.cn" in text: - return "uc" - if "pan.baidu.com" in text: - return "baidu" - if "xunlei" in text: - return "xunlei" - if "123pan" in text: - return "123pan" - if "caiyun" in text or "139.com" in text: - return "yd" - if "aliyundrive" in text or "alipan" in text: - return "ali" - return "" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_双星.TestShuangXingSpider -v` -Expected: PASS for the scaffold tests. - -- [ ] **Step 5: Commit** - -```bash -git add py/双星.py py/tests/test_双星.py -git commit -m "feat: scaffold shuangxing spider" -``` - -### Task 2: Add Category/Search Requests And Card Parsing - -**Files:** -- Modify: `py/tests/test_双星.py` -- Modify: `py/双星.py` - -- [ ] **Step 1: Write the failing test** - -```python -from urllib.parse import quote - - -class TestShuangXingSpider(unittest.TestCase): - @patch.object(Spider, "_get_html") - def test_category_content_builds_reference_url_and_parses_cards(self, mock_get_html): - mock_get_html.return_value = """ - - - - """ - result = self.spider.categoryContent("ju", "3", False, {}) - self.assertEqual(mock_get_html.call_args.args[0], "https://1.star2.cn/ju_3/") - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 15) - self.assertEqual(result["total"], 32) - self.assertEqual( - result["list"], - [ - {"vod_id": "/post/alpha", "vod_name": "示例国剧", "vod_pic": "", "vod_remarks": ""}, - {"vod_id": "/post/beta", "vod_name": "示例综艺", "vod_pic": "", "vod_remarks": ""}, - ], - ) - - @patch.object(Spider, "_get_html") - def test_search_content_builds_reference_url_and_parses_results(self, mock_get_html): - mock_get_html.return_value = """ - - - - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_get_html.call_args.args[0], - f"https://1.star2.cn/search/?keyword={quote('繁花')}&page=2", - ) - self.assertEqual(result["page"], 2) - self.assertEqual( - result["list"], - [{"vod_id": "/post/search", "vod_name": "搜索结果", "vod_pic": "", "vod_remarks": ""}], - ) - - def test_search_content_short_circuits_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_双星.TestShuangXingSpider -v` -Expected: FAIL with missing `_get_html`, `categoryContent`, `searchContent`, or wrong payload shape. - -- [ ] **Step 3: Write minimal implementation** - -```python -from urllib.parse import quote - - - def _clean_text(self, text): - return re.sub(r"\s+", " ", str(text or "").replace("\xa0", " ")).strip() - - def _get_html(self, url): - response = self.fetch(url, headers=self._headers(), timeout=15) - if response.status_code != 200: - return "" - return response.text or "" - - def _parse_cards(self, html): - root = self.html(html) - if root is None: - return [] - items = [] - for node in root.xpath("/html/body/div/div/main/div/ul/li"): - href = "".join(node.xpath(".//div[contains(@class,'a')]//a[1]/@href")).strip() - title = self._clean_text("".join(node.xpath(".//div[contains(@class,'a')]//a[1]//text()"))) - if not href or not title: - continue - items.append({"vod_id": href, "vod_name": title, "vod_pic": "", "vod_remarks": ""}) - return items - - def categoryContent(self, tid, pg, filter, extend): - page = int(pg) - items = self._parse_cards(self._get_html(f"{self.BASE_URL}/{str(tid).strip()}_{page}/")) - return {"page": page, "limit": 15, "total": (page - 1) * 15 + len(items), "list": items} - - def searchContent(self, key, quick, pg="1"): - page = int(pg) - keyword = self._clean_text(key) - if not keyword: - return {"page": page, "total": 0, "list": []} - items = self._parse_cards(self._get_html(f"{self.BASE_URL}/search/?keyword={quote(keyword)}&page={page}")) - return {"page": page, "total": len(items), "list": items} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_双星.TestShuangXingSpider -v` -Expected: PASS for scaffold plus list/search tests. - -- [ ] **Step 5: Commit** - -```bash -git add py/双星.py py/tests/test_双星.py -git commit -m "feat: add shuangxing list and search parsing" -``` - -### Task 3: Add Detail Parsing, Play-Line Assembly, And Player Passthrough - -**Files:** -- Modify: `py/tests/test_双星.py` -- Modify: `py/双星.py` - -- [ ] **Step 1: Write the failing test** - -```python -class TestShuangXingSpider(unittest.TestCase): - @patch.object(Spider, "_get_html") - def test_detail_content_extracts_title_and_sorted_deduplicated_pan_lines(self, mock_get_html): - mock_get_html.return_value = """ - -
-
-

双星示例

-
-
-
-
- - - - -
-
- - """ - result = self.spider.detailContent(["/post/demo"]) - self.assertEqual(mock_get_html.call_args.args[0], "https://1.star2.cn/post/demo") - self.assertEqual( - result, - { - "list": [ - { - "vod_id": "/post/demo", - "vod_name": "双星示例", - "vod_pic": "", - "vod_remarks": "", - "vod_content": "", - "vod_director": "", - "vod_actor": "", - "vod_play_from": "quark$$$baidu", - "vod_play_url": "夸克资源$https://pan.quark.cn/s/q-demo$$$百度资源$https://pan.baidu.com/s/b-demo", - } - ] - }, - ) - - @patch.object(Spider, "_get_html") - def test_detail_content_returns_empty_list_for_blank_html(self, mock_get_html): - mock_get_html.return_value = "" - self.assertEqual(self.spider.detailContent(["/post/missing"]), {"list": []}) - - def test_player_content_passthroughs_supported_pan_links(self): - self.assertEqual( - self.spider.playerContent("quark", "https://pan.quark.cn/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.quark.cn/s/demo"}, - ) - - def test_player_content_rejects_unknown_links(self): - self.assertEqual( - self.spider.playerContent("site", "https://example.com/video", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run from `py/`: `python -m unittest tests.test_双星.TestShuangXingSpider -v` -Expected: FAIL with missing `detailContent`, `playerContent`, or wrong `vod_play_from` / `vod_play_url`. - -- [ ] **Step 3: Write minimal implementation** - -```python -from urllib.parse import urljoin - - - PAN_TITLES = { - "quark": "夸克资源", - "ali": "阿里资源", - "115": "115资源", - "tianyi": "天翼资源", - "uc": "UC资源", - "baidu": "百度资源", - "xunlei": "迅雷资源", - "123pan": "123资源", - "yd": "移动云盘资源", - } - PAN_ORDER = ["quark", "ali", "115", "tianyi", "uc", "baidu", "xunlei", "123pan", "yd"] - - def _build_pan_lines(self, share_links): - groups = {} - seen = set() - for link in share_links: - raw = str(link or "").strip() - pan_type = self._detect_pan_type(raw) - if not raw or not pan_type or raw in seen: - continue - seen.add(raw) - groups.setdefault(pan_type, []).append(f"{self.PAN_TITLES[pan_type]}${raw}") - if not groups: - return {"vod_play_from": "", "vod_play_url": ""} - names = [name for name in self.PAN_ORDER if name in groups] - return { - "vod_play_from": "$$$".join(names), - "vod_play_url": "$$$".join("#".join(groups[name]) for name in names), - } - - def detailContent(self, ids): - vod_id = str((ids or [""])[0] or "").strip() - if not vod_id: - return {"list": []} - html = self._get_html(urljoin(self.BASE_URL, vod_id)) - root = self.html(html) - if root is None: - return {"list": []} - title = self._clean_text( - "".join(root.xpath("/html/body/div/div[contains(@class,'s20erx') and contains(@class,'erx-content')]/main/article/h1//text()")) - ) - share_links = [ - str(value).strip() - for value in root.xpath("//*[@id='maximg']//div[contains(@class,'dlipp-cont-bd')]//a[@href]/@href") - ] - play = self._build_pan_lines(share_links) - return { - "list": [ - { - "vod_id": vod_id, - "vod_name": title, - "vod_pic": "", - "vod_remarks": "", - "vod_content": "", - "vod_director": "", - "vod_actor": "", - "vod_play_from": play["vod_play_from"], - "vod_play_url": play["vod_play_url"], - } - ] - } - - def playerContent(self, flag, id, vipFlags): - target = str(id or "").strip() - if self._detect_pan_type(target): - return {"parse": 0, "playUrl": "", "url": target} - return {"parse": 0, "playUrl": "", "url": ""} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run from `py/`: `python -m unittest tests.test_双星.TestShuangXingSpider -v` -Expected: PASS for the complete `tests.test_双星` suite. - -- [ ] **Step 5: Commit** - -```bash -git add py/双星.py py/tests/test_双星.py -git commit -m "feat: complete shuangxing spider" -``` diff --git a/py/docs/superpowers/specs/2026-04-18-czzy-spider-design.md b/py/docs/superpowers/specs/2026-04-18-czzy-spider-design.md deleted file mode 100644 index 0383ecb..0000000 --- a/py/docs/superpowers/specs/2026-04-18-czzy-spider-design.md +++ /dev/null @@ -1,271 +0,0 @@ -# 厂长资源 Python 爬虫设计 - -## 目标 - -在当前仓库中新增一个符合 `base.spider.Spider` 接口的厂长资源爬虫,覆盖以下能力: - -- 分类列表 -- 详情页 -- 搜索 -- 播放解析 - -实现以网页抓取为主,不依赖私有 API,不改动 `base/` 公共层。 - -## 范围 - -本次实现包含: - -- 新增独立站点脚本,暂定文件名为 `厂长资源.py` -- 内置站点主机列表: - - `https://www.cz01.org` - - `https://www.czzy89.com` -- 支持站内播放链接解析 -- 支持网盘资源保留并透传给上层 - -本次实现不包含: - -- 通用网页源框架抽象 -- 持久化 host 健康检查缓存 -- 对站点全部历史分类页面做完全覆盖 - -## 方案选择 - -采用 `requests + lxml` 直接抓取网页 DOM,并吸收参考插件 `plugin_czzy` 中已经验证过的页面结构与播放器解析规则。 - -不选择直接平移整份 JS 逻辑到 Python,原因是: - -- Python 代码可读性会更高 -- 更容易适配当前 `Spider` 接口 -- 只保留确实需要的站点规则,减少无关实现 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化 host 列表、请求头、分类映射和运行期缓存 -- `homeContent` - - 返回固定分类与筛选定义 -- `categoryContent` - - 按分类拼接列表页 URL 并解析卡片 -- `detailContent` - - 解析影片基础信息与播放列表 -- `searchContent` - - 调用站内搜索页并复用卡片解析 -- `playerContent` - - 解析剧集页和播放器 iframe,产出最终播放 URL -- 若干私有辅助函数 - - host 回退 - - 页面请求 - - 卡片解析 - - 详情解析 - - 剧集解析 - - iframe 播放解析 - -## Host 策略 - -站点域名容易变更,因此实现内置候选 host 列表。请求策略如下: - -1. 优先使用当前可用 host -2. 若请求失败、返回空页面、或关键节点缺失,则依次尝试下一候选 host -3. 一旦发现可用 host,将其提升为当前优先 host - -判定页面是否可用时,不只看 HTTP 200,还要检查关键 DOM 是否存在,例如: - -- 分类页是否存在媒体卡片 -- 搜索页是否存在结果卡片或搜索结果容器 -- 详情页是否存在标题或播放列表容器 - -## 分类设计 - -分类使用稳定的字符串 ID,而不是数字 ID,避免额外映射层。首批支持以下分类: - -- `movie` -- `tv` -- `anime` -- `cn_movie` -- `in_movie` -- `ru_movie` -- `ca_movie` -- `jp_movie` -- `kr_movie` -- `western_movie` -- `cn_drama` -- `jp_drama` -- `us_drama` -- `kr_drama` -- `intl_drama` - -每个分类映射到站点的固定路径,例如: - -- `movie -> /movie_bt/movie_bt_series/dyy/page/{pg}` -- `tv -> /movie_bt/movie_bt_series/dianshiju/page/{pg}` - -首页推荐 `homeVideoContent` 保持空列表,避免额外抓取首页并降低不稳定性。 - -## 列表与搜索解析 - -分类页与搜索页共用同一套卡片解析逻辑 `parse_media_cards(html)`。 - -卡片提取策略: - -- 详情链接:优先取卡片内主链接 `href` -- 标题:优先取图片 `alt`,其次链接 `title`,再回退到卡片文本 -- 图片:依次尝试 `data-original`、`data-src`、`src` -- 备注:优先取集数或清晰度角标,如 `jidi`、`hdinfo` - -输出字段: - -- `vod_id` - - 直接使用详情页相对路径或绝对路径,避免额外查表 -- `vod_name` -- `vod_pic` -- `vod_remarks` - -分页策略: - -- `page = 当前页` -- `limit = 24` 或按实际卡片数返回 -- `pagecount = pg + 1` 作为保守值 -- `total` 采用近似值,不依赖站点总数统计 - -如果当页没有结果,则返回空列表,并将 `pagecount` 设为当前页,避免上层无限翻页。 - -## 详情页设计 - -`detailContent` 通过 `vod_id` 请求详情页,提取: - -- `vod_name` -- `vod_pic` -- `vod_year` -- `vod_area` -- `vod_actor` -- `vod_director` -- `vod_content` - -剧集来源分为两组: - -1. 站内播放 - - 来自 `paly_list_btn` -2. 网盘资源 - - 来自 `ypbt_down_list` - -播放源组织方式: - -- `vod_play_from = "厂长资源$$$网盘资源"` -- `vod_play_url = "<站内剧集串>$$$<网盘资源串>"` - -这样上层可以明确区分站内播放与网盘分享链接,不会混在同一条线路里。 - -剧集项格式: - -- 站内播放:`标题$剧集页URL` -- 网盘资源:`标题$分享链接` - -如果某一来源没有数据,则只返回存在的来源,不保留空占位。 - -## 播放解析设计 - -`playerContent(flag, id, vipFlags)` 的行为按来源分流: - -### 站内播放 - -1. 请求剧集页 -2. 提取页面中的 `iframe src` -3. 请求 iframe 页面,并带正确 `Referer` -4. 按顺序尝试以下解析方式: - - 解析 `var player` 和 `var rand`,做 AES 解密后读取 `url` - - 读取并解混淆 `data` - - 直接提取 `mysvg` - - 直接提取 `art.url` - - 解析包含 `window.wp_nonce` 的脚本作为兜底 -5. 成功后返回: - - `parse = 0` - - `url = 最终播放 URL` - - `playUrl = ""` - - `header = {"User-Agent": "...", "Referer": "..."}` - -### 网盘资源 - -不做二次解析,直接返回原始分享链接,让上层播放器或网盘插件处理: - -- `parse = 0` -- `url = 原始分享链接` -- `playUrl = ""` - -## 请求与兼容性 - -统一请求头: - -- 浏览器 `User-Agent` -- 详情页与 iframe 页设置正确 `Referer` - -必要时维护简单 cookie jar,用于: - -- 首次访问站点后记录下发 cookie -- 后续请求带回同域 cookie - -优先先做无状态请求;若验证发现某些页面必须依赖 cookie,再补上轻量 cookie 维护。 - -## 错误处理 - -实现遵循“失败可回退、最终返回空而非抛出”的原则: - -- 单个 host 请求失败时切换到备选 host -- 单种播放器解析失败时继续尝试下一种规则 -- 所有规则失败时返回空 URL,而不是抛出异常中断调用 -- 详情页缺失部分字段时返回空字符串,不影响剧集列表输出 - -日志仅保留必要调试信息,避免刷屏。 - -## 测试设计 - -采用测试优先方式实现,先为关键解析逻辑补测试,再写生产代码。 - -测试重点放在纯解析函数,不依赖真实网络: - -1. 分类卡片解析 - - 输入参考 HTML 片段 - - 断言标题、封面、备注、详情链接解析正确 -2. 详情页剧集解析 - - 断言能区分站内播放与网盘资源 - - 断言重复链接会去重 -3. iframe 播放解析 - - 覆盖 `iframe src` 提取 - - 覆盖 `mysvg`、`art.url`、`data` 等直接解析路径 -4. host 回退 - - 模拟首个 host 失败、第二个成功 - - 断言当前 host 被更新 - -集成验证以手工调用为辅: - -- `homeContent` -- `categoryContent` -- `searchContent` -- `detailContent` -- `playerContent` - -如果当前仓库尚无正式测试框架,本次可以新增最小化 `unittest` 测试文件。 - -## 实施顺序 - -1. 新增测试文件,覆盖纯解析与 host 回退 -2. 新增 `厂长资源.py` 基本骨架与分类映射 -3. 实现列表页和搜索页抓取 -4. 实现详情页和播放列表组织 -5. 实现播放器多策略解析 -6. 运行测试与基础手工验证 - -## 风险 - -- 站点 HTML 结构可能随时变化 -- 播放器页混淆规则可能变更 -- 某些 host 可能对 Referer 或 cookie 敏感 - -对应策略: - -- 解析逻辑采用多重回退 -- host 失败时自动切换 -- 测试覆盖核心解析函数,降低回归风险 diff --git a/py/docs/superpowers/specs/2026-04-18-dbku-spider-design.md b/py/docs/superpowers/specs/2026-04-18-dbku-spider-design.md deleted file mode 100644 index db4f6d8..0000000 --- a/py/docs/superpowers/specs/2026-04-18-dbku-spider-design.md +++ /dev/null @@ -1,252 +0,0 @@ -# 独播库 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的独播库站点爬虫,覆盖以下能力: - -- 分类列表 -- 详情页 -- 搜索 -- 播放解析 - -实现基于网页 DOM 抓取,不依赖站点私有 API,不修改 `base/` 公共层。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,暂定文件名为 `独播库.py` -- 使用单一站点主域:`https://www.dbku.tv` -- 支持列表页、搜索页、详情页和站内播放解析 - -本次实现不包含: - -- 多域名自动回退 -- 网盘资源解析 -- 通用网页源抽象框架 - -## 方案选择 - -采用 `requests + lxml` 直接抓取网页 HTML,并吸收参考插件 `plugin_dbku` 中已经验证过的 DOM 结构与 `player_data` 解析规则。 - -不直接把整份 JS 逐行平移到 Python,原因是: - -- Python 代码更容易维护 -- 更贴合当前仓库的 `Spider` 接口 -- 只保留独播库真正需要的站点规则,减少噪音 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、分类映射 -- `homeContent` - - 返回固定分类 -- `categoryContent` - - 请求分类页并解析媒体卡片 -- `searchContent` - - 请求搜索页并解析搜索结果 -- `detailContent` - - 提取影片基础信息和剧集列表 -- `playerContent` - - 解析剧集页中的 `player_data`,得到最终播放地址 -- 私有辅助函数 - - URL 归一化 - - 列表卡片解析 - - 搜索结果解析 - - 详情页解析 - - `player_data` 提取与解码 - -## Host 策略 - -本次只实现单域: - -- `https://www.dbku.tv` - -请求失败时不做多域切换,只返回空结果或空播放地址。后续如果该站常态化换域,再补 host 回退。 - -## 分类设计 - -分类 ID 直接使用参考实现中的键,避免额外映射层: - -- `index` -- `movie` -- `variety` -- `anime` -- `hk` -- `luju` - -分类 URL 映射如下: - -- `index -> /vodtype/2--------{pg}---.html` -- `movie -> /vodtype/1--------{pg}---.html` -- `variety -> /vodtype/3--------{pg}---.html` -- `anime -> /vodtype/4--------{pg}---.html` -- `hk -> /vodtype/20--------{pg}---.html` -- `luju -> /vodtype/13--------{pg}---.html` - -首页推荐 `homeVideoContent` 维持空列表,避免额外抓站点首页。 - -## 列表与搜索解析 - -### 分类页 - -分类页主要解析 `myui-vodlist__box` 卡片。 - -每张卡片提取规则: - -- 链接:优先选择 `href` 含 `/voddetail/` 的链接 -- 标题:优先链接 `title`,回退到节点文本 -- 封面:优先取 `data-original`,其次 `src` -- 描述:优先取类名含 `pic-text` 的文本,回退到卡片中首个有意义的附加文本 - -输出字段: - -- `vod_id` - - 直接使用详情页绝对 URL -- `vod_name` -- `vod_pic` -- `vod_remarks` - -### 搜索页 - -搜索 URL: - -- `/vodsearch/-------------.html?wd=&submit=` - -搜索结果优先解析 `#searchList` 容器内的卡片;若没有搜索容器或解析结果为空,则回退到普通列表卡片解析,以兼容不同模板。 - -### 分页策略 - -返回分页字段: - -- `page = 当前页` -- `pagecount = pg + 1`,若当页无内容则为当前页 -- `limit = 实际条目数` -- `total = 近似值` - -不依赖站点总数统计。 - -## 详情页设计 - -`detailContent` 使用 `vod_id` 请求详情页,提取: - -- `vod_name` -- `vod_pic` -- `vod_year` -- `vod_area` -- `vod_actor` -- `vod_director` -- `vod_content` - -播放列表只保留站内源: - -- `vod_play_from = "独播库"` - -剧集项格式: - -- `标题$剧集页URL` - -若发现多组播放列表,先按页面顺序合并同站剧集,去重后输出为单一播放源。 - -## 播放解析设计 - -独播库播放解析核心是剧集页中的 `player_data`。 - -实现步骤: - -1. 请求剧集页 -2. 提取页面脚本中的 `player_data` JSON -3. 读取: - - `url` - - `from` - - `encrypt` -4. 按 `encrypt` 解码播放地址 - -解码规则: - -- `encrypt = 0` - - 原样返回 -- `encrypt = 1` - - 执行 `unescape` 等价解码 -- `encrypt = 2` - - `base64` 解码后再尝试 URL 解码 -- `encrypt = 3` - - 按参考实现做裁剪后的 `base64` 变体解码 - -如果解码后的 URL 仍是站内中间页而不是最终播放地址,则继续请求该页面并再次尝试提取 `player_data` 或直链,直到: - -- 得到最终可播放 URL -- 无法继续解析,此时返回空 URL - -返回格式: - -- `parse = 0` -- `playUrl = ""` -- `url = 最终播放地址` -- `header = {"User-Agent": "...", "Referer": "..."}` - -## 请求与兼容性 - -统一请求头至少包含: - -- 浏览器 `User-Agent` -- 需要时补 `Referer` - -本次优先采用无状态请求;如果验证时发现独播库对 cookie 敏感,再补最小 cookie 维护。 - -## 错误处理 - -实现遵循“失败返回空,不抛异常中断”的原则: - -- 页面请求失败时返回空列表或空播放 URL -- DOM 节点缺失时字段回退为空字符串 -- `player_data` 解析失败时继续尝试回退规则 -- 最终失败则返回 `{"parse": 0, "playUrl": "", "url": ""}` - -日志只保留必要调试信息。 - -## 测试设计 - -采用测试优先方式实现,先给纯解析函数写测试,再补生产代码。 - -测试重点: - -1. 分类卡片解析 - - 断言能从 `myui-vodlist__box` 提取详情链接、标题、封面和描述 -2. 搜索结果解析 - - 断言优先使用 `#searchList` - - 断言缺失时会回退到普通列表解析 -3. 详情页解析 - - 断言影片元信息提取正确 - - 断言剧集列表生成正确并去重 -4. `player_data` 解码 - - 覆盖 `encrypt = 0/1/2/3` -5. `playerContent` - - 断言能从剧集页解析到最终播放地址 - -优先使用 `unittest` 和 mock,避免测试依赖真实站点网络。 - -## 实施顺序 - -1. 新增独播库测试文件,覆盖卡片、搜索、详情和播放器解析 -2. 新增 `独播库.py` 基本骨架与分类映射 -3. 实现分类页和搜索页抓取 -4. 实现详情页和剧集列表 -5. 实现 `player_data` 解码与高层 `playerContent` -6. 运行测试并做最小手工验证 - -## 风险 - -- 页面模板可能调整 -- `player_data` 格式或 `encrypt` 规则可能变化 -- 个别剧集可能存在站内二跳或额外播放器包装 - -对应策略: - -- 列表和搜索解析都保留回退路径 -- 播放解析按 `encrypt` 分类处理 -- 测试覆盖所有已知解码路径,降低回归风险 diff --git a/py/docs/superpowers/specs/2026-04-18-libvio-spider-design.md b/py/docs/superpowers/specs/2026-04-18-libvio-spider-design.md deleted file mode 100644 index 2d1a30e..0000000 --- a/py/docs/superpowers/specs/2026-04-18-libvio-spider-design.md +++ /dev/null @@ -1,280 +0,0 @@ -# LibVIO Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 LibVIO 站点爬虫,覆盖以下能力: - -- 首页分类 -- 首页近期列表 -- 分类列表 -- 详情页 -- 搜索 -- 播放解析 - -实现基于网页 DOM 抓取与播放页脚本解析,不依赖 Playwright,不修改 `base/` 公共层。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,暂定文件名为 `libvio.py` -- 使用单一站点主域:`https://libvio.site` -- 支持首页、分类、搜索、详情和站内播放解析 - -本次实现不包含: - -- 多域名自动回退 -- 网盘资源解析 -- 大规模并发探测可播线路 -- 通用资源站抽象框架 - -## 方案选择 - -采用混合方案: - -- 列表、详情、搜索使用 `requests + lxml` 直接解析 HTML -- 播放解析吸收参考插件 `plugin_libvio` 中已验证的 `player_*` 配置、播放器 JS 和中间 API 解析逻辑 - -不直接逐行平移参考 JS,原因是: - -- 当前仓库已有 Python 爬虫风格,宜保持单文件站点实现 -- 参考 JS 中包含探测候选源、并发验证等偏重逻辑,Python 版先只保留稳定主链路 -- 先满足常见播放线路的可维护实现,避免把站点特例扩散到整个仓库 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、分类映射 -- `homeContent` - - 返回固定分类 -- `homeVideoContent` - - 抓取首页最近更新列表 -- `categoryContent` - - 请求分类页并解析媒体卡片 -- `searchContent` - - 请求搜索页并解析搜索结果 -- `detailContent` - - 提取影片基础信息和剧集列表 -- `playerContent` - - 解析剧集页中的 `player_*` 配置并拿到最终播放地址 -- 私有辅助函数 - - URL 归一化 - - 列表卡片解析 - - 详情字段解析 - - 播放页配置提取 - - 播放器 JS 基址提取 - - API 播放地址提取 - -## Host 与分类策略 - -本次只实现单域: - -- `https://libvio.site` - -请求失败时不做多域切换,只返回空结果或空播放地址。 - -分类沿用参考插件配置: - -- `index` -- `movie` -- `series` -- `anime` -- `jpandkr` -- `euandus` - -分类 URL 映射如下: - -- `index -> /` -- `movie -> /type/1-{pg}.html` -- `series -> /type/2-{pg}.html` -- `anime -> /type/4-{pg}.html` -- `jpandkr -> /type/15-{pg}.html` -- `euandus -> /type/16-{pg}.html` - -其中: - -- `homeContent` 返回上述固定分类 -- `homeVideoContent` 抓取首页并提取最近更新条目 - -## 列表与搜索解析 - -### 首页与分类页 - -首页和分类页主要解析 `stui-vodlist__box` 卡片。 - -每张卡片提取规则: - -- 链接:选择 `href` 含详情页路径的主链接 -- 标题:优先链接 `title`,回退到节点文本 -- 封面:优先 `data-original`,其次 `src` -- 描述:优先类名含 `pic-text` 的文本 - -输出字段: - -- `vod_id` - - 返回紧凑数字 id,不返回完整详情 URL -- `vod_name` -- `vod_pic` -- `vod_remarks` - -列表解析时跳过明显网盘提示项,避免把不可直播资源混进常规列表。 - -### 搜索页 - -搜索 URL: - -- `/search/-------------.html?wd=` - -搜索结果优先按首页同类卡片结构解析,保持输出字段与分类页一致。 - -### 分页策略 - -返回分页字段: - -- `page = 当前页` -- `pagecount = pg + 1`,若当页无内容则为当前页 -- `limit = 实际条目数` -- `total = 近似值` - -不依赖站点总数统计。 - -## 详情页设计 - -`detailContent` 使用 `vod_id` 在内部组装详情页 URL 并请求页面。 - -提取字段: - -- `vod_id` -- `path` -- `vod_name` -- `vod_pic` -- `vod_tag` -- `vod_time` -- `vod_remarks` -- `vod_play_from` -- `vod_play_url` -- `type_name` -- `vod_content` -- `vod_year` -- `vod_area` -- `vod_lang` -- `vod_director` -- `vod_actor` - -不返回: - -- `dbid` -- `type` - -详情字段优先从详情主块中按标签解析,兼容纯文本行与结构化节点两种形式。 - -### 播放线路 - -播放列表只保留站内可播源: - -- 解析 `stui-content__playlist` -- 跳过标题或分组中明显标识为网盘、夸克、UC 的资源 -- `vod_play_from` 用 `$$$` 拼接线路名 -- `vod_play_url` 用 `$$$` 对齐对应线路剧集 - -单个剧集项格式: - -- `标题$播放id` - -其中 `播放id` 为紧凑值,由 `playerContent` 再组装完整播放页 URL。 - -## 播放解析设计 - -LibVIO 播放解析核心不是详情页直链,而是播放页里的 `player_*` 配置和播放器脚本。 - -实现步骤: - -1. 组装播放页 URL 并请求 -2. 提取页面脚本中的 `player_*` JSON -3. 读取关键字段: - - `url` - - `from` - - `link_next` - - `id` - - `nid` -4. 如果 `from` 属于网盘类(如 `kuake`、`uc`),直接返回空 -5. 如果 `from` 是站点特殊源(如 `ty_new1`),按固定 API 模式请求 -6. 否则请求 `/static/player/.js`,提取播放器 API 基址 -7. 根据不同源拼接 API 地址,再从 API 响应中抽取最终 `m3u8/mp4` - -### API 响应解析 - -优先支持以下模式: - -- JSON 或脚本内的 `url/urls` 字段 -- 变量赋值中的 `m3u8/mp4` 直链 -- `tweb` 之类需要二次解码的源,按参考实现做最小必要解码 - -如果最终 URL 仍是站内中间页,则允许一层站内跳转继续解析。 - -返回格式: - -- `parse = 0` -- `playUrl = ""` -- `url = 最终播放地址` -- `header = {"User-Agent": "...", "Referer": "..."}` - -## 请求与兼容性 - -统一请求头至少包含: - -- 浏览器 `User-Agent` -- 需要时补 `Referer` - -播放器 API 解析需要稳定 Referer,因此播放页与播放器 API 请求都应显式带站点 Referer。 - -本次优先采用无状态请求;如验证发现 LibVIO 对 cookie 敏感,再补最小 cookie 维护。 - -## 错误处理 - -实现遵循“失败返回空,不抛异常中断”的原则: - -- 页面请求失败时返回空列表或空播放地址 -- DOM 节点缺失时字段回退为空字符串 -- 播放配置提取失败时尝试回退到直链正则 -- 最终失败则返回 `{"parse": 0, "playUrl": "", "url": ""}` - -日志只保留必要调试信息,主要用于播放解析链路。 - -## 测试设计 - -采用测试优先方式实现,先给纯解析函数写测试,再补生产代码。 - -测试重点: - -1. 首页/分类卡片解析 - - 断言能从 `stui-vodlist__box` 提取详情 id、标题、封面和备注 -2. 首页与分类高层流程 - - 断言 `homeContent` 返回固定分类 - - 断言 `homeVideoContent` 与 `categoryContent` 组装分页结果正确 -3. 搜索解析 - - 断言能解析搜索列表并复用卡片解析 -4. 详情解析 - - 断言基础字段提取正确 - - 断言会过滤网盘线路 - - 断言剧集列表输出为紧凑播放 id -5. 播放解析 - - 断言可从播放页提取 `player_*` 配置 - - 断言可从播放器 JS 提取 API 基址 - - 断言可从 API 或脚本提取最终 `m3u8/mp4` - - 断言特殊源与空结果路径行为正确 - -优先使用 `unittest` 和 mock,避免测试依赖真实站点网络。 - -## 实施顺序 - -1. 新增 `tests/test_libvio.py`,先覆盖首页/分类/搜索/详情/播放器核心解析 -2. 新增 `libvio.py` 基本骨架与分类映射 -3. 实现首页、分类和搜索列表抓取 -4. 实现详情字段和线路提取 -5. 实现播放页配置解析与播放器 API 解析 -6. 运行测试并补必要的站内跳转与特殊源兼容 diff --git a/py/docs/superpowers/specs/2026-04-18-youknow-spider-design.md b/py/docs/superpowers/specs/2026-04-18-youknow-spider-design.md deleted file mode 100644 index 315675e..0000000 --- a/py/docs/superpowers/specs/2026-04-18-youknow-spider-design.md +++ /dev/null @@ -1,310 +0,0 @@ -# YouKnowTV Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 YouKnowTV 站点爬虫,覆盖以下能力: - -- 首页分类 -- 今日更新列表 -- 分类列表 -- 详情页 -- 搜索 -- 播放解析 - -实现基于网页 DOM 抓取与播放页脚本解析,不依赖 Playwright,不修改 `base/` 公共层。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,暂定文件名为 `youknow.py` -- 使用单一站点主域:`https://www.youknow.tv` -- 支持 `home/homeVideo/category/detail/search/player` 全链路 - -本次实现不包含: - -- 多域名自动回退 -- Cloudflare 绕过 -- 通用视频网站抽象框架 -- 参考 JS 中所有非核心候选线路策略的 1:1 平移 - -## 方案选择 - -采用混合方案: - -- 首页、分类、搜索、详情使用 `requests + lxml` 直接解析 HTML -- 播放解析吸收参考插件 `plugin_youknow` 中已经验证过的关键策略,包括: - - `player_aaaa` 配置提取 - - 多层百分号/base64 解码 - - 播放页与 iframe 页中的候选直链提取 - - 多线路同集对齐 - -不逐行平移参考 JS,原因是: - -- 当前仓库已有 Python 站点脚本风格,宜保持单文件实现 -- 参考插件包含运行时兼容分支和 UI 输出逻辑,Python 版只保留站点真正需要的解析链路 -- 先确保常见播放链路稳定,再看是否需要补特例 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、分类映射 -- `homeContent` - - 返回固定分类 -- `homeVideoContent` - - 抓取今日更新页并解析媒体卡片 -- `categoryContent` - - 请求分类页并解析媒体卡片 -- `searchContent` - - 请求搜索页并解析搜索结果 -- `detailContent` - - 提取影片基础信息和多线路剧集列表 -- `playerContent` - - 解析剧集页、iframe 页与候选直链,返回最终播放地址 -- 私有辅助函数 - - URL 与紧凑 id 归一化 - - 列表卡片解析 - - 详情字段解析 - - 剧集线路对齐 - - 播放页配置提取 - - 候选直链收集与去重 - -## Host 与分类策略 - -本次只实现单域: - -- `https://www.youknow.tv` - -请求失败时不做多域切换,只返回空结果或空播放地址。 - -分类沿用参考插件配置: - -- `index` -- `drama` -- `movie` -- `variety` -- `anime` -- `short` -- `doc` - -分类 URL 映射如下: - -- `index -> /label/new/` -- `drama -> /show/1--------{pg}---/` -- `movie -> /show/2--------{pg}---/` -- `variety -> /show/3--------{pg}---/` -- `anime -> /show/4--------{pg}---/` -- `short -> /show/55--------{pg}---/` -- `doc -> /show/5--------{pg}---/` - -其中: - -- `homeContent` 返回上述固定分类 -- `homeVideoContent` 请求 `/label/new/` - -## 列表与搜索解析 - -### 今日更新页与分类页 - -主要解析海报卡片,优先覆盖参考插件里正则与 DOM 两种结构: - -- `module-poster-item` -- `module-card-item-poster` - -每张卡片提取规则: - -- 链接:详情页链接 -- 标题:优先 `title`,回退到 `alt` 或节点文本 -- 封面:优先 `data-original`、`data-src`,其次 `src` -- 描述:优先 `module-item-note` 或 `module-item-text` - -输出字段: - -- `vod_id` - - 返回紧凑详情 id,不返回完整详情 URL -- `vod_name` -- `vod_pic` -- `vod_remarks` - -### 搜索页 - -搜索 URL: - -- `/search/-------------.html?wd=` - -搜索结果与分类页复用同一套卡片解析逻辑。 - -### 分页策略 - -返回分页字段: - -- `page = 当前页` -- `pagecount = pg + 1`,若当页无内容则为当前页 -- `limit = 实际条目数` -- `total = 近似值` - -不依赖站点总数统计。 - -## 详情页设计 - -`detailContent` 使用紧凑 `vod_id` 在内部组装详情页 URL 并请求页面。 - -提取字段: - -- `vod_id` -- `path` -- `vod_name` -- `vod_pic` -- `vod_tag` -- `vod_time` -- `vod_remarks` -- `vod_play_from` -- `vod_play_url` -- `type_name` -- `vod_content` -- `vod_year` -- `vod_area` -- `vod_lang` -- `vod_director` -- `vod_actor` - -不返回: - -- `dbid` -- `type` - -详情字段优先从详情主块中按标签解析,并兼容纯文本或链接混排形式。 - -### 多线路剧集设计 - -YouKnowTV 的详情页可能存在多线路、多集数,且同一集在不同线路有不同播放页 URL。参考实现的关键点不是简单把所有链接平铺,而是: - -- 先解析每条线路的剧集链接 -- 从剧集 URL 中提取: - - `vodId` - - `sourceId` - - `episodeIndex` -- 按 `episodeIndex` 对齐多条线路 -- 对同一集生成一个紧凑播放 payload,里面保存多个候选线路 - -Python 版会保留这一思路,但对外仍然维持仓库统一约定: - -- `vod_play_from` - - 若只做单一虚拟源,可固定为 `YouKnowTV` -- `vod_play_url` - - 单集项格式为 `标题$紧凑播放id` - -紧凑播放 id 将采用站点内部可逆的序列化形式,用于在 `playerContent` 中恢复该集的候选线路列表,而不是直接暴露完整播放页 URL。 - -## 播放解析设计 - -YouKnowTV 的播放解析不是单层页面直链,而是候选直链收集问题。核心来源包括: - -1. 播放页脚本中的 `player_aaaa` -2. 播放页 HTML 中直接出现的 `m3u8/mp4/flv` -3. 播放页中 iframe 的 `src` -4. iframe 页中再次出现的 `player_aaaa` 或直链 - -### 解码策略 - -参考实现中关键解码规则如下,Python 版保留: - -- `encrypt = 0` - - 原样处理 -- `encrypt = 1` - - 百分号解码 -- `encrypt = 2` - - 多层候选解码: - - 原值 - - 百分号解码 1-2 层 - - base64 解码 - - base64 后再做 1-3 层百分号解码 - - 从这些候选里优先选直接可播 URL - -### 候选直链判定 - -可播直链至少满足以下之一: - -- `http://` 或 `https://` -- 协议相对 `//` -- 包含 `.m3u8` -- 包含 `.mp4` -- 包含 `.flv` - -### 播放流程 - -`playerContent` 实现步骤: - -1. 将紧凑播放 id 还原为候选播放页 URL 列表 -2. 按顺序请求每个候选播放页 -3. 收集播放页中的候选直链 -4. 若页面包含 iframe,再请求 iframe 页补充候选直链 -5. 将候选直链去重 -6. 选择第一个最终可播 URL 返回 - -返回格式: - -- `parse = 0` -- `playUrl = ""` -- `url = 最终播放地址` -- `header = {"User-Agent": "...", "Referer": "..."}` - -若所有候选线路都失败,则返回空播放结构。 - -## 请求与兼容性 - -统一请求头至少包含: - -- 浏览器 `User-Agent` -- `Referer` -- `Accept-Language` - -YouKnowTV 可能出现 challenge 页面。Python 版不负责绕过 challenge,但会识别明显 challenge 标志并优雅返回空结果,避免把错误页当成正文继续解析。 - -## 错误处理 - -实现遵循“失败返回空,不抛异常中断”的原则: - -- 页面请求失败时返回空列表或空播放 URL -- DOM 节点缺失时字段回退为空字符串 -- 候选播放页解析失败时继续尝试下一个候选 -- 最终失败则返回 `{"parse": 0, "playUrl": "", "url": ""}` - -日志只保留必要调试信息,重点是播放候选恢复和直链提取链路。 - -## 测试设计 - -采用测试优先方式实现,先给纯解析函数写测试,再补生产代码。 - -测试重点: - -1. 首页/分类卡片解析 - - 断言能解析紧凑 `vod_id`、标题、封面和备注 -2. 首页与分类高层流程 - - 断言 `homeContent` 返回固定分类 - - 断言 `homeVideoContent` 与 `categoryContent` 组装结果正确 -3. 搜索解析 - - 断言搜索结果复用卡片解析 -4. 详情解析 - - 断言基础字段提取正确 - - 断言多线路剧集会被对齐成紧凑播放 id -5. 播放解析 - - 断言能提取 `player_aaaa` - - 断言 `encrypt=1/2` 解码行为正确 - - 断言能从播放页与 iframe 页提取候选直链 - - 断言候选失败时继续尝试下一条 - -优先使用 `unittest` 和 mock,避免测试依赖真实站点网络。 - -## 实施顺序 - -1. 新增 `tests/test_youknow.py`,先覆盖首页/分类/搜索/详情/播放器核心解析 -2. 新增 `youknow.py` 基本骨架与分类映射 -3. 实现首页、分类和搜索列表抓取 -4. 实现详情字段、多线路对齐与紧凑播放 payload -5. 实现播放页与 iframe 页的候选直链解析 -6. 运行测试并补必要的解码与容错逻辑 diff --git a/py/docs/superpowers/specs/2026-04-19-cupfox-spider-design.md b/py/docs/superpowers/specs/2026-04-19-cupfox-spider-design.md deleted file mode 100644 index ef85e7a..0000000 --- a/py/docs/superpowers/specs/2026-04-19-cupfox-spider-design.md +++ /dev/null @@ -1,336 +0,0 @@ -# 茶杯狐 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的茶杯狐站点爬虫,完整覆盖以下能力: - -- 首页分类 -- 首页推荐 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 -- 搜索/详情/播放页面的人机验证绕过 - -实现基于站点 HTML 页面和 `foxplay` 接口,不引入服务端路由层,不修改 `base/` 公共层,并保持当前仓库的短 ID 和结果结构约定。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `茶杯狐.py` -- 新增对应单元测试 `tests/test_茶杯狐.py` -- 使用单一站点主域:`https://www.cupfox.ai` -- 支持 `home/homeVideo/category/search/detail/player` 主链路 -- 实现命中验证页后的 Cookie 合并、`token` 提取和 `/robot.php` 过盾请求 -- 实现 `foxplay/api.php` 的两种播放链接解密 -- 过滤 404 占位、403 和明显不可用链接,并在失败时回退 `parse=1` - -本次实现不包含: - -- 多域名探活与自动切换 -- 浏览器自动化、JS 引擎执行和验证码识别 -- 站外搜索源聚合和非站内播放链路修复 -- 登录态管理和本地缓存持久化 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分为短 ID 处理、请求与过盾、卡片解析、详情解析、播放解密几个 helper -- 只在站点文件内部维护逻辑,不修改 `base/` - -不直接照搬参考 JS 路由层的原因是: - -- 当前仓库只消费 Spider 接口,不消费 Node 路由插件 -- Python 版已有稳定的 HTML 爬虫实现风格,应延续既有结构 -- 本次重点是把茶杯狐能力转成可测试的 Python Spider,而不是保留一层额外运行时 - -## 模块边界 - -新增脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、分页大小和字符映射表 -- `homeContent` - - 返回分类列表和静态筛选空结构 -- `homeVideoContent` - - 请求首页并解析推荐卡片,去重后截断 -- `categoryContent` - - 请求分类页,复用卡片解析并返回页码、总量估算和列表 -- `searchContent` - - 请求搜索页,复用卡片解析并支持 `quick` -- `detailContent` - - 请求详情页,提取元数据和多线路播放列表 -- `playerContent` - - 请求播放页、提取 `player_aaaa`、调用 `foxplay/api.php`、解密直链并决定是否回退解析 -- 私有辅助函数 - - URL 补全与短 ID 编解码 - - HTML 请求与过盾流程 - - Cookie 合并 - - `token` 提取和过盾加密 - - `player_aaaa` 解析 - - 两套播放解密 - - 占位/禁播链接识别 - -## ID 与 URL 设计 - -遵循当前仓库“短 ID,不暴露完整 URL”的约定。 - -详情页短 ID: - -- 页面链接 `/movie/.html` -- 存储为 `detail/` - -播放页短 ID: - -- 页面链接 `/play/.html` -- 存储为 `play/` - -编码与解码原则: - -- 只接受站内标准详情/播放路径 -- 解析失败时返回空字符串,调用侧直接回空结果或回退 -- 内部统一通过 `urljoin` 补全相对路径 - -## Host 与请求策略 - -本次只实现单域: - -- `https://www.cupfox.ai` - -统一请求头至少包含: - -- `User-Agent` -- `Referer` -- `Accept` -- `Accept-Language` - -请求原则: - -- HTML 请求超时固定为 15 秒 -- 普通页面请求走 `self.fetch` / `self.post` -- 页面请求默认不自动依赖持久会话,而是在单次过盾流程里显式维护 Cookie 字典 -- 命中验证时只处理当前请求,不做全局 Cookie 缓存 -- 请求失败时返回空结果或播放页回退,不抛出未处理异常 - -## 过盾设计 - -页面请求统一走 `_request_with_firewall()`: - -1. 首次请求目标 URL,收集响应文本和 `set-cookie` -2. 如果页面不包含 `人机验证` 或 `verifyBox`,直接返回 HTML -3. 如果命中验证,从 HTML 中提取 `var token = encrypt("...")` -4. 对当前 URL 和原始 token 分别执行参考实现中的字符位移 + 随机填充 + Base64 加密 -5. 向 `/robot.php` 发起表单 POST,请求头带上已收集 Cookie -6. 合并验证接口返回的 Cookie -7. 带最新 Cookie 二次请求原页面 -8. 若二次请求仍是验证页,则按失败处理 - -该流程只用于 HTML 页面: - -- 首页 -- 分类页 -- 搜索页 -- 详情页 -- 播放页 - -`foxplay/api.php` 不走过盾流程。 - -## 分类、首页与搜索设计 - -### 分类 - -分类从首页导航 `nav.bm-item-list a` 动态解析,提取: - -- `type_id` -- `type_name` - -只保留 `/type/.html` 结构的导航链接。 - -### 首页推荐 - -首页推荐从 `.mobile-main .panel .movie-list-item` 提取,按详情 URL 去重后返回前 20 条。 - -### 分类列表 - -分类页路径: - -- `/type/-.html` - -每张卡片提取: - -- 链接:`a[href]` -- 标题:`a[title]` -- 封面:`.Lazy[data-original]` -- 备注:`.movie-item-note`,为空时退回 `.movie-item-score` - -### 搜索 - -搜索页路径: - -- `/search/-------------.html` - -搜索结果按 `.vod-search-list .box` 解析,字段提取规则与分类卡片一致,但标题优先取 `.movie-title`。 - -返回策略: - -- `limit` 固定为 20 -- 列表和搜索返回 `page`、`total`、`limit`、`list` -- 不返回 `pagecount` -- 当页结果为空时 `total` 为 0 -- 当页结果非空时,`total` 至少保证大于等于 `page * len(list)`,避免上层立即判定无后续分页 - -## 详情页设计 - -详情页解析以下字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_content` -- `vod_year` -- `vod_director` -- `vod_actor` -- `vod_play_from` -- `vod_play_url` - -解析策略: - -- 标题取 `h1.movie-title` -- 海报取 `.poster img` -- 简介取 `.summary.detailsTxt` 的纯文本,并移除展开按钮文本 -- 年份从 `.scroll-content a` 中提取四位数字 -- 导演和演员从 `.info-data` 中按标签文案识别并拼接 - -播放列表策略: - -- 线路名从 `.play_source_tab .swiper-slide` 提取 -- 每个 `.play_list_box` 对应一组线路 -- 集数条目从 `.content_playlist li a` 提取为 `名称$play/` -- 最终按仓库约定拼接成 `vod_play_from` 和 `vod_play_url` - -如果线路数和列表数不一致: - -- 优先保留已有剧集列表 -- 缺失的线路名回退为 `线路` - -## 播放解析设计 - -### 播放页提取 - -`playerContent` 先还原播放页 URL,请求 HTML,再从脚本中提取: - -- `player_aaaa.url` -- `player_aaaa.from` -- `player_aaaa.server` - -如果找不到 `player_aaaa.url`,直接回退: - -- `parse = 1` -- `url = 播放页 URL` -- `header` 带 `User-Agent` 和当前页 `Referer` - -### `foxplay/api.php` - -当 `player_aaaa.url` 存在时: - -1. 以 `vid=` POST 到 `/foxplay/api.php` -2. 若接口返回 `data.url`,按 `urlmode` 解密: - - `1` 走 `Decode1.sign` - - `2` 走 `decode2` - - 其他值直接使用原始 `url` -3. 对解密结果做占位链接识别 - -### 解密规则 - -`Decode1.sign` 对应参考实现的三段式处理: - -- 自定义异或解码 -- Base64 还原映射表 -- 按明文/密文字母映射恢复真实路径 - -`decode2` 对应参考实现的字典反向位移逻辑: - -- Base64 解码 -- 每三个字符取中间位 -- 按字典回退 3 位 - -### 占位链接识别 - -以下情况视为不可用直链: - -- 空字符串 -- 非 `http(s)` / `//` / `magnet:` 链接 -- 指向 `404.mp4` -- 参数含 `code=403` -- 包含 `forbidden` - -### 播放返回策略 - -如果解密得到可用直链: - -- 返回 `parse = 0` -- `url` 为真实地址 -- `header.Referer` 指向 `muiplayer.php?vid=...` - -如果 API 无直链、解密失败或命中占位链接: - -- 返回 `parse = 1` -- `url` 为原播放页 -- `header.Referer` 指向原播放页 - -## 错误处理 - -所有对外方法在站点异常时返回兼容结构,不抛出未处理异常: - -- `homeContent` 返回空分类 -- `homeVideoContent` 返回空列表 -- `categoryContent` 返回当前页和空列表 -- `searchContent` 返回当前页和空列表 -- `detailContent` 返回空 `list` -- `playerContent` 返回原播放页的 `parse=1` - -内部异常处理原则: - -- 私有 helper 可抛出 `ValueError` -- 对外接口统一兜底 -- JSON 解析、Base64 解码和映射解密失败时返回空字符串,不中断整个请求 - -## 测试设计 - -测试采用 `unittest` 和 `unittest.mock`,避免真实网络访问。 - -首批覆盖: - -- 短 ID 编解码 -- Cookie 合并和请求头拼装 -- 验证页 `token` 提取和过盾二次请求 -- 首页分类解析 -- 首页推荐去重 -- 分类列表解析 -- 搜索结果解析和空关键词分支 -- 详情页元数据和多线路播放列表拼装 -- `Decode1` 与 `decode2` 的解密行为 -- `playerContent` 的直链成功分支 -- `playerContent` 的占位链接回退分支 -- `playerContent` 的缺失 `player_aaaa` 回退分支 - -测试粒度要求: - -- 先验证最小私有 helper -- 再验证对外接口方法 -- 只 mock 网络层,不 mock 纯解析函数 - -## 验收标准 - -满足以下条件视为完成: - -- 新增 `茶杯狐.py`,接口与仓库现有 Spider 一致 -- 新增 `tests/test_茶杯狐.py` -- 列表和搜索结果使用短详情 ID -- 详情页播放列表使用短播放 ID -- 命中验证页时能完成两次请求和 Cookie 合并 -- `playerContent` 能在可用时返回直链,在不可用时稳定回退 -- 相关测试通过,且不引入 `pagecount` 到列表/搜索返回结构 diff --git a/py/docs/superpowers/specs/2026-04-19-ddys-spider-design.md b/py/docs/superpowers/specs/2026-04-19-ddys-spider-design.md deleted file mode 100644 index 7a5743f..0000000 --- a/py/docs/superpowers/specs/2026-04-19-ddys-spider-design.md +++ /dev/null @@ -1,304 +0,0 @@ -# 低端影视 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的低端影视站点爬虫,覆盖以下能力: - -- 首页分类与筛选 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 - -实现基于站点 HTML 页面结构,不引入服务端路由层,不修改 `base/` 公共层,并且保留详情页中的网盘分享链接。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `低端影视.py` -- 使用单一站点主域:`https://ddys.io` -- 支持 `home/category/detail/search/player` 主链路 -- 首页返回固定分类与静态筛选配置 -- 详情页同时输出站内播放线路和网盘分享线路 -- `playerContent` 对站内线路返回直链,对网盘线路直接透传分享链接 - -本次实现不包含: - -- 参考 JS 中的 Fastify 路由封装 -- 网盘驱动匹配、网盘二次解析、网盘播放鉴权 -- 浏览器自动化或 JS 执行环境 -- 多域名探活与自动切换 -- 登录态、Cookie 常驻维护、站点反爬绕过 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分为请求封装、列表解析、详情解析、播放器提取几个 helper -- 保留参考实现的核心行为,但只实现当前 Python 仓库真正需要的 Spider 逻辑 - -不直接照搬参考 JS 服务端包装层的原因是: - -- 当前仓库只消费 Spider 接口,不消费站内 HTTP API -- Python 版已有成熟模式,新增站点应遵循既有结构 -- 这次重点是 HTML 解析与数据整形,不是路由或中间件封装 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、固定分类、筛选项与分页参数 -- `homeContent` - - 返回固定 `class` 与 `filters` -- `homeVideoContent` - - 返回空列表,不额外做首页推荐抓取 -- `categoryContent` - - 根据分类、筛选和分页拼出 URL,请求 HTML 并解析卡片 -- `detailContent` - - 请求详情页,提取影片元数据、普通播放线路和网盘线路 -- `searchContent` - - 通过站内搜索接口 POST 关键词并解析结果卡片 -- `playerContent` - - 对普通线路返回最终 URL,对网盘线路直接返回分享链接 -- 私有辅助函数 - - 补全相对地址 - - 清理文本 - - 模板化分类筛选路径 - - 解析卡片列表 - - 解析详情元信息 - - 解析普通播放源 - - 解析并保留网盘分享链接 - -## Host 与请求策略 - -本次只实现单域: - -- `https://ddys.io` - -请求统一通过 `self.fetch` / `self.post` 发起,固定请求头至少包含: - -- `User-Agent` -- `Referer` - -搜索接口额外补充: - -- `Content-Type: application/x-www-form-urlencoded` - -请求原则: - -- HTML 请求超时固定为 10 秒 -- 请求失败时返回空 HTML 或空结果,不抛出未处理异常 -- 不引入 gzip/base64 之外的额外协议适配 - -## 分类与筛选设计 - -首页分类固定为: - -- `series -> 剧集` -- `movie -> 电影` -- `variety -> 综艺` -- `anime -> 动漫` - -筛选配置直接内置到脚本中,字段与参考实现保持一致: - -- `class` -- `area` -- `year` -- `by` - -筛选 URL 规则采用模板拼接: - -- `{{fl.by}}{{fl.cateId}}{{fl.class}}{{fl.area}}{{fl.year}}/page/fypage` - -规则说明: - -- `cateId` 来自当前分类默认配置 -- `class/area/year/by` 来自传入的 `extend` -- 当第一页且存在可选分页模板时,输出第一页真实路径 -- 当页无结果时返回当前页,避免上层无限翻页 - -`homeContent` 返回: - -- `class` -- `filters` - -不返回首页推荐列表。 - -## 列表与搜索设计 - -分类页解析对象为新版卡片: - -- 外层选择器:`.movie-card` -- 标题:`h3` -- 图片:`img[src]` -- 备注:`.poster-badge` -- 链接:内部 `a[href]` - -输出统一卡片结构: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -搜索接口使用: - -- `POST /search` - -请求体: - -- `q=<关键词>` - -搜索结果优先解析第一个结果区块中的 `.movie-card`,如果站点结构缺失,再回退到通用卡片解析器。 - -分页策略采用保守估计: - -- 分类页 `limit` 固定为 24 -- 当页有结果时,`pagecount = page + 1` -- 当页无结果时,`pagecount = page` -- 搜索同样按“有结果则可翻下一页”的保守逻辑返回 - -## 详情页设计 - -详情页仅适配新版页面结构。 - -元信息提取范围: - -- 标题:主标题与可选副标题 -- 封面:首张主海报图 -- 年份、地区、类型 -- 导演、主演 -- 剧情简介 - -详情输出字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_content` -- `vod_remarks` -- `vod_year` -- `vod_area` -- `vod_class` -- `vod_director` -- `vod_actor` -- `vod_play_from` -- `vod_play_url` - -播放来源分为两组: - -1. 普通站内线路 - - 来自页面中的 `switchSource(...)` 按钮 - - 线路名使用按钮文本 - - 单线路只有一个 URL 时输出 `全集$` - - 多集线路按 `名称$URL#名称$URL` 组织 -2. 网盘线路 - - 来自 `.download-type-content` 模块中的按钮 - - 从 `atob('...')` 中解码出真实分享链接 - - 每个支持的网盘分组单独作为一条线路 - -网盘线路识别范围: - -- `quark` -- `xunlei` -- `baidu` - -网盘线路保留策略: - -- 只保留分享链接,不做驱动匹配 -- 分享链接直接写入 `vod_play_url` -- `vod_play_from` 使用网盘名,如 `quark`、`xunlei`、`baidu` - -如果某种来源没有数据,则不保留空占位;最终只返回实际存在的线路。 - -## 播放设计 - -`playerContent(flag, id, vipFlags)` 按来源分流: - -### 普通站内线路 - -- 如果 `id` 不是绝对地址,则补全为站点完整 URL -- 返回: - - `parse = 0` - - `jx = 0` - - `url = 完整播放链接` - - `header = 基础请求头` - -### 网盘线路 - -当 `flag` 命中以下关键字时视为网盘线路: - -- `baidu` -- `quark` -- `xunlei` - -对网盘线路不做二次解析,直接透传分享链接: - -- `parse = 0` -- `jx = 0` -- `url = 原始分享链接` -- `header = {}` - -这样可以保证上层仍能识别和消费网盘资源,不会因为 Spider 侧强行解析而丢失链接。 - -## 解析与兼容性策略 - -为了降低页面结构波动影响,实现上采用“选择器优先、正则兜底”的策略: - -- 列表与搜索主走 XPath -- 详情页按钮参数用正则提取 -- 网盘链接通过 `atob('...')` 片段做 base64 解码 - -文本处理原则: - -- 缺失字段返回空字符串 -- 标题、备注、简介统一做空白折叠 -- 图片和详情链接统一补全为绝对地址 - -## 错误处理 - -实现遵循“失败可回退、最终返回空结果而非抛异常”的原则: - -- 页面请求失败时返回空结果 -- 单条线路解析失败时跳过该线路,继续解析其他线路 -- 网盘链接解码失败时跳过当前网盘项,不影响普通线路输出 -- 搜索页或详情页局部字段缺失时,保留其余可解析字段 - -## 测试设计 - -采用测试优先方式实现,先补单测,再写生产代码。 - -测试重点放在纯解析逻辑和结果结构,不依赖真实网络: - -1. 首页内容 - - 断言固定分类和筛选项按预期输出 -2. 分类列表 - - 断言 URL 构造与卡片解析正确 -3. 搜索 - - 断言 POST 请求参数正确 - - 断言搜索结果卡片可解析 -4. 详情 - - 断言能同时提取普通线路和网盘线路 - - 断言 `vod_play_from` / `vod_play_url` 正确分组 -5. 播放 - - 断言普通线路返回完整直链 - - 断言网盘线路直接透传分享链接 - -测试文件命名: - -- `tests/test_低端影视.py` - -## 验收标准 - -满足以下条件即视为完成: - -- 新增 `低端影视.py`,可被仓库按现有方式加载 -- `homeContent/categoryContent/detailContent/searchContent/playerContent` 均返回符合当前项目习惯的数据结构 -- 详情页普通线路与网盘线路分开输出 -- 网盘分享链接在详情页和播放阶段都不会被丢弃 -- 新增单测通过,且不破坏现有测试 diff --git a/py/docs/superpowers/specs/2026-04-19-dida-spider-design.md b/py/docs/superpowers/specs/2026-04-19-dida-spider-design.md deleted file mode 100644 index 5a1e692..0000000 --- a/py/docs/superpowers/specs/2026-04-19-dida-spider-design.md +++ /dev/null @@ -1,295 +0,0 @@ -# 滴答影视 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的滴答影视站点爬虫,覆盖以下能力: - -- 首页分类与筛选 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 - -实现基于站点 HTML 页面结构,不引入服务端路由层,不修改 `base/` 公共层,并且仅保留网盘资源。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `滴答影视.py` -- 使用单一站点主域:`https://www.didahd.pro` -- 支持 `home/category/detail/search/player` 主链路 -- 首页返回固定分类与静态筛选配置 -- 详情页仅保留网盘资源,不输出普通播放线路 -- `playerContent` 仅处理网盘分享链接透传 - -本次实现不包含: - -- 普通站内播放线路解析 -- 播放页解密、直链提取与中间页跳转 -- 网盘驱动对象注入和驱动缓存映射 -- 多域名探活与自动切换 -- 登录态、反爬绕过和浏览器自动化 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分为 URL 处理、静态筛选配置、列表卡片解析、详情页网盘资源提取和播放分流几个 helper -- 保留参考实现的筛选配置和 URL 规则,但只实现 Python Spider 需要的部分 - -不直接照搬参考 JS 路由层的原因是: - -- 当前仓库只消费 Spider 接口,不消费站内 HTTP API -- Python 版已有稳定的 HTML 爬虫实现风格,应延续既有结构 -- 本次重点是详情页网盘资源保留,不是驱动管理或中间层封装 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、固定分类、静态筛选项与默认筛选值 -- `homeContent` - - 返回固定 `class` 与 `filters` -- `homeVideoContent` - - 返回空列表,不额外抓首页推荐 -- `categoryContent` - - 按筛选模板构造分类 URL,请求 HTML 并解析卡片 -- `detailContent` - - 请求详情页,提取影片元数据和网盘资源 -- `searchContent` - - 使用站内搜索页并复用卡片解析 -- `playerContent` - - 对网盘分享链接直接透传 -- 私有辅助函数 - - URL 补全 - - 筛选路径拼接 - - 卡片解析 - - 详情元信息提取 - - 网盘资源面板提取 - - 网盘线路排序与去重 - -## Host 与请求策略 - -本次只实现单域: - -- `https://www.didahd.pro` - -请求统一通过 `self.fetch` 发起,固定请求头至少包含: - -- `User-Agent` -- `Referer` - -请求原则: - -- HTML 请求超时固定为 10 秒 -- 请求失败时返回空 HTML 或空结果,不抛出未处理异常 -- 不引入压缩解码、JS 执行或 cookie 持久化 - -## 分类与筛选设计 - -首页分类固定为: - -- `1 -> 电影` -- `2 -> 电视剧` -- `5 -> 综艺` -- `4 -> 动漫` -- `3 -> 纪录片` - -筛选配置直接内置到脚本中,复用用户提供的筛选定义,主要字段包括: - -- `class` -- `area` -- `year` -- `lang` -- `sort` - -筛选 URL 规则采用模板拼接: - -- `{{fl.cateId}}-{{fl.area}}-{{fl.sort}}-{{fl.class}}-{{fl.lang}}-{{fl.letter}}---{{fypage}}---{{fl.year}}` - -规则说明: - -- `cateId` 来自当前分类默认配置 -- 其他字段来自传入的 `extend` -- 若第一页无特殊缩略规则,仍显式保留分页段 -- 当页无结果时返回当前页,避免上层无限翻页 - -`homeContent` 返回: - -- `class` -- `filters` - -不返回首页推荐列表。 - -## 列表与搜索设计 - -分类页、搜索页卡片结构统一按 `.myui-vodlist__box` 解析。 - -每张卡片提取: - -- 链接:主 `a[href]` -- 标题:`.title a` 的 `title` 或文本 -- 封面:`.lazyload` 的 `data-original` 或 `src` -- 备注:`.pic-text` - -输出统一卡片结构: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -搜索接口使用: - -- `/search/-------------.html?wd=<关键词>` - -搜索结果优先解析搜索结果容器;若页面结构回退,则仍复用通用卡片解析。 - -分页策略采用保守估计: - -- `limit` 固定为 12 -- 当页有结果时,`pagecount = page + 1` -- 当页无结果时,`pagecount = page` - -## 详情页设计 - -详情页只解析网盘资源,不保留普通播放线路。 - -元信息提取范围: - -- 标题 -- 封面 -- 年份、地区、类型 -- 导演、主演 -- 剧情简介 - -详情输出字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_content` -- `vod_remarks` -- `vod_year` -- `vod_area` -- `vod_class` -- `vod_director` -- `vod_actor` -- `vod_play_from` -- `vod_play_url` - -### 网盘资源 - -详情页应定位“视频下载”或等价的下载面板,提取其中网盘链接。 - -支持识别的网盘线路包括: - -- `quark` -- `baidu` -- `xunlei` -- `uc` -- `aliyun` - -每个网盘资源条目格式: - -- `标题$分享链接` - -去重规则: - -- 同一线路内按分享链接去重,不按标题去重 -- 保留首次出现的标题文案 - -排序规则: - -- `baidu` -- `quark` -- `uc` -- `aliyun` -- `xunlei` -- 其他未识别网盘名按原始顺序排最后 - -如果详情页不存在任何网盘资源: - -- `vod_play_from` 为空字符串 -- `vod_play_url` 为空字符串 - -## 播放设计 - -`playerContent(flag, id, vipFlags)` 仅处理网盘分享链接。 - -### 网盘线路 - -当 `id` 本身已经是网盘分享链接时,直接透传: - -- `parse = 0` -- `playUrl = ""` -- `url = 原始分享链接` - -识别范围至少包括: - -- `drive.uc.cn` -- `pan.quark.cn` -- `pan.baidu.com` -- `pan.xunlei.com` -- `alipan.com` -- `aliyundrive.com` - -### 非网盘线路 - -普通线路视为不支持,直接返回空: - -- `parse = 0` -- `playUrl = ""` -- `url = ""` - -这样可以明确表达“本 Spider 只保留网盘资源,不负责普通播放”。 - -## 错误处理 - -实现遵循“失败可回退、最终返回空结果而非抛异常”的原则: - -- 页面请求失败时返回空结果 -- 单条网盘资源解析失败时跳过,不影响其他条目 -- 详情页缺失字段时返回空字符串 -- 搜索或分类页无结果时返回空列表 - -## 测试设计 - -采用测试优先方式实现,先补单测,再写生产代码。 - -测试重点放在纯解析逻辑和结果结构,不依赖真实网络: - -1. 首页内容 - - 断言固定分类和筛选项输出正确 -2. 分类列表 - - 断言 URL 构造正确 - - 断言卡片解析正确 -3. 搜索 - - 断言搜索 URL 正确 - - 断言搜索结果卡片可解析 -4. 详情 - - 断言只保留网盘资源,不保留普通线路 - - 断言网盘资源按链接去重和优先级排序 -5. 播放 - - 断言网盘分享链接直接透传 - - 断言普通线路返回空 URL - -测试文件命名: - -- `tests/test_滴答影视.py` - -## 验收标准 - -满足以下条件即视为完成: - -- 新增 `滴答影视.py`,可被仓库按现有方式加载 -- `homeContent/categoryContent/detailContent/searchContent/playerContent` 返回符合当前项目习惯的数据结构 -- 详情页只输出网盘线路 -- 网盘资源按分享链接去重,并按优先级排序 -- 播放阶段对网盘分享链接直接透传 -- 新增单测通过,且不破坏现有测试 diff --git a/py/docs/superpowers/specs/2026-04-19-gimy-spider-design.md b/py/docs/superpowers/specs/2026-04-19-gimy-spider-design.md deleted file mode 100644 index e45035e..0000000 --- a/py/docs/superpowers/specs/2026-04-19-gimy-spider-design.md +++ /dev/null @@ -1,412 +0,0 @@ -# Gimy剧迷 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 Gimy剧迷站点爬虫,覆盖以下能力: - -- 首页分类 -- 首页推荐视频 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 - -实现基于网页 HTML 抓取与播放页脚本解析,不依赖 Playwright,不修改 `base/` 公共层。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `剧迷.py` -- 使用单一站点主域:`https://gimyai.tw` -- 支持 `home/homeVideo/category/detail/search/player` 全链路 -- 支持分类排序筛选 -- 支持多线路详情播放列表 -- 支持搜索结果精筛 -- 支持播放页直链提取与 `parse.php` 回退解析 - -本次实现不包含: - -- 多域名自动回退 -- Cloudflare 或其他风控绕过 -- 第三方繁转简依赖 -- 参考 JS 中全部兼容分支的 1:1 平移 -- 通用电影网站抽象层 - -## 方案选择 - -采用仓库风格的单文件拆分方案: - -- 对外保持当前仓库统一的 `Spider` 接口 -- 对内拆成列表解析、详情解析、搜索精筛、播放解析、文本清洗与繁转简几个独立 helper -- 优先复用当前仓库里 HTML 站点的实现风格,而不是逐行翻译参考 JS - -不做逐行平移的原因是: - -- 当前仓库以单站点单文件为主,便于维护和测试 -- 参考 JS 中包含动态依赖探测和运行时日志逻辑,Python 版只保留真正影响解析结果的核心链路 -- 本次目标是先把常见场景稳定覆盖,而不是追求所有历史兼容分支同步迁移 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、分类配置、筛选配置 -- `homeContent` - - 返回固定分类与筛选定义 -- `homeVideoContent` - - 抓取首页卡片,返回首页推荐列表 -- `categoryContent` - - 构造分类页 URL 并解析卡片 -- `detailContent` - - 请求详情页并整理影片元信息与多线路剧集列表 -- `searchContent` - - 请求站内搜索页,解析原始结果并做关键词精筛 -- `playerContent` - - 解析播放页脚本,优先直取媒体地址,失败时回退到 `parse.php` 或播放页 -- 私有辅助函数 - - URL 归一化 - - HTML 文本清洗 - - 精简繁转简映射 - - 列表卡片解析 - - 详情字段提取 - - 播放页脚本提取和 URL 解码 - - 搜索关键词归一化与打分 - -## Host 与请求策略 - -本次只实现单域: - -- `https://gimyai.tw` - -请求头固定为浏览器样式,至少包含: - -- `User-Agent` -- `Accept-Language` -- `Referer` -- `Accept` - -对播放页和 `parse.php` 请求,按需要补充: - -- `Origin` - -请求策略保持轻量: - -- 所有页面请求统一走 `self.fetch` -- 默认超时取 10 秒到 20 秒之间的站点级固定值 -- 非 200 响应统一回退为空 HTML 或在播放解析时进入回退流程 - -不做: - -- host 探活切换 -- 重试队列 -- 代理层改造 - -## 分类与筛选设计 - -分类固定为: - -- `1 -> 电影` -- `2 -> 电视剧` -- `4 -> 动漫` -- `29 -> 综艺` -- `34 -> 短剧` -- `13 -> 陆剧` - -筛选只支持排序字段 `by`,候选值为: - -- `time` -- `hits` -- `score` - -`homeContent` 返回: - -- `class` -- `filters` - -其中 `filter_def` 只在站点内部用于默认值,不额外暴露到返回结构。 - -## 首页与列表设计 - -### 首页推荐 - -`homeVideoContent` 直接请求首页 HTML,并复用列表卡片解析逻辑。 - -卡片解析范围: - -- 详情链接包含 `/detail/` -- 标题优先取 `title` -- 回退 `img alt` -- 再回退 `.title`、`.video-text`、标题标签文本或卡片文本 -- 封面优先取 lazyload 属性,再回退 `img src` 或背景图样式 -- 备注从卡片或父容器文本中抓取常见状态文案,例如: - - `更新至第X集` - - `全X集` - - `HD` - - `TC` - - `抢先版` - -首页推荐只返回前 24 条,保持与参考代码一致。 - -### 分类列表 - -分类 URL 模式: - -- `/genre/.html` -- 当 `page > 1` 或排序不为 `time` 时追加 `?page=&by=` - -返回分页字段: - -- `page = 当前页` -- `pagecount = pg + 1`,当页命中数量达到列表页常规规模时递增 -- `limit = 20` -- `total = pg * 20 + 当前条数` - -这里的总数是近似值,和当前仓库其他 HTML 站点的处理方式保持一致,不承诺真实总数。 - -## 文本清洗与繁转简 - -站点内容与搜索结果里可能混有繁体字。为了保证搜索命中率和展示稳定性,脚本内部会提供两层处理: - -- `normalizeText` - - 去 HTML 标签 - - 去 ` ` - - 压缩空白 -- `convertTraditionalToSimplified` - - 使用内置映射表做精简繁转简 - -不引入 `opencc` 或其他第三方库,原因是: - -- 当前仓库未配置相关依赖 -- 本次只需要覆盖站点常见影视字段和搜索词,不需要通用全文转换 -- 减少安装和运行环境差异 - -映射表优先覆盖: - -- 影视分类词 -- 常见标题用字 -- 搜索噪声词 - -展示用文本通过 `toDisplayText` 统一进入“清洗 + 繁转简”链路。 - -## 搜索设计 - -搜索 URL: - -- `/find/-------------.html?wd=&page=` - -搜索流程分两步: - -1. 解析原始卡片列表 -2. 对原始列表做关键词精筛 - -关键词精筛策略保留参考实现的核心思路: - -- 对关键词和片名统一做繁转简 -- 去空格、破折号、标点和括号 -- 移除常见噪声后缀,例如: - - `线上看` - - `全集` - - `连续剧` - - `电视剧` - - `动漫` - - `电影` - - `综艺` -- 衍生 token: - - 原始归一化词 - - 去噪声词后的词 - - 去掉结尾集数或纯数字后的词 -- 打分规则: - - 完全相等最高 - - 前缀匹配次之 - - 包含匹配再次之 - - 片名较短且被 token 包含作为弱命中 - -若打分结果为空,再回退到宽松包含匹配,避免误把合法结果全部过滤掉。 - -## 详情页设计 - -详情 URL 模式: - -- `/detail/.html` - -`detailContent` 支持传入: - -- 纯数字详情 id -- 完整详情 URL - -输出字段包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_content` -- `vod_remarks` -- `type_name` -- `vod_year` -- `vod_area` -- `vod_actor` -- `vod_director` -- `vod_play_from` -- `vod_play_url` - -字段提取策略: - -- 标题优先取详情页主标题,回退页面 `` -- 封面优先取 `og:image`,再回退详情图区域图片 -- 剧情简介优先取详情正文区域,回退 `meta description` -- `状态`、`类别`、`年代`、`国家/地区` 通过标签名前缀匹配提取 -- `主演` 与 `导演` 优先从带链接的列表区域提取并以逗号连接 - -### 多线路播放列表 - -详情页的线路和剧集采用 tab + playlist 容器结构: - -- 线路名从 `#playTab a[href^='#con_playlist_']` 提取 -- 每个 tab 内的剧集链接从 `/play/<id>.html` 提取 -- 单集格式为 `集名$play_id` - -最终按当前仓库约定输出: - -- `vod_play_from = 线路A$$$线路B` -- `vod_play_url = 第1集$100-1-1#第2集$100-1-2$$$正片$100-2-1` - -## 播放解析设计 - -播放 URL 模式: - -- `/play/<play_id>.html` - -`playerContent` 支持传入: - -- 紧凑 `play_id` -- 完整播放页 URL - -### 播放页数据提取 - -核心信息来自页面脚本中的 `player_data`: - -- `url` -- `encrypt` -- `from` - -提取方式: - -- 用正则抓取 `var player_data = {...}` -- JSON 解析失败则视为无数据 - -### URL 解码策略 - -保留参考实现的解码规则: - -- `encrypt = 0` - - 原样使用 -- `encrypt = 1` - - URL decode -- `encrypt = 2` - - base64 decode 后再尝试 URL decode - -### 播放优先级 - -1. 若解码后的 `rawUrl` 已经是以 `m3u8/mp4/flv/m4s` 结尾的媒体地址,直接返回: - - `parse = 0` - - `url = rawUrl` -2. 若 `rawUrl` 非空但不是直接媒体地址,则根据线路名构造解析器地址: - - 默认 `https://play.gimyai.tw/v/parse.php` - - `JD4K/JD2K/JDHG/JDQM` 走 `/d/parse.php` - - `NSYS` 走 `/n/parse.php` -3. 若 `parse.php` 返回 JSON,优先读取: - - `url` - - `video` - - `playurl` -4. 若解析器没有给出直链,但 `rawUrl` 自身是 http 地址,则返回: - - `parse = 1` - - `jx = 1` - - `url = rawUrl` -5. 全部失败时回退返回播放页地址,保证行为可用: - - `parse = 1` - - `jx = 1` - - `url = 播放页 URL` - -### 返回头策略 - -直链成功时按场景返回最小必要头: - -- 直接媒体地址: - - 保留浏览器 `User-Agent` - - `Referer = 播放页` -- `parse.php` 成功: - - `Referer = 对应解析页` - - `Origin = 解析页 origin` - -## 错误处理策略 - -站点脚本应尽量返回空结构或回退值,而不是把异常直接抛到上层。 - -原则如下: - -- 列表页请求失败时返回空列表分页 -- 详情页单条失败时可跳过当前 id,避免批量请求全失败 -- 播放解析失败时优先回退到播放页 URL -- JSON 解析失败一律回退空字典或空字符串 - -不做细粒度错误分类,只做站点级稳妥回退。 - -## 测试设计 - -新增测试文件: - -- `tests/test_剧迷.py` - -测试只做离线单元测试,使用 `patch` mock 网络请求,不依赖真实站点。 - -重点覆盖以下行为: - -1. `homeContent` - - 返回固定分类和 `filters` -2. 列表卡片解析 - - 能提取 `vod_id`、`vod_name`、`vod_pic`、`vod_remarks` -3. `detailContent` - - 能解析基础字段和多线路播放列表 -4. 搜索精筛 - - 简体关键词能命中繁体标题 - - 噪声后缀不会挤掉正确结果 -5. `playerContent` - - 直接媒体地址时返回 `parse=0` - - `parse.php` 成功时返回解析后的直链 - - `parse.php` 失败时能回退到原始 URL 或播放页 -6. 解码辅助函数 - - 覆盖 `encrypt=0/1/2` - -测试风格对齐: - -- `tests/test_dbku.py` -- `tests/test_youknow.py` - -## 非目标与风险 - -本次实现明确不覆盖: - -- 多 host 灾备 -- 强风控页面或验证码绕过 -- 站点未来改版后的自适应恢复 -- 通用繁转简库级别的高完整度转换 - -主要风险如下: - -- Gimy 的 DOM 类名若调整,列表和详情解析会失效 -- 播放解析依赖 `player_data` 和 `parse.php` 的现有格式,若接口切换需单独修复 -- 精简繁转简映射只覆盖常见影视词,极少数片名可能仍然需要宽松匹配兜底 - -## 实施顺序 - -推荐实施顺序如下: - -1. 先写 `tests/test_剧迷.py`,覆盖卡片、详情、搜索和播放关键行为 -2. 运行测试确认红灯 -3. 实现 `剧迷.py` 的基础结构与 helper -4. 逐步补齐搜索精筛与播放解析,直到测试转绿 -5. 最后运行站点测试集合做回归确认 diff --git a/py/docs/superpowers/specs/2026-04-19-juquanquan-spider-design.md b/py/docs/superpowers/specs/2026-04-19-juquanquan-spider-design.md deleted file mode 100644 index 53178f9..0000000 --- a/py/docs/superpowers/specs/2026-04-19-juquanquan-spider-design.md +++ /dev/null @@ -1,316 +0,0 @@ -# 剧圈圈 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的剧圈圈站点爬虫,覆盖以下能力: - -- 首页分类与推荐 -- 分类列表分页 -- 联想搜索 -- 详情信息与多线路播放列表 -- 播放直链解析与接口回退 - -实现基于站点 HTML 和站内接口,不引入 JS 路由层,不修改 `base/` 公共层。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `剧圈圈.py` -- 新增独立单测,文件名为 `tests/test_剧圈圈.py` -- 固定主域为 `https://www.jqqzx.cc` -- 支持 `homeContent/homeVideoContent/categoryContent/detailContent/searchContent/playerContent` -- 首页固定分类为电影、剧集、动漫、综艺、短剧 -- 详情解析基础信息与多线路播放列表 -- 播放优先返回直链,无法直出时回退到站内解析接口 - -本次实现不包含: - -- 筛选配置 -- 多域名切换 -- 浏览器自动化 -- 站外驱动接入 -- 账户登录或长期 cookie 持久化 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案。 - -对内拆分为: - -- URL 与 id 互转 -- 列表卡片解析 -- 搜索结果解析 -- 详情元信息解析 -- 播放线路解析 -- 播放页数据解码与回退 - -不直接照搬参考 JS 路由层的原因: - -- 当前仓库消费的是 Python Spider 接口,不消费站内服务端路由 -- 已有站点实现普遍使用 `self.fetch + lxml/xpath/regex`,应延续既有结构 -- 本次重点是站点能力迁移,不是路由层复刻 - -## ID 设计 - -用户要求“都尽量压缩成站内 id”,因此统一采用短路径形式,而不是完整 URL。 - -### 详情 id - -列表与搜索返回: - -- `vod/<数字id>` - -例如: - -- `vod/12345` - -内部在 `detailContent` 中还原为: - -- `https://www.jqqzx.cc/vod/12345.html` - -### 播放 id - -详情播放列表返回: - -- `play/<数字id-线路序号-剧集序号>` - -例如: - -- `play/12345-1-1` - -内部在 `playerContent` 中还原为: - -- `https://www.jqqzx.cc/play/12345-1-1.html` - -这样可以: - -- 避免在返回数据中暴露完整 URL -- 保持 id 可读 -- 与站内 URL 结构一一对应 -- 降低与其他站点数字 id 混淆的概率 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本职责拆分如下: - -- `init` - - 初始化主域、请求头和固定分类 -- `homeContent` - - 返回固定分类 -- `homeVideoContent` - - 抓首页并解析推荐卡片 -- `categoryContent` - - 构造分类分页 URL,请求 HTML 并解析卡片 -- `detailContent` - - 还原详情 URL,解析元信息与多线路播放列表 -- `searchContent` - - 请求联想搜索接口并映射为卡片列表 -- `playerContent` - - 还原播放 URL,优先返回直链,失败时回退站内解析接口 -- 私有 helper - - 文本清洗 - - 绝对 URL 组装 - - 详情 id 与播放 id 编解码 - - cookie 合并 - - 播放页数据提取与解码 - -## Host 与请求策略 - -本次只实现单域: - -- `https://www.jqqzx.cc` - -请求统一通过 `self.fetch` 发起,固定请求头至少包含: - -- `User-Agent` -- `Accept-Language` -- `Referer` - -请求原则: - -- HTML / JSON 请求超时固定为 10 秒 -- 请求失败时返回空结果或兜底播放结果,不抛出未处理异常 -- 仅在 `playerContent` 内维护本次请求链路所需的临时 cookie - -## 首页与分类设计 - -首页分类固定为: - -- `dianying -> 电影` -- `juji -> 剧集` -- `dongman -> 动漫` -- `zongyi -> 综艺` -- `duanju -> 短剧` - -`homeContent` 返回: - -- `class` - -不返回筛选配置。 - -`homeVideoContent`: - -- 请求首页 -- 解析推荐卡片 -- 最多返回前 40 条 - -分类 URL 规则: - -- `/type/<分类id>/page/<页码>.html` - -分类返回字段: - -- `page` -- `pagecount` -- `total` -- `list` - -分页策略采用保守估计: - -- 当前页有数据时,`pagecount = page + 1` -- 当前页无数据时,`pagecount = page` - -## 列表与搜索设计 - -分类页和首页卡片解析规则: - -- 外层:`a.module-poster-item.module-item` -- 标题:`.module-poster-item-title` 或 `title/alt` -- 封面:`img` 的 `data-original` 或 `src` -- 备注:`.module-item-note` - -输出统一卡片结构: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -搜索使用联想接口: - -- `/index.php/ajax/suggest?mid=1&wd=<关键词>` - -返回 JSON 后映射为卡片列表。搜索结果中的详情 id 也压缩为: - -- `vod/<数字id>` - -搜索无分页,固定返回: - -- `page = 当前请求页` -- `pagecount = 1` - -## 详情页设计 - -详情页解析以下字段: - -- 标题 -- 封面 -- 类型 -- 备注或状态 -- 主演 -- 导演 -- 剧情简介 -- 多线路播放列表 - -输出至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `type_name` -- `vod_remarks` -- `vod_actor` -- `vod_director` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -线路解析规则: - -- 线路名来自 `#y-playList .module-tab-item` -- 每个线路对应一个 `.his-tab-list` -- 剧集项来自 `a.module-play-list-link[href]` -- 剧集名优先取 `span` 文本,回退到节点文本 -- 剧集链接压缩成 `play/<id>` - -播放列表输出形如: - -- `vod_play_from = 线路1$$$线路2` -- `vod_play_url = 第1集$play/123-1-1#第2集$play/123-1-2$$$正片$play/123-2-1` - -## 播放设计 - -`playerContent(flag, id, vipFlags)` 处理流程: - -1. 将 `play/<id>` 还原为播放页 URL -2. 请求播放页并提取 `player_aaaa` -3. 优先尝试从 `player_aaaa.url` 解出真实地址 -4. 若解出的是直链媒体地址,则直接返回 -5. 若不是直链,则请求: - - `/jx/player.php?vid=<vid>` - - `/jx/api.php` -6. 若接口成功返回可解出的真实地址,则返回直链 -7. 若仍失败,则回退为播放页地址并设置 `parse=1` - -### 播放解码 - -保留参考实现中的三段能力: - -- Base64 解码 -- `md5("test")` 派生 key 的 XOR 解码 -- `error://apiRes_` 前缀清洗与字母映射恢复 - -直链判定标准: - -- `http/https` -- 末尾或查询参数前匹配 `m3u8/mp4/flv/m4s` - -### 播放返回策略 - -直链成功时返回: - -- `parse = 0` -- `jx = 0` -- `url = 真实播放地址` - -无法直出时返回: - -- `parse = 1` -- `jx = 1` -- `url = 还原后的播放页地址` 或中间地址 - -## 错误处理 - -- HTML 解析失败时返回空列表或空字符串,不抛出未处理异常 -- 搜索 JSON 解析失败时返回空搜索结果 -- `player_aaaa` 缺失时,`playerContent` 回退为解析播放页 -- 解析接口返回非法 JSON 时,记录日志并回退 -- cookie 仅在当前 `playerContent` 调用内合并,不做跨请求缓存 - -## 测试设计 - -新增 `tests/test_剧圈圈.py`,至少覆盖: - -- 首页分类返回固定五类 -- 首页推荐卡片解析 -- 分类 URL 构造与分页返回 -- 搜索 JSON 映射为压缩后的 `vod/<id>` -- 详情页元信息提取 -- 多线路播放列表解析为压缩后的 `play/<id>` -- 直链播放返回 -- 解析接口回退成功 -- `player_aaaa` 缺失时回退到播放页 - -测试全部使用 mock HTML / JSON,不依赖外网。 - -## 验收标准 - -- 新增 `剧圈圈.py` -- 新增 `tests/test_剧圈圈.py` -- `home/category/detail/search/player` 都能返回符合当前仓库习惯的数据结构 -- `vod_id` 与播放 id 均压缩为站内短 id,不暴露完整 URL -- 播放逻辑满足“直链优先,解析接口回退” -- 新增测试可独立通过 diff --git a/py/docs/superpowers/specs/2026-04-19-short-id-audit-design.md b/py/docs/superpowers/specs/2026-04-19-short-id-audit-design.md deleted file mode 100644 index fc2a369..0000000 --- a/py/docs/superpowers/specs/2026-04-19-short-id-audit-design.md +++ /dev/null @@ -1,155 +0,0 @@ -# Spider 短路径 ID 审计与修正规范 - -## 目标 - -对当前 Python 仓库内已有 Spider 做一轮 `vod_id` 与播放 id 的规范审计,修正仍然暴露完整 URL 的实现,统一满足以下规则: - -- `vod_id` 使用站内短路径 id -- 播放列表中的剧集 id 使用站内短路径 id -- `detailContent` 与 `playerContent` 在内部把短路径还原为完整请求地址 -- 对外返回数据不暴露完整 URL - -本次工作是规范性修正,不新增站点功能,不调整已有解析能力范围。 - -## 范围 - -本次审计对象包括当前仓库内的这些 Spider: - -- `libvio.py` -- `低端影视.py` -- `滴答影视.py` -- `剧迷.py` -- `橘子TV.py` -- `youknow.py` -- `乌云影视.py` -- `剧圈圈.py` - -本次实现包含: - -- 审计上述 Spider 的 `vod_id` 与播放 id 返回形式 -- 只修改实际违规的 Spider -- 为每个被修改的 Spider 补充或更新测试 - -本次实现不包含: - -- 改动本就已符合规范的 Spider -- 统一不同站点的 id 格式 -- 修改站点已有的播放解析策略 -- 修改与 id 压缩无关的列表、详情、搜索、播放逻辑 - -## 核心规则 - -### 规则 1:按各站原始路径压缩 - -用户要求“按各站原始路径压缩”,因此不采用统一的跨站格式,例如不强制所有站都用 `vod/<id>`。 - -每个站点应保留自身 URL 路径语义,例如: - -- `/detail/123.html` 压缩为 `detail/123` -- `/vod/123.html` 压缩为 `vod/123` -- `/play/100-1-1.html` 压缩为 `play/100-1-1` -- `/movie/demo/` 压缩为 `movie/demo` -- `/d/456/` 压缩为 `d/456` - -允许保留当前 Spider 已稳定使用的非 URL 型短 id,例如: - -- 纯数字 id -- 站点 API 原生 `vodId` -- 已有可逆紧凑 payload - -前提是这些值本身不暴露完整 URL。 - -### 规则 2:详情 id 可逆 - -`vod_id` 必须是 Spider 内部可逆的: - -- 列表/搜索返回短 id -- `detailContent` 内部负责把短 id 还原成完整详情请求地址 - -### 规则 3:播放 id 可逆 - -详情播放列表中的每个剧集项也必须是 Spider 内部可逆的: - -- 详情返回短路径或紧凑播放 id -- `playerContent` 内部负责把 id 还原成完整播放请求地址或播放参数 - -### 规则 4:对外不返回完整 URL - -以下位置禁止返回完整站内详情/播放 URL: - -- 列表页结果中的 `vod_id` -- 搜索结果中的 `vod_id` -- 详情结果中 `vod_play_url` 里的剧集 id - -例外: - -- 外站直链媒体地址 -- 网盘分享地址 -- 本就不是站内详情/播放页的第三方链接 - -## 审计策略 - -采用“全量审计,按需修复”的方式。 - -### 第一步:识别是否违规 - -重点检查: - -- 列表和搜索是否直接把完整详情 URL 赋值给 `vod_id` -- 详情页播放列表是否直接把完整播放 URL 塞进 `vod_play_url` -- `detailContent` 是否把传入值直接当 URL 请求 -- `playerContent` 是否把传入值直接当完整播放页 URL 使用 - -若 Spider 已满足以下任一条件,则视为合规: - -- 使用站点内部短路径字符串 -- 使用纯数字或 API 原生主键 -- 使用当前 Spider 已有可逆紧凑 payload - -### 第二步:最小修复 - -对违规 Spider 只做以下最小改动: - -- 添加 `encode/decode` helper,或复用现有 helper -- 将列表/搜索返回的完整 URL 改为短路径 -- 将详情播放项中的完整播放 URL 改为短路径 -- 在 `detailContent/playerContent` 中添加还原逻辑 - -不做额外重构,不调整字段命名,不重写不相关逻辑。 - -## 当前预期违规对象 - -基于当前代码初步审计,明确违规的对象至少包括: - -- `低端影视.py` -- `滴答影视.py` - -这两个 Spider 当前存在以下问题: - -- 列表/搜索直接返回完整详情 URL 作为 `vod_id` -- `detailContent` 直接把传入的 `vod_id` 当完整 URL 请求 - -对于其他 Spider: - -- `libvio.py`、`剧迷.py`、`youknow.py`、`橘子TV.py`、`乌云影视.py`、`剧圈圈.py` - 当前实现看起来已经使用可逆短 id 或站点主键 - 本次只做确认,不做无意义修改 - -## 测试策略 - -每个被修改的 Spider 至少补以下回归测试: - -- 列表页返回短路径 `vod_id` -- 搜索页返回短路径 `vod_id`(若该 Spider 支持搜索) -- `detailContent` 接受短路径 id 并正确还原详情 URL -- 详情播放列表返回短路径或紧凑播放 id -- `playerContent` 接受短路径或紧凑播放 id 并正确还原播放请求地址 - -测试全部使用 mock HTML / JSON,不依赖外网。 - -## 验收标准 - -- 审计范围内 Spider 不再对外暴露完整站内详情/播放 URL -- 仅对实际违规 Spider 做代码修改 -- 每个被修正的 Spider 都有对应测试覆盖 -- 现有已合规 Spider 不发生接口格式回退 diff --git a/py/docs/superpowers/specs/2026-04-19-wooyun-spider-design.md b/py/docs/superpowers/specs/2026-04-19-wooyun-spider-design.md deleted file mode 100644 index e7e7b3d..0000000 --- a/py/docs/superpowers/specs/2026-04-19-wooyun-spider-design.md +++ /dev/null @@ -1,311 +0,0 @@ -# 乌云影视 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的乌云影视站点爬虫,覆盖以下能力: - -- 首页分类与筛选 -- 首页推荐视频 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 - -实现基于站点现有 JSON API,不依赖浏览器自动化,不修改 `base/` 公共层。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `乌云影视.py` -- 使用单一站点主域:`https://wooyun.tv` -- 支持 `home/homeVideo/category/detail/search/player` 全链路 -- 支持年份、地区、类型、语言、排序筛选 -- 支持多季播放列表 -- 支持播放接口直链优先与播放回退 - -本次实现不包含: - -- 多域名自动回退 -- 登录态或会员内容适配 -- 站点反爬绕过 -- 通用 JSON 影视站抽象层 -- 参考 JS 的 Fastify 路由封装 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分为请求封装、分类过滤构造、列表映射、详情解析、播放载荷编码几个 helper -- 保留参考实现的核心行为,但去掉服务端路由与日志包装层 - -不直接照搬参考 JS 包装层的原因是: - -- 当前仓库只需要站点 Spider,不需要 HTTP 路由注册 -- Python 版单文件 Spider 更符合现有项目结构 -- 测试重点应放在参数构造和解析结果,而不是中间层框架逻辑 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、默认分类、默认分页参数 -- `homeContent` - - 拉取菜单接口,构造 `class` 与 `filters` -- `homeVideoContent` - - 拉取首页推荐接口,返回首页卡片列表 -- `categoryContent` - - 组装搜索接口请求体,返回分页列表 -- `detailContent` - - 组合基础详情、扩展详情和剧集列表,整理成 TVBox 详情结构 -- `searchContent` - - 复用搜索接口执行关键字查询 -- `playerContent` - - 解析编码后的播放载荷,优先返回直链,必要时回查剧集接口 -- 私有辅助函数 - - 安全文本转换 - - 相对地址补全 - - 筛选数据归一化 - - 分类与筛选构造 - - 卡片映射 - - 播放载荷编码与解码 - - 兜底剧集地址查找 - -## Host 与请求策略 - -本次只实现单域: - -- `https://wooyun.tv` - -请求统一走站点 JSON API,固定请求头至少包含: - -- `Accept` -- `Origin` -- `Referer` -- `User-Agent` - -其中: - -- GET/POST 都走 `self.fetch` / `self.post` -- 默认超时使用站点级固定值,优先取 10 秒到 15 秒 -- 非 2xx 响应视为失败并回退为空结果或播放解析 - -不做: - -- 自动重试 -- 动态 host 探活 -- 本地缓存持久化 - -## 分类与筛选设计 - -菜单接口: - -- `/movie/category/menu` - -分类优先保留以下根分类: - -- `movie -> 电影` -- `tv_series -> 电视剧` -- `animation -> 动画` -- `variety -> 综艺` -- `short_drama -> 短剧` - -同时补充特殊年份分类: - -- `THIS_YEAR -> 今年` -- `LAST_YEAR -> 去年` - -筛选定义从菜单接口动态生成,按站点字段映射为: - -- `year -> 年份` -- `region -> 地区` -- `genre -> 类型` -- `lang -> 语言` -- `sort -> 排序` - -排序候选固定为: - -- `default` -- `latest` -- `hot` -- `score` - -`homeContent` 返回: - -- `class` -- `filters` - -分类接口请求体使用: - -- `menuCodeList` -- `pageIndex` -- `pageSize` -- `searchKey` -- `sortCode` -- `topCode` - -其中 `THIS_YEAR` 和 `LAST_YEAR` 不作为 `topCode`,只作为 `menuCodeList` 过滤项,`topCode` 仍回落到 `movie`。 - -## 首页与列表设计 - -首页推荐接口: - -- `/movie/media/home/custom/classify/1/3?limit=<n>` - -推荐列表从区块数据中提取 `mediaResources`,按 `id` 去重后映射为统一卡片结构: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `type_id` -- `type_name` -- `vod_remarks` -- `vod_year` -- `vod_douban_score` -- `vod_actor` -- `vod_director` - -列表与搜索都复用相同映射逻辑,封面优先级为: - -- `posterUrlS3` -- `posterUrl` -- `thumbUrlS3` -- `thumbUrl` - -分类接口: - -- `POST /movie/media/search` - -分页字段返回: - -- `page` -- `pagecount` -- `limit` -- `total` -- `list` - -其中 `limit` 固定为 24,与参考实现保持一致。 - -## 详情页设计 - -详情阶段使用三个接口并行组合: - -- `/movie/media/base/detail?mediaId=<id>` -- `/movie/media/detail?mediaId=<id>` -- `/movie/media/video/list?mediaId=<id>&lineName=&resolutionCode=` - -详情合并规则: - -- 优先使用扩展详情接口字段 -- 基础详情作为兜底 -- 剧集列表单独解析播放源 - -输出字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `type_id` -- `type_name` -- `vod_remarks` -- `vod_year` -- `vod_area` -- `vod_actor` -- `vod_director` -- `vod_content` -- `vod_douban_score` -- `vod_play_from` -- `vod_play_url` - -多季播放列表按季拆分: - -- 多季时 `vod_play_from` 为 `第1季$$$第2季...` -- 单季时 `vod_play_from` 使用线路名或站点名兜底 - -每个剧集条目名称优先按 `第N集` 生成,存在备注时拼接为 `第N集 <remark>`,无法取到集数时回退 `正片`。 - -## 播放设计 - -播放列表中的每一集不直接暴露站点原始对象,而是把最小必要字段编码为可逆载荷: - -- `mediaId` -- `seasonNo` -- `epNo` -- `videoId` -- `playUrl` -- `name` - -编码格式为 base64url JSON 字符串。 - -`playerContent` 处理流程: - -1. 解码播放载荷 -2. 若载荷内已有 `playUrl`,直接返回补全后的绝对地址 -3. 若没有 `playUrl` 但有 `mediaId`,回查剧集接口并按 `seasonNo/videoId/epNo` 找到目标地址 -4. 若仍拿不到地址,回退为 `parse=1` 并把原始 `id` 交给外层解析 - -直链返回头只保留播放必要字段: - -- `Referer` -- `Origin` -- `User-Agent` - -## 搜索设计 - -搜索复用: - -- `POST /movie/media/search` - -请求体固定: - -- `menuCodeList = []` -- `pageIndex = pg` -- `pageSize = 24` -- `searchKey = keyword` -- `sortCode = ""` -- `topCode = ""` - -空关键字直接返回空列表,不发请求。 - -## 错误处理 - -核心策略保持简单明确: - -- HTTP 非 2xx 直接抛错 -- JSON 结构中若存在站点失败标记,也抛错 -- `home/homeVideo/category/search/detail` 失败时返回空结构或上层调用感知异常 -- `playerContent` 失败时优先回退到 `parse=1` - -不增加复杂的错误码映射,只保留对调用方有意义的最小错误信息。 - -## 测试设计 - -测试文件: - -- `tests/test_乌云影视.py` - -单测覆盖以下边界: - -- 菜单数据能够构造固定分类与动态筛选 -- 首页推荐区块能去重并映射统一卡片结构 -- 分类请求体能正确处理根分类与年份特殊分类 -- 分类结果能返回正确分页字段 -- 详情页能合并详情字段并生成多季播放列表 -- 播放载荷编码解码可逆 -- 播放回查逻辑能按 `videoId` 或 `epNo` 命中剧集地址 -- 搜索空关键字直接返回空结构 -- 播放在没有直链时回退 `parse=1` - -测试以 mock HTTP 返回和纯函数解析为主,不依赖真实网络。 - -## 实现约束 - -- 不修改 `base/spider.py` -- 不引入新第三方依赖 -- 维持仓库现有返回结构命名 -- 代码以 ASCII 为主,中文仅用于站点名和用户可见字段 -- 与现有站点脚本风格保持一致,优先单文件内聚实现 diff --git a/py/docs/superpowers/specs/2026-04-20-bubuyingshi-spider-design.md b/py/docs/superpowers/specs/2026-04-20-bubuyingshi-spider-design.md deleted file mode 100644 index b876650..0000000 --- a/py/docs/superpowers/specs/2026-04-20-bubuyingshi-spider-design.md +++ /dev/null @@ -1,369 +0,0 @@ -# 步步影视 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的步步影视站点爬虫,覆盖以下能力: - -- 首页分类与筛选 -- 首页推荐视频 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 - -实现基于站点现有 JSON API,不依赖浏览器自动化,不修改 `base/` 公共层。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `步步影视.py` -- 使用单一站点主域:`https://bbys.app` -- 支持 `home/homeVideo/category/detail/search/player` 全链路 -- 支持类型、地区、年份、排序筛选 -- 支持首页接口失败时回退到主分类聚合 -- 支持播放线路解码 - -本次实现不包含: - -- 多域名自动探活与切换 -- 调试日志文件、路由注册和 Node 中间层封装 -- 浏览器执行、验证码处理或复杂反爬绕过 -- 登录态、会员内容和本地缓存持久化 -- 通用 JSON 影视站抽象层 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分为签名头生成、请求封装、列表映射、详情重组和播放解码几个 helper -- 保留参考 JS 的核心行为,但去掉 Fastify 路由层、调试日志和无关包装 - -不直接照搬参考 JS 路由层的原因是: - -- 当前仓库只消费 Spider 接口,不消费站内 HTTP API 路由 -- Python 版单文件 Spider 更符合现有项目结构 -- 测试重点应放在请求参数与返回映射,而不是路由分发 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、固定头字段、分类、筛选定义和签名常量 -- `homeContent` - - 返回固定 `class` 与 `filters` -- `homeVideoContent` - - 请求首页接口并映射首页卡片,失败时回退到主分类聚合 -- `categoryContent` - - 请求分类接口并返回分页结果 -- `detailContent` - - 请求详情接口并整理影片元数据与播放列表 -- `searchContent` - - 请求搜索接口并映射结果列表 -- `playerContent` - - 解析压缩后的播放载荷,必要时调用解码接口 -- 私有辅助函数 - - 签名生成 - - `app/web` 请求头构造 - - 文本与数组归一化 - - 视频卡片映射 - - 首页数据映射 - - 播放线路重组 - - 播放地址解码与兜底 - -## Host 与请求策略 - -本次只实现单域: - -- `https://bbys.app` - -请求分两类: - -- `app` 接口:`/api.php/app/...` -- `web` 接口:`/api.php/web/...` - -固定签名参数来自参考实现: - -- `pkg = com.sunshine.tv` -- `ver = 4` -- `finger = SF-C3B2B41F6EFFFF9869176CF68F6790E8F07506FC88632C94B4F5F0430D5498CA` -- `sk = SK-thanks` -- `webSign = f65f3a83d6d9ad6f` -- `xClient = 8f3d2a1c7b6e5d4c9a0b1f2e3d4c5b6a` - -请求头策略: - -- `app` 请求带 `x-aid/x-time/x-sign/x-nonc/x-ave` -- `web` 请求在此基础上额外带 `web-sign/X-Client` -- 默认 `User-Agent` 维持参考实现的移动端接口风格 - -失败策略: - -- 单次请求失败返回空数据,不向上抛出未处理异常 -- 首页接口为空时回退到主分类聚合 -- 分类未知或为空时回退到主分类热门列表 - -不做: - -- 自动重试 -- 动态 host 探活 -- 写入本地日志文件 - -## 分类与筛选设计 - -首页分类固定为: - -- `1 -> 电影` -- `2 -> 剧集` -- `3 -> 动漫` -- `4 -> 综艺` - -筛选配置直接内置到脚本中,字段沿用参考实现: - -- `class` -- `area` -- `year` -- `by` - -年份值按当前年份动态生成: - -- 电影从当年递减到 2016,并补充区间项 -- 剧集从当年递减到 2021,并补充区间项 -- 动漫、综艺从当年递减到 2011,并补充“更早” - -`homeContent` 返回: - -- `class` -- `filters` - -`homeVideoContent` 不在 `homeContent` 内联返回,保持与仓库现有模式一致。 - -## 首页与列表设计 - -首页接口: - -- `GET /api.php/web/index/home` - -首页数据从 `data.categories` 提取: - -- 分类列表映射为 `type_id/type_name` -- 各分类下 `videos` 聚合后映射为统一卡片结构 - -卡片字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `type_name` -- `vod_year` -- `vod_area` - -若首页接口为空或异常: - -- 依次请求“电影/剧集/综艺/动漫”四个主分类的热门列表 -- 聚合结果作为首页推荐 - -分类接口: - -- `GET /api.php/web/filter/vod` - -请求参数包括: - -- `type_name` -- `page` -- `sort` -- `class` -- `area` -- `year` - -分类返回字段: - -- `page` -- `limit` -- `total` -- `list` - -为了符合仓库约定,分类与搜索结果不返回 `pagecount`。 - -`limit/total` 采用保守策略: - -- 有结果时 `limit = len(list)` -- `total` 至少为当前页已知数量 -- 不承诺站点真实总页数 - -当外部传入未知分类值时: - -- 不直接报空 -- 自动回退到四个主分类热门列表聚合,减少壳子兼容性问题 - -## 搜索设计 - -搜索接口: - -- `GET /api.php/app/search/index` - -请求参数包括: - -- `wd` -- `page` -- `limit=15` - -返回结果统一映射为卡片结构: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `type_name` -- `vod_year` -- `vod_area` - -空关键字直接返回空结果,不发起请求。 - -## 详情页设计 - -详情接口: - -- `GET /api.php/web/vod/get_detail?vod_id=<id>` - -接口可能返回对象或数组,设计上统一兼容: - -- 若为数组则取首项 -- 若为空则返回空 `list` - -详情输出字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_year` -- `vod_area` -- `vod_actor` -- `vod_director` -- `vod_content` -- `type_name` -- `vod_play_from` -- `vod_play_url` - -说明: - -- `vod_content` 需移除 HTML 标签并把段落、换行转为纯文本 -- `type_name` 优先取站点详情中的分类字段 - -### 播放线路重组 - -站点详情返回的原始播放字段: - -- `vod_play_from` -- `vod_play_url` - -两者都可能以 `$$$` 分组,以 `#` 分集,以 `$` 分割标题和地址。 - -实现时将其重组为仓库侧可用格式: - -- 每个线路名显示为 `线路名(集数)` -- 每个分集地址编码为 `标题$线路名@1@原始地址` - -规则: - -- 空线路组跳过 -- 分集无标题时兜底为“播放” -- 没有线路名时兜底为 `lineN` - -## 播放设计 - -播放阶段优先消费详情中编码后的 `线路名@1@原始地址`。 - -### 输入解析 - -支持三类输入: - -- `线路名@1@原始地址` -- 直接 `http/https` 地址 -- 未带前缀的原始地址 - -解析规则: - -- `@1@` 表示需要调用解码接口 -- 已经是绝对地址时直接透传 -- 未带协议且未带前缀时视为需要解码 - -### 解码接口 - -- `GET /api.php/app/decode/url/?url=<raw>&vodFrom=<from>` - -返回结构可能是: - -- `data` 为字符串 -- `data.url` -- `url` - -实现时依次兼容提取。 - -### 输出规则 - -播放器结果字段: - -- `parse = 0` -- `playUrl = ""` -- `url = 最终地址` - -当最终地址属于以下站点时,额外标记需要嗅探: - -- `iqiyi.com` -- `v.qq.com` -- `youku.com` -- `mgtv.com` -- `bilibili.com` - -兼容策略: - -- 仓库现有 Spider 接口不统一消费 `jx` -- 本次实现仍保留 `jx` 字段,便于上层兼容支持 - -解码失败时: - -- 回退返回原始输入地址 -- 不抛出异常 - -## 测试设计 - -新增 `tests/test_步步影视.py`,使用 `unittest` 和 `unittest.mock`,不访问真实网络。 - -首批测试覆盖: - -- 签名头包含必要字段,`web` 头附带 `web-sign/X-Client` -- `homeContent` 返回固定分类与筛选 -- `homeVideoContent` 可从首页接口提取分类和视频,并在失败时走主分类聚合 -- `categoryContent` 正确拼接查询参数并映射列表 -- 未知分类触发主分类聚合回退 -- `searchContent` 对空关键字不发请求,对有效关键字正确构造参数 -- `detailContent` 兼容对象和数组详情,能完成 HTML 简介清洗 -- 播放线路重组能处理多线路、多分集和空标题兜底 -- `playerContent` 能解析编码播放 ID,并在需要时调用解码接口 -- 解码结果兼容 `data/data.url/url` 三种结构 - -验证顺序: - -- 先跑 `tests/test_步步影视.py` -- 通过后再视情况补跑更大范围测试 - -## 风险与取舍 - -主要风险: - -- 站点接口签名字段未来可能变更 -- 首页与分类接口的真实响应结构可能出现字段漂移 -- 解码接口返回格式不稳定 - -本次取舍: - -- 优先保证接口兼容和测试可维护性 -- 不为未知响应结构做过度抽象 -- 不引入真实联网测试,避免把站点波动带进单测 diff --git a/py/docs/superpowers/specs/2026-04-20-heimao-app-spider-design.md b/py/docs/superpowers/specs/2026-04-20-heimao-app-spider-design.md deleted file mode 100644 index fdaabce..0000000 --- a/py/docs/superpowers/specs/2026-04-20-heimao-app-spider-design.md +++ /dev/null @@ -1,364 +0,0 @@ -# 黑猫 APP Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的黑猫 APP 爬虫,覆盖以下能力: - -- 首页分类与筛选 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 - -实现以用户提供的 Node/JS 版本为行为参考,但落地形式遵循当前仓库的单文件 Spider 约定。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `黑猫APP.py` -- 使用黑猫 APP 的 AES-CBC 接口协议 -- 支持固定 `url`,并为后续动态 `site` 获取 host 预留扩展 -- 支持 `home/category/detail/search/player` 全链路 -- 支持分类排序、分类屏蔽和分类重命名 -- 支持地区融合和年份自动补充 -- 支持线路排序、线路重命名和线路屏蔽 -- 为新增行为补齐 `unittest` - -本次实现不包含: - -- Node/Fastify 路由层 -- OCR 服务联调或真实验证码识别 -- 修改 `base/` 公共层 -- 多站点自动探活 -- 本地缓存和重试机制 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分成配置、AES 加解密、API 调用、分类处理、详情解析、播放解析几个 helper -- 保留参考 JS 的主要业务分支,但移除与仓库无关的 HTTP 路由包装 - -不直接照搬参考 JS 导出层的原因是: - -- 当前仓库消费的是 Spider 方法,不是独立 HTTP 服务 -- 单文件 Spider 与现有项目结构一致,测试成本最低 -- Python 版重点应落在字段映射、短 ID 与播放分支,而不是请求分发 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化 host、API path、AES key/iv、UA、分类配置、地区融合配置、线路配置 -- `homeContent` - - 拉取初始化接口,输出分类与筛选 -- `homeVideoContent` - - 返回空列表 -- `categoryContent` - - 请求分类筛选接口,必要时执行大陆地区聚合 -- `searchContent` - - 请求搜索接口,执行本地结果过滤 -- `detailContent` - - 请求详情接口并整理影片元数据与播放列表 -- `playerContent` - - 根据线路模式、解析类型和 `vodParse` 结果输出播放信息 -- 私有辅助函数 - - AES 加解密 - - API 请求 - - 年份补全 - - 地区融合 - - 线路整理 - - OCR 预留 - -## Host 与请求策略 - -默认主域使用配置中的: - -- `http://app1-0-0.87333.cc` - -请求策略: - -- 接口路径按 `api=1` 组装为 `/api.php/getappapi.index/<endpoint>` -- 请求方法统一为 `POST` -- 请求头默认只带 `User-Agent` 与 `Accept-Encoding` -- 返回体中的 `data` 字段先做 AES-CBC 解密,再解析 JSON - -扩展策略: - -- 如果后续配置了 `site`,则先请求 `site` 取得真实 host,再继续后续接口请求 -- 若初始化接口中出现 `system_search_verify_status`,则把搜索验证码状态置为开启 - -异常策略: - -- 单次请求失败时尽量返回空结果,不向上抛出未处理异常 -- 某个详情接口失败时允许切换备用端点继续尝试 -- 播放解析失败时返回空 URL 或回退系统解析,而不是抛异常 - -## 配置设计 - -站点文件内部维护默认配置对象,至少包含: - -- `name` -- `url` -- `api` -- `dataKey` -- `dataIv` -- `init` -- `search` -- `version` -- `ua` -- `headers` -- `categories` -- `areaMerge` -- `ocr` - -分类管理配置: - -- `blockedNames` -- `renameMap` -- `forceOrder` - -地区融合配置: - -- `enabled` -- `displayName` -- `mergeList` - -线路管理配置: - -- 线路匹配关键字 -- 显示名 -- 排序权重 -- 解析模式 -- 是否启用 - -这样可以把黑猫的站点差异保持在站点文件内部,避免污染公共层。 - -## 分类与筛选设计 - -`homeContent` 的输入来源是初始化接口返回的 `type_list`。 - -分类处理规则: - -- 屏蔽名称包含在 `blockedNames` 里的分类,例如 `伦理` -- 对命中的分类名应用 `renameMap` -- 若配置了 `forceOrder`,则按配置顺序重排已保留分类 - -筛选转换规则: - -- 将接口中的 `class/area/lang/year/sort` 转成仓库常用的筛选结构 -- `sort` 对外统一映射为 `by` -- 显示名映射为 `类型/地区/语言/年份/排序` - -特殊筛选处理: - -- 地区筛选启用融合时,把 `中国大陆/大陆/内地` 合并显示为单个 `大陆` -- 年份筛选自动补入当前年份;若列表里无当前年份,则插入到 `全部` 后面 - -首页返回字段: - -- `class` -- `filters` -- `list` - -其中 `list` 直接复用初始化返回的分类推荐数据。 - -## 列表与搜索设计 - -分类列表使用接口: - -- `typeFilterVodList` - -请求参数包括: - -- `type_id` -- `page` -- `area` -- `year` -- `sort` -- `lang` -- `class` - -对外筛选字段中的 `by` 在请求时还原成接口所需的 `sort`。 - -大陆地区融合开启且用户选择融合值时: - -- 不直接请求一次 `area=大陆` -- 改为依次请求 `中国大陆/大陆/内地` -- 按 `vod_id` 去重后合并结果 - -搜索使用接口: - -- `searchList`,也允许通过配置覆盖 - -搜索额外规则: - -- 若初始化要求验证码,且 OCR 关闭或识别失败,则返回空列表和错误信息 -- 本地过滤掉 `vod_class` 包含 `伦理` 的结果 -- 如果有搜索词,则只保留标题、备注或分类文本中包含关键词的结果 - -分页返回字段遵循仓库当前约定: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -## 详情设计 - -详情优先尝试两个端点: - -- `vodDetail` -- `vodDetail2` - -只要任一端点成功即可继续解析。 - -输出字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_content` -- `vod_actor` -- `vod_director` -- `vod_year` -- `vod_area` -- `vod_play_from` -- `vod_play_url` - -详情线路处理规则: - -- 线路名来自 `vod_play_list[].player_info.show` -- 若线路名包含 `防走丢/群/防失群/官网` 等无意义提示词,则回退为 `1线/2线...` -- 同名线路追加序号避免冲突 -- 被线路配置禁用的线路直接跳过 -- 线路显示名、排序权重和解析模式从线路配置读取 - -每个播放条目输出为: - -- `<剧集名>$<线路名>@@<模式>@@<payload>` - -其中 `payload` 由以下字段拼接: - -- `parse_api` -- `play_url` -- `token` -- `player_parse_type` -- `parse_type` - -多集用 `#` 连接,多线路用 `$$$` 连接。 - -## 播放解析设计 - -`playerContent` 输入的 `id` 实际承载详情阶段拼好的内部播放串。 - -解析顺序如下: - -第一层,线路被禁用: - -- 直接返回空地址 - -第二层,自动解析线路: - -- 若线路模式为 `auto` -- 则按配置里的解析器优先级调用对应 app 解析器 -- 任意解析器返回可用 `url` 即结束 - -第三层,按 `parse_type` 直接分支: - -- `parse_type == 0` - - 认为 `play_url` 已是直链 - - 返回 `parse=0` -- `parse_type == 2` - - 返回 `parse=1` - - URL 为 `parse_api + play_url` - -第四层,按 `player_parse_type == 2` 尝试解析接口: - -- 直接请求 `parse_api + play_url` -- 如果响应 JSON 中存在 `url`,则返回直链 - -第五层,回退 `vodParse`: - -- 先把 `play_url` 做 AES-CBC 加密 -- 调用 `vodParse` -- 从返回 JSON 里提取真实 URL - -返回字段统一为: - -- `parse` -- `jx` -- `url` -- `header` - -其中直链场景返回 `parse=0, jx=0`;需要二次解析的场景返回 `parse=1, jx=1`。 - -## 验证码与 OCR 设计 - -首版只做结构预留,不把 OCR 作为通过条件。 - -保留两个辅助函数: - -- `replaceCode` -- `getVerificationCode` - -行为约束: - -- 只有在初始化明确声明搜索需要验证码时才尝试获取 -- 若 `ocr.enabled` 为 `false`,则搜索直接返回空列表和错误信息 -- 不在单测中依赖真实 OCR 服务 - -## 测试设计 - -新增 `tests/test_黑猫APP.py`,覆盖最小可验证行为: - -- 初始化后首页分类会按配置过滤与重排 -- 地区筛选会把大陆相关值合并 -- 年份筛选会补入当前年份 -- 分类请求会把 `by` 映射为 `sort` -- 选择融合地区时会发起多次请求并按 `vod_id` 去重 -- 搜索会过滤 `伦理` 内容并做关键词匹配 -- 详情会清洗线路名、处理重复线路名并按排序输出 -- 播放解析会覆盖 `parse_type=0` -- 播放解析会覆盖 `parse_type=2` -- 播放解析会覆盖 `player_parse_type=2` 的直连分支 -- 播放解析会覆盖 `vodParse` 回退分支 - -测试实现手段: - -- 使用 `unittest` -- 通过 `patch.object` mock `_api_post`、`fetch`、`post` -- 使用固定 JSON fixture,不访问真实站点和 OCR 服务 - -## 风险与约束 - -主要风险: - -- 黑猫接口 host 可能变动 -- 某些接口返回结构可能在不同部署之间存在细微差异 -- OCR 验证码链路依赖外部服务,不适合首版强耦合 - -约束处理: - -- 通过站点配置与 helper 隔离 host、key、路径差异 -- 详情接口按双端点兜底 -- 搜索验证码默认走可关闭策略,避免首版被外部依赖卡死 - -## 验收标准 - -满足以下条件即可视为本次实现完成: - -- 新增 `黑猫APP.py`,可被仓库按普通 Spider 加载 -- 首页返回分类和筛选,且体现分类过滤、地区融合和年份补全 -- 分类、搜索、详情、播放四条链路都有对应单测 -- 单测不依赖真实网络 -- 新增结果字段格式与仓库现有 Spider 保持一致 -- 分类和搜索结果不返回 `pagecount` diff --git a/py/docs/superpowers/specs/2026-04-20-jingyu-app-spider-design.md b/py/docs/superpowers/specs/2026-04-20-jingyu-app-spider-design.md deleted file mode 100644 index 062e572..0000000 --- a/py/docs/superpowers/specs/2026-04-20-jingyu-app-spider-design.md +++ /dev/null @@ -1,90 +0,0 @@ -# 鲸鱼APP Spider Design - -## Overview -Port of the JS TVBox T4 plugin for 鲸鱼APP to Python, following existing spider patterns in the repo. - -## Files -- `py/鲸鱼APP.py` — spider implementation -- `py/tests/test_鲸鱼APP.py` — unit tests - -## Source Config -- **Host**: Dynamic, fetched from `https://jingyu4k-1312635929.cos.ap-nanjing.myqcloud.com/juyu3.json` -- **API Path**: `/api.php/qijiappapi.index` (api version 2) -- **AES**: CBC mode, key/iv = `AAdgrdghjfgsABC1` -- **Init endpoint**: `initV122` -- **UA**: `okhttp/3.10.0` - -## Spider Methods - -### `init(extend="")` -- Fetch host from COS URL, store as `self.host` -- Call `initV122` to populate categories and filters -- Store category config, area merge config, OCR config -- Check `system_search_verify_status` for search verification - -### `homeContent(filter)` -- Return `class` list with blockedNames filtering, renameMap, forceOrder sorting -- Return `filters` dict per type_id with area merge and year auto-supplement applied - -### `homeVideoContent()` -- Return `initData.type_list` flattened recommend_list - -### `categoryContent(tid, pg, filter, extend)` -- POST to `typeFilterVodList` with type_id, page, area, year, sort, lang, class -- If area is merged value ("大陆"), query all merge values and deduplicate -- Return list + pagination metadata - -### `detailContent(ids)` -- Try `vodDetail`, fallback `vodDetail2` -- Build play lines: filter blocked lines, rename displays, sort by order -- Encode each episode as `name$lineName@@mode@@parse_api,url,token+token,player_parse_type,parse_type` -- Return vod info with vod_play_from (line names $$$-separated) and vod_play_url (episodes #+$$$-separated) - -### `searchContent(key, quick, pg="1")` -- POST to `searchList` with keywords, type_id=0, page -- If search verify enabled, fetch OCR code first -- Filter results by keyword match on name/remarks/class -- Exclude items with "屏蔽预留" in vod_class - -### `playerContent(flag, id, vipFlags)` -- Parse id as `lineName@@mode@@payload` -- Handle modes: - - `parse_type == '0'`: direct URL, parse=0 - - `parse_type == '2'`: prefix parse_api + url, parse=1 - - `player_parse_type == '2'`: GET parse_api+url, extract response.url, parse=0 - - default: AES encrypt url, POST to vodParse, extract inner.url, parse=0 -- Return `{parse, jx, url, header}` - -## Category Management -- `blockedNames`: ["全部"] -- `renameMap`: {} (identity) -- `forceOrder`: ["电影","电视剧","综艺","动漫","短剧"] - -## Area Merge -- Merge ["中国大陆", "大陆", "内地"] → display as "大陆" -- When "大陆" selected in filter, query all merge values and deduplicate by vod_id - -## Encryption -- AES-CBC with PKCS7 padding -- Key/IV: `AAdgrdghjfgsABC1` (UTF-8 encoded, 16 bytes) -- Encrypt: plaintext → AES encrypt → Base64 -- Decrypt: Base64 → AES decrypt → plaintext → JSON parse -- Use `Crypto.Cipher.AES` from pycryptodome - -## OCR Verification (optional) -- API: `http://154.222.22.188:9898/ocr/b64/text` -- Fetch captcha image, base64 encode, POST to OCR API -- Apply character replacement map for common misreads - -## Dependencies -- `pycryptodome` (already in project via 瓜子.py) -- `requests` (base dependency) -- No new dependencies needed - -## Test Strategy -- Mock `fetch`/`post` for network isolation -- Test AES encrypt/decrypt round-trip -- Test category processing (blocking, renaming, sorting) -- Test area merge logic -- Test playerContent parse modes with fixture payloads -- Test searchContent with mock response diff --git a/py/docs/superpowers/specs/2026-04-20-letu-spider-design.md b/py/docs/superpowers/specs/2026-04-20-letu-spider-design.md deleted file mode 100644 index 5d820fc..0000000 --- a/py/docs/superpowers/specs/2026-04-20-letu-spider-design.md +++ /dev/null @@ -1,280 +0,0 @@ -# 乐兔 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的乐兔站点爬虫,覆盖以下能力: - -- 首页分类 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 - -实现以用户提供的 Node/JS 版本为行为参考,但落地形式遵循当前仓库的单文件 Spider 约定。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `乐兔.py` -- 使用单一站点主域:`https://www.letu.me` -- 支持 `home/category/detail/search/player` 全链路 -- 返回仓库当前通用的视频字段 -- 对详情和播放地址使用站内短 ID -- 为新增行为补齐 `unittest` - -本次实现不包含: - -- Node/Fastify 路由层 -- 站点扩展筛选 -- 多域名探活和切换 -- 浏览器执行、验证码处理或动态反爬绕过 -- 修改 `base/` 公共层 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分成 URL 组装、短 ID 编解码、列表解析、详情解析、播放解析几个 helper -- 保留参考 JS 的主要分支,但去掉与仓库无关的 HTTP 路由包装 - -不直接照搬参考 JS 路由层的原因是: - -- 当前仓库消费的是 Spider 方法,不是站内 HTTP API -- 单文件 Spider 与现有项目结构一致,测试也更直接 -- Python 版重点应落在字段映射与解析行为,而不是请求分发 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、分类定义 -- `homeContent` - - 返回固定分类,不内联推荐列表 -- `homeVideoContent` - - 返回空列表,保持与多数现有 Spider 一致 -- `categoryContent` - - 请求分类页并返回分页结果 -- `detailContent` - - 请求详情页并整理影片元数据与播放列表 -- `searchContent` - - 请求搜索页并映射结果列表 -- `playerContent` - - 解析播放页,优先输出直链,失败时回退系统解析 -- 私有辅助函数 - - URL 组装 - - 文本清洗 - - 详情与播放短 ID 编解码 - - 列表卡片解析 - - 详情信息提取 - - 播放配置解析 - -## Host 与请求策略 - -本次只实现单域: - -- `https://www.letu.me` - -请求策略: - -- 默认使用站点页面 HTML,不依赖隐藏接口 -- 所有请求都带固定 `User-Agent` -- `Referer` 默认指向首页 - -异常策略: - -- 单次请求失败时返回空结果,不向上抛出未处理异常 -- 播放解析失败时回退为 `parse=1` -- 不实现自动重试和本地缓存 - -## 分类设计 - -首页分类固定为: - -- `1 -> 电影` -- `2 -> 电视剧` -- `3 -> 综艺` -- `4 -> 动漫` -- `5 -> 短剧` - -`homeContent` 返回: - -- `class` - -不返回筛选配置,因为参考实现里 `filterable = 0`,且 `getFilters()` 为空对象。 - -`homeVideoContent` 直接返回: - -- `{"list": []}` - -这样与仓库当前多数 Spider 的使用方式一致,避免把分类第一页混入首页推荐。 - -## 列表与搜索设计 - -分类列表 URL: - -- `/type/<tid>-<page>.html` - -搜索 URL: - -- `/vodsearch/-------------.html?wd=<keyword>` - -卡片解析统一抽到一个 helper 中,从页面结构提取: - -- 标题:`a[title]` -- 链接:`a[href]` -- 海报:`.large[data-src]` -- 备注:`.small-text` - -输出字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -分页返回字段: - -- `page` -- `limit` -- `total` -- `list` - -为了符合仓库约定,分类和搜索结果不返回 `pagecount`。由于页面没有稳定总页数,本次采用保守返回: - -- `limit = len(list)` -- `total >= 当前页可见数量` - -## 短 ID 设计 - -为了遵循仓库里的短 ID 约定,不对外暴露完整详情页和播放页 URL。 - -详情页短 ID: - -- 原始路径:`/detail/<slug>.html` -- 对外格式:`detail/<slug>` - -播放页短 ID: - -- 原始路径:`/play/<id>.html` -- 对外格式:`play/<id>` - -编码规则: - -- 列表和搜索阶段把详情链接压成 `detail/...` -- 详情页播放列表把线路链接压成 `play/...` - -解码规则: - -- `detailContent` 先把 `detail/...` 还原成站点详情 URL -- `playerContent` 先把 `play/...` 还原成站点播放 URL - -若传入的已经是完整 URL,则兼容直接使用,不额外报错。 - -## 详情页设计 - -详情解析基于详情页 HTML,输出单个视频对象,字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `type_name` -- `vod_actor` -- `vod_director` -- `vod_area` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -字段提取策略: - -- 标题:首个 `h1` -- 海报:详情主图 `img[src|data-src]` -- 类型、演员:从详情信息区链接提取 -- 导演、地区:从对应文本块提取并清洗 -- 简介:详情介绍段落文本 - -播放列表设计: - -- 线路名来自 `.tabs.left-align a` -- 对应剧集来自同序号的 `.playno` -- 每条剧集拼接成 `剧集名$play/...` -- 每个线路内剧集用 `#` 连接 -- 多线路之间用 `$$$` 连接 - -如果某个线路没有可用剧集,则跳过该线路,避免产生空分组。 - -## 播放解析设计 - -播放解析分三层,顺序与参考 JS 保持一致。 - -第一层,直接 JSON: - -- 若播放页响应本身是 JSON -- 且 `code == 200` 且存在 `url` -- 则优先取 `url` - -处理规则: - -- `rose_` 前缀:先去掉前缀,再尝试 `decodeURIComponent + base64`,失败后回退普通 `base64` -- 相对路径:补成主站绝对 URL -- 成功后返回 `parse=0` - -第二层,MacCMS player 配置: - -- 从 HTML 中提取 `player_* = {...}` -- 读取 `url` 与 `encrypt` -- `encrypt == "1"` 时做 URL 解码 -- `encrypt == "2"` 时先 URL 解码再 Base64 解码 - -若结果是直链,则返回: - -- `parse=0` -- `jx=0` -- `url=<real_url>` - -第三层,系统解析兜底: - -- 当前两层都失败时 -- 返回还原后的播放页 URL -- 设置 `parse=1` -- 设置 `jx=1` - -所有成功分支默认附带站点头字段;直链分支的 `Referer` 指向站点首页。 - -## 测试设计 - -实现过程采用 TDD。测试文件为 `tests/test_乐兔.py`,先写失败测试,再写实现代码。 - -首轮测试覆盖: - -- 首页分类返回固定 `type_id/type_name` -- 详情与播放短 ID 的编码和解码 -- 分类列表卡片解析为统一字段 -- 分类页 URL 组装正确 -- 搜索 URL 正确并能解析结果 -- 分类与搜索结果不包含 `pagecount` -- 详情页能提取元数据和多线路播放列表 -- `detailContent` 会先把短 ID 还原成详情页 URL -- 播放解析支持纯 JSON 直链返回 -- 播放解析支持 `rose_` 前缀解码 -- 播放解析支持 MacCMS `encrypt=1` -- 播放解析支持 MacCMS `encrypt=2` -- 播放解析失败时回退系统解析 - -测试只使用内嵌 HTML/JSON 夹具和 mock 请求,不依赖真实网络。 - -## 验收标准 - -完成后应满足: - -- 新增 `乐兔.py` 且不修改 `base/` -- 新增 `tests/test_乐兔.py` -- 分类、搜索、详情、播放四条主链路都可由单测覆盖 -- 外部返回的详情和播放 ID 为短路径,不暴露完整 URL -- 分类和搜索结果不返回 `pagecount` -- 播放解析至少覆盖 JSON、`rose_`、MacCMS、兜底四类分支 diff --git a/py/docs/superpowers/specs/2026-04-20-mofang-app-spider-design.md b/py/docs/superpowers/specs/2026-04-20-mofang-app-spider-design.md deleted file mode 100644 index d59ea3e..0000000 --- a/py/docs/superpowers/specs/2026-04-20-mofang-app-spider-design.md +++ /dev/null @@ -1,382 +0,0 @@ -# 魔方 APP Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的魔方 APP 爬虫,覆盖以下能力: - -- 首页分类与筛选 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 - -实现以用户提供的 Node/JS 版本为行为参考,但落地形式遵循当前仓库的单文件 Spider 约定。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `魔方APP.py` -- 使用魔方 APP 的 AES-CBC 接口协议 -- 固定 host 为 `http://www.613mf4k12.top` -- 支持 `home/category/detail/search/player` 全链路 -- 支持分类排序、分类屏蔽和分类重命名 -- 支持地区融合和年份自动补充 -- 支持线路排序、线路重命名和线路屏蔽 -- 保留搜索验证码分支,并在需要时尝试 OCR -- 为新增行为补齐 `unittest` - -本次实现不包含: - -- 动态 `site` 下发 host -- Node/Fastify 路由层 -- 真实 OCR 服务联调 -- 修改 `base/` 公共层 -- 多解析器 `auto` 优先级调度 -- 本地缓存和重试机制 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案,并以 `黑猫APP.py` 的结构为底稿实现魔方版本。 - -选择该方案的原因: - -- `黑猫APP.py` 已具备 AES、筛选、详情、播放的完整链路骨架 -- 魔方的 JS 逻辑与黑猫同属 APP AES 接口族,迁移成本最低 -- 本次任务目标是新增一个稳定 Spider,不是抽象公共基类 - -本次不抽取共享基类的原因: - -- 会扩大改动面,增加回归范围 -- 现有多个同类 Spider 仍有站点级差异,抽象收益不足 -- 用户当前需求是尽快落地单蜘蛛实现 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化 host、API path、AES key/iv、UA、分类配置、地区融合配置、线路配置、搜索验证码状态 -- `homeContent` - - 拉取初始化接口,输出分类、筛选和首页推荐 -- `homeVideoContent` - - 返回空列表 -- `categoryContent` - - 请求分类筛选接口,必要时执行大陆地区聚合 -- `searchContent` - - 请求搜索接口,执行验证码处理与本地结果过滤 -- `detailContent` - - 请求详情接口并整理影片元数据与播放列表 -- `playerContent` - - 根据线路模式、解析类型和 `vodParse` 结果输出播放信息 -- 私有辅助函数 - - AES 加解密 - - API 请求 - - 年份补全 - - 地区融合 - - 线路整理 - - 验证码获取与 OCR 文本修正 - -## Host 与请求策略 - -固定主域: - -- `http://www.613mf4k12.top` - -接口路径: - -- `api=1`,因此实际请求路径为 `/api.php/getappapi.index/<endpoint>` - -初始化与搜索端点默认值: - -- `initV119` -- `searchList` - -请求策略: - -- 接口请求统一走 `POST` -- 请求头默认带 `User-Agent: okhttp/3.10.0` 和 `Accept-Encoding: gzip` -- 返回体中的 `data` 字段先做 AES-CBC 解密,再解析 JSON -- `version` 暂为空;只有后续站点明确要求时才注入 - -初始化策略: - -- `init` 只做一次 -- 通过初始化接口读取 `system_search_verify_status` -- 若该标记为真,则搜索前进入验证码分支 - -异常策略: - -- 单次请求失败时尽量返回空结果,不向上抛出未处理异常 -- 某个详情端点失败时允许切换备用端点继续尝试 -- 播放解析失败时返回空 URL 或空列表,而不是抛异常 - -## 配置设计 - -站点文件内部维护默认配置对象,至少包含: - -- `name` -- `url` -- `api` -- `dataKey` -- `dataIv` -- `init` -- `search` -- `version` -- `ua` -- `headers` -- `categories` -- `areaMerge` -- `ocr` - -分类管理配置: - -- `blockedNames = ["全部"]` -- `renameMap = {}` -- `forceOrder = ["电影", "连续剧", "综艺", "动漫", "短剧", "直播"]` - -地区融合配置: - -- `enabled = True` -- `displayName = "大陆"` -- `mergeList = ["中国大陆", "大陆", "内地"]` - -OCR 配置: - -- `enabled = False` -- `api = "http://154.222.22.188:9898/ocr/b64/text"` - -线路管理配置: - -- 以内置线路表匹配原始线路名 -- 支持显示名、排序权重、解析模式、是否启用 -- 直接使用用户提供的魔方线路表,包括禁用项如 `水印资源`、`YY` - -## 分类与筛选设计 - -`homeContent` 的输入来源是初始化接口返回的 `type_list`。 - -分类处理规则: - -- 屏蔽名称包含在 `blockedNames` 里的分类,例如 `全部` -- 对命中的分类名应用 `renameMap` -- 若配置了 `forceOrder`,则按配置顺序重排已保留分类 - -筛选转换规则: - -- 将接口中的 `class/area/lang/year/sort` 转成仓库常用的筛选结构 -- `sort` 对外统一映射为 `by` -- 显示名映射为 `类型/地区/语言/年份/排序` - -特殊筛选处理: - -- 地区筛选启用融合时,把 `中国大陆/大陆/内地` 合并显示为单个 `大陆` -- 年份筛选自动补入当前年份;若列表里无当前年份,则插入到 `全部` 后面 - -首页返回字段: - -- `class` -- `filters` -- `list` - -其中 `list` 直接复用初始化返回的分类推荐数据平铺结果。 - -## 列表与搜索设计 - -分类列表使用接口: - -- `typeFilterVodList` - -请求参数包括: - -- `type_id` -- `page` -- `area` -- `year` -- `sort` -- `lang` -- `class` - -对外筛选字段中的 `by` 在请求时还原成接口所需的 `sort`。 - -大陆地区融合开启且用户选择融合值时: - -- 不直接请求一次 `area=大陆` -- 改为依次请求 `中国大陆/大陆/内地` -- 按 `vod_id` 去重后合并结果 - -搜索使用接口: - -- `searchList`,也允许通过配置覆盖 - -搜索规则: - -- 若初始化要求验证码,则在搜索前获取验证码图片、调用 OCR 并修正常见误识别字符 -- 若 OCR 失败,则返回空列表,并附带错误消息 -- 本地过滤掉 `vod_class` 包含 `屏蔽预留` 的结果 -- 如果有搜索词,则只保留标题、备注或分类文本中包含关键词的结果 -- 搜索结果映射为 `vod_id/vod_name/vod_pic/vod_remarks` -- `vod_remarks` 使用 `vod_year + vod_class` 拼接 - -分页返回字段遵循仓库当前约定: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -## 验证码设计 - -验证码仅在初始化标记要求时启用。 - -处理流程: - -- 请求 `/verify/create?key=<uuid>` 获取图片 -- 将图片转为 base64 文本 -- 发送到 OCR 接口 -- 对 OCR 返回文本执行字符替换修正 -- 仅当结果是 4 位数字时才作为有效验证码提交 - -字符修正规则沿用参考 JS 中的映射,例如: - -- `y -> 9` -- `口 -> 0` -- `q/u/o/d/D -> 0` -- `b -> 8` -- `已 -> 2` -- `五 -> 5` - -实现要求: - -- 优先使用标准库方式生成 `uuid` -- 测试中只验证本地逻辑与请求参数,不访问真实 OCR 服务 - -## 详情设计 - -详情优先尝试两个端点: - -- `vodDetail` -- `vodDetail2` - -详情字段映射: - -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_content` -- `vod_actor` -- `vod_director` -- `vod_year` -- `vod_area` -- `vod_play_from` -- `vod_play_url` - -数据整理规则: - -- `vod_actor` 去掉前缀 `演员` -- `vod_director` 去掉前缀 `导演` -- `vod_year` 有值时补后缀 `年` - -线路处理规则: - -- 如果原始线路名含 `防走丢/群/防失群/官网`,则替换为递增兜底名 `1线/2线/...` -- 若线路名重复,则对重复项追加序号,避免冲突 -- 若线路被配置为禁用,则直接跳过 -- 每条线路按配置映射展示名与排序权重 - -播放串编码格式: - -- `集名$线路名@@模式@@parse_api,url,token+,player_parse_type,parse_type` - -输出时: - -- `vod_play_from` 使用排序后的展示线路名,以 `$$$` 拼接 -- `vod_play_url` 使用排序后的集列表,以 `$$$` 拼接 - -## 播放解析设计 - -`playerContent` 输入是详情阶段编码好的播放串。 - -处理顺序: - -- 先解析出线路名、模式和载荷 -- 若线路已禁用,直接返回空地址 -- 当前只支持 `direct` 模式;`auto` 不实现动态 parser 调度 - -解析分支: - -- `parse_type == "0"` - - 直接返回解码后的原始播放地址 - - `parse = 0`,`jx = 0` -- `parse_type == "2"` - - 返回 `parse_api + 播放地址` - - `parse = 1`,`jx = 1` -- `player_parse_type == "2"` - - 先请求 `parse_api + 播放地址` - - 若响应 JSON 里存在 `url`,则直接返回该直链 -- 其他情况 - - 对播放地址执行 AES 加密 - - 请求 `vodParse` - - 从返回的 `json` 字段中提取最终地址 - -播放器返回结构沿用仓库现有 APP Spider 约定: - -- `parse` -- `jx` -- `url` -- `header` - -直连与需二次解析分支均带 Android/Dalvik UA 头;其余分支默认返回空 header。 - -## 测试设计 - -新增测试文件: - -- `tests/test_魔方APP.py` - -测试覆盖目标: - -- AES 加解密可逆 -- `_api_post` 能正确解密响应 -- `homeContent` 会过滤分类、处理地区融合并补当前年份 -- `categoryContent` 能正确映射 `by -> sort` -- `categoryContent` 在选择 `大陆` 时会多次请求并按 `vod_id` 去重 -- `searchContent` 会过滤 `屏蔽预留` 且只保留关键词命中的结果 -- 搜索验证码开启但 OCR 失败时返回空列表 -- `detailContent` 会回退第二详情端点,并按线路规则构造 `vod_play_from` 与 `vod_play_url` -- `playerContent` 覆盖 `parse_type == 0` -- `playerContent` 覆盖 `parse_type == 2` -- `playerContent` 覆盖 `player_parse_type == 2` -- `playerContent` 覆盖 `vodParse` 回退分支 - -测试策略: - -- 使用 `unittest` 和 `unittest.mock` -- 只 mock `fetch/post/_api_post` 等边界调用 -- 不访问真实站点和 OCR 服务 -- 先跑单模块测试,再视需要扩展到更大范围 - -## 验证计划 - -实现完成后的验证顺序: - -1. `python -m unittest tests.test_魔方APP -v` -2. 如果实现过程中复用了或改动了相邻逻辑,再补跑相关模块测试 - -## 风险与取舍 - -主要风险: - -- 魔方站点实际返回字段可能与参考 JS 记录存在细节差异 -- 验证码图片格式或 OCR 服务返回格式可能不稳定 -- 某些线路名可能与当前内置线路表存在别名差异 - -本次取舍: - -- 优先保证与参考 JS 的主要行为一致 -- 不为未使用的 `auto` 解析模式增加复杂度 -- 不提前抽公共基类,避免影响其他 Spider diff --git a/py/docs/superpowers/specs/2026-04-20-ouge-aggregate-design.md b/py/docs/superpowers/specs/2026-04-20-ouge-aggregate-design.md deleted file mode 100644 index 56043ea..0000000 --- a/py/docs/superpowers/specs/2026-04-20-ouge-aggregate-design.md +++ /dev/null @@ -1,222 +0,0 @@ -# 欧歌接入玩偶聚合设计 - -**日期:** 2026-04-20 - -## 目标 - -在当前聚合蜘蛛 `py/玩偶聚合.py` 中新增 `欧歌` 站点配置,使其作为一个独立聚合站点参与首页分类、分类页抓取、搜索聚合和详情网盘线路合并。 - -本次工作同时要求: - -- 保留独立单站 `py/欧歌.py` -- 不抽共享逻辑 -- 不改变现有聚合接口 -- 通过对应 `unittest` - -## 范围 - -本次实现包含: - -- 在 `py/玩偶聚合.py` 的 `self.sites` 中新增 `ouge` 站点 -- 暴露 `site_ouge` 首页分类与 6 个固定分类筛选 -- 支持欧歌分类页和搜索页 URL 模板 -- 让欧歌参与现有聚合搜索与聚合详情 -- 在 `py/tests/test_玩偶聚合.py` 中补充欧歌站点覆盖 - -本次实现不包含: - -- 修改独立 `py/欧歌.py` -- 从独立 `欧歌.py` 复用配置或 helper -- 抽象新的公共站点基类 -- 调整聚合结果的排序规则 -- 修改其它现有站点的行为 - -## 方案选择 - -采用“最小配置接入,必要时增加最小站点兜底”的方案。 - -原因: - -- 当前 `玩偶聚合.py` 已支持同类型网盘站的列表、搜索、详情与网盘线路整理 -- 欧歌站点结构与现有聚合站的 HTML 结构兼容度较高 -- 用户明确要求只接入新站,不抽共享层 -- 最小接入可以把影响面控制在单个聚合文件和对应测试内 - -不采用“顺手与独立 `欧歌.py` 合并配置”的方案,因为会增加耦合和变更面,也不符合用户当前范围要求。 - -## 站点配置设计 - -新增站点定义: - -- `id`: `ouge` -- `name`: `欧歌` -- `domains`: `["https://woog.nxog.eu.org"]` -- `filter_files`: `[]` - -选择器与 URL 模板: - -- `list_xpath`: `//*[contains(@class,'module-item')]` -- `search_xpath`: `//*[contains(@class,'module-search-item')]` -- `detail_pan_xpath`: `//*[contains(@class,'module-row-info')]//p` -- `category_url`: `/index.php/vod/show/id/{categoryId}/page/{page}.html` -- `search_url`: `/index.php/vod/search/page/{page}/wd/{keyword}.html` - -默认分类完整沿用独立欧歌源: - -- `("1", "电影")` -- `("2", "剧集")` -- `("3", "动漫")` -- `("4", "综艺")` -- `("5", "短剧")` -- `("21", "综合")` - -这里故意不使用“欧歌电影”“欧哥剧集”这类首页展示名作为聚合过滤项值名,而是与现有聚合站点保持一致,使用更短的分类名称。 - -## 对外行为 - -### `homeContent` - -新增 `site_ouge`: - -- `class` 中出现 `{"type_id": "site_ouge", "type_name": "欧歌"}` -- `filters["site_ouge"]` 至少包含 `categoryId` 分组 -- `categoryId` 的值顺序必须是: - - 全部 - - 电影 - - 剧集 - - 动漫 - - 综艺 - - 短剧 - - 综合 - -不新增其它本地筛选项。 - -### `categoryContent` - -对 `site_ouge`: - -- 使用现有聚合接口 `categoryContent("site_ouge", pg, filter, extend)` -- 当 `extend` 中没有 `categoryId` 时,默认回退到欧歌首分类 `1` -- URL 组装规则为: - - `/index.php/vod/show/id/{categoryId}/page/{page}.html` -- 列表卡片继续复用现有 `_parse_cards` - -不为欧歌新增独立分类接口。 - -### `searchContent` - -欧歌参与现有多站搜索流程: - -- 站点搜索 URL 为 `/index.php/vod/search/page/{page}/wd/{keyword}.html` -- 搜索结果复用现有 `_parse_search_cards` -- 同名同年结果继续复用 `_aggregate_search_results` -- 聚合结果主信息仍由现有 `site_priority` 决定 - -不为欧歌新增独立搜索合并逻辑。 - -### `detailContent` - -欧歌详情继续走现有聚合详情流程: - -- `_fetch_site_detail` 使用站内短路径抓取详情页 -- `_parse_detail_page` 提取元数据和网盘链接 -- 欧歌站只贡献网盘分享链接,不参与站内播放解析 -- 最终网盘线路继续进入聚合器现有 `vod_play_from` / `vod_play_url` 组装 - -## 排序与优先级 - -`site_priority` 中保留现有 `ouge` 位置,不调整其它站点排序: - -- `wanou` -- `muou` -- `labi` -- `zhizhen` -- `erxiao` -- `huban` -- `kuaiying` -- `shandian` -- `ouge` - -这样可以保证: - -- 现有站点的主图、备注、主结果选择不发生变化 -- 欧歌仅作为新补充来源参与聚合 - -## 错误处理 - -保持现有聚合器行为: - -- 欧歌单站请求失败时,只影响该站,不中断整个聚合搜索 -- 欧歌详情抓取失败时,跳过该站或返回空详情壳 -- 站点 HTML 为空时,返回空列表或空网盘集合 - -不新增: - -- 域名切换 -- 重试 -- 验证码绕过 -- 浏览器执行 - -## 测试设计 - -仅补最小必要覆盖到 `py/tests/test_玩偶聚合.py`。 - -### 首页 - -新增断言: - -- `site_ouge` 出现在 `homeContent(False)["class"]` -- `filters["site_ouge"][0]["value"]` 正确暴露 6 个分类 - -### 分类 - -新增测试: - -- `categoryContent("site_ouge", "2", False, {})` 默认走欧歌分类模板 -- 或者显式提供 `{"categoryId": "21"}` 时能正确拼接欧歌分类 URL -- 列表结果能解析出 `site:ouge:<detail_path>` 形式的 `vod_id` - -### 搜索 - -新增测试: - -- 欧歌站点能参与 `searchContent` -- 至少验证欧歌单站结果可被纳入聚合结果 -- 不重复测试已有的聚合去重算法细节 - -### 详情 - -新增测试: - -- 欧歌详情页提取出的网盘链接能被合并进聚合线路 -- `vod_play_from` / `vod_play_url` 的格式继续符合现有聚合器约定 - -## 变更边界 - -本次改动应控制在: - -- `py/玩偶聚合.py` -- `py/tests/test_玩偶聚合.py` -- 规格与计划文档 - -明确不修改: - -- `py/欧歌.py` -- `py/tests/test_欧歌.py` -- `base/` 公共层 - -## 风险 - -- 欧歌站分类名与独立源首页文案略有差异:独立源是“欧歌电影/欧哥剧集”等,聚合过滤项采用短名称“电影/剧集”等;这是有意保持聚合 UI 一致性的选择 -- 如果欧歌详情页字段结构和现有 `_parse_detail_page` 假设不完全一致,可能需要为欧歌补一个极小的专用兜底分支 -- 现有聚合测试对搜索参与站数量可能较敏感,新增欧歌后需要避免把旧测试写死成固定调用次数 - -## 验收标准 - -满足以下条件即可视为完成: - -- `玩偶聚合.py` 新增 `ouge` 站点配置 -- `site_ouge` 在首页和筛选中可见 -- 欧歌能参与分类页、搜索和详情聚合 -- 独立 `欧歌.py` 继续保留且不受影响 -- 新增/更新的 `tests/test_玩偶聚合.py` 通过 diff --git a/py/docs/superpowers/specs/2026-04-20-ouge-spider-design.md b/py/docs/superpowers/specs/2026-04-20-ouge-spider-design.md deleted file mode 100644 index f5be867..0000000 --- a/py/docs/superpowers/specs/2026-04-20-ouge-spider-design.md +++ /dev/null @@ -1,350 +0,0 @@ -# 欧歌 Python 爬虫设计 - -**日期:** 2026-04-20 - -## 目标 - -在当前 Python Spider 仓库中新增一个独立单站蜘蛛 `欧歌.py`,参考用户提供的 JS 版本行为,实现符合 `base.spider.Spider` 接口的网盘资源站适配。 - -本次实现需要覆盖: - -- 固定 6 个分类 -- 分类列表 -- 搜索 -- 详情页元数据解析 -- 网盘链接整理 -- 播放透传 -- 对应 `unittest` - -## 范围 - -本次实现包含: - -- 新增独立蜘蛛文件 `py/欧歌.py` -- 新增测试文件 `py/tests/test_欧歌.py` -- 单域名站点适配:`https://woog.nxog.eu.org` -- 固定 6 个分类,分类 ID 与参考 JS 保持一致 -- 分类页和搜索页卡片解析 -- 详情页元数据与网盘链接提取 -- 按网盘类型输出 `vod_play_from` 和 `vod_play_url` -- `playerContent` 对已识别网盘分享链接直接透传 - -本次实现不包含: - -- 聚合多站 -- 本地筛选配置文件 -- 站内视频播放解析 -- 验证码、浏览器执行或复杂反爬绕过 -- 修改 `base/` 公共层 - -## 方案选择 - -采用“单站单文件 + 少量 helper + 单测”的仓库现有模式,而不是直接把参考 JS 逐段翻译成 Python。 - -原因: - -- 用户明确要求独立单站文件 -- 当前仓库已经普遍使用单文件 Spider 模式 -- 私有 helper 可以把 URL 组装、文本清洗、列表解析、详情提取和网盘识别拆开,降低后续修站成本 -- 测试可以直接针对 helper 和高层接口分层验证,适合 TDD - -不采用“提前抽公共网盘站基类”的方案,原因是本次只落单站,过早抽象会增加复杂度且不符合当前仓库习惯。 - -## 接口设计 - -### `homeContent` - -返回固定 6 个分类: - -- `1 -> 欧歌电影` -- `2 -> 欧哥剧集` -- `3 -> 欧歌动漫` -- `4 -> 欧歌综艺` -- `5 -> 欧歌短剧` -- `21 -> 欧歌综合` - -不返回筛选项。 - -### `homeVideoContent` - -返回空列表: - -- `{"list": []}` - -### `categoryContent` - -分类页 URL 规则: - -- `/index.php/vod/show/id/{tid}/page/{page}.html` - -解析页面中的卡片,输出: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -分页返回字段: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`,以保持当前仓库对新蜘蛛的约定。 - -### `searchContent` - -搜索 URL 规则: - -- `/index.php/vod/search/page/{page}/wd/{keyword}.html` - -空关键词直接返回空列表。 - -搜索结果结构与分类列表保持一致。 - -### `detailContent` - -通过详情页提取: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_year` -- `vod_director` -- `vod_actor` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -不解析站内播放页,只整理网盘分享链接。 - -### `playerContent` - -若 `id` 是支持的网盘分享链接,则返回透传结果: - -```python -{"parse": 0, "jx": 0, "playUrl": "", "url": id, "header": {}} -``` - -若不是已识别网盘链接,则返回空 URL: - -```python -{"parse": 0, "jx": 0, "playUrl": "", "url": "", "header": {}} -``` - -## 模块边界 - -新蜘蛛内部拆分为以下职责: - -- 站点配置与固定分类 -- URL 组装 -- 文本清洗 -- 图片 URL 修正 -- 列表卡片解析 -- 搜索卡片解析 -- 详情页字段提取 -- 网盘类型识别 -- 网盘线路拼接 - -不新增公共基类,不抽共享模块。 - -## URL 与 ID 设计 - -详情页 `vod_id` 使用站内短路径,而不是完整 URL。 - -编码方式: - -- 详情链接 `/index.php/vod/detail/id/123.html` 对外直接保存为 `/index.php/vod/detail/id/123.html` - -原因: - -- 与参考 JS 行为一致 -- 当前仓库已有多个蜘蛛直接使用站内短路径作为 `vod_id` -- 单站实现不需要再引入额外编码层 - -详情请求时再基于主域拼成完整地址。 - -## 请求策略 - -主域固定为: - -- `https://woog.nxog.eu.org` - -请求头包含固定 `User-Agent`,必要时补 `Referer` 为首页。 - -异常处理策略: - -- 页面请求失败时返回空列表或空字段结果 -- 不向上抛出未处理异常 -- 不实现多域名切换 -- 不实现重试 - -## 图片修正规则 - -保留参考 JS 的核心行为: - -- 空图片返回空字符串 -- 若已是绝对 URL,原样返回 -- 若是站内相对路径,则补全为绝对地址 -- 优先使用 `data-src`,其次回退 `src` - -本次不额外引入聚合站里针对百度图片包裹 URL 的特殊修正,因为用户提供的欧歌参考实现没有这层要求;如果测试夹具后续证明站点确有该问题,再单独补充。 - -## 列表与搜索解析 - -分类列表解析容器: - -- `#main .module-item` - -提取策略: - -- 链接:`.module-item-pic a[href]` -- 标题:`.module-item-pic img[alt]` -- 封面:`.module-item-pic img[data-src|src]` -- 备注:`.module-item-text` - -搜索结果解析容器: - -- `.module-search-item` - -提取策略: - -- 链接和标题优先来自 `.video-serial` -- 封面来自 `.module-item-pic img[data-src|src]` -- 备注优先取 `.video-serial` 文本,缺失时回退 `.module-item-text` - -列表和搜索都应避免空标题、空链接项。 - -## 详情解析 - -详情页从 HTML 中提取以下信息: - -- 标题:`.page-title` -- 封面:`.mobile-play .lazyload[data-src|src]` -- 年份:优先取页面中 `.module-item-caption span` 或其它可用年份文本,缺失时留空 -- 导演、主演、剧情:遍历 `.video-info-itemtitle` 与相邻内容节点 -- 网盘链接:`.module-row-info p` - -字段处理规则: - -- 多个导演或主演用英文逗号连接 -- 去除多余空白字符 -- 缺失字段保留空字符串 - -剧情字段优先从标题为“剧情”的信息块中提取 `p` 文本。 - -## 网盘线路设计 - -详情页中每个分享链接会被识别为网盘类型,再输出为播放线路。 - -首批支持识别: - -- 百度网盘 -- 夸克网盘 -- UC 网盘 -- 阿里云盘 -- 迅雷云盘 -- 115 网盘 -- 天翼云盘 -- 139 网盘 -- 123 云盘 - -输出规则: - -- `vod_play_from` 中每条线路格式为 `<pan_type>` -- `vod_play_url` 中每条线路格式为 `<资源标题>$<分享链接>` -- 线路之间使用 `$$$` 分隔 -- 同一详情页内对重复分享链接去重 -- 同一详情页内按预设网盘优先级排序 - -资源标题使用人类可读名称,例如: - -- `百度资源` -- `夸克资源` -- `UC资源` -- `阿里资源` - -这里刻意不把站点名写入 `vod_play_from`,因为本次是独立单站,保留纯盘类型更贴近用户提供的 JS 行为。 - -## 失败与回退行为 - -### 分类与搜索 - -- HTML 为空或请求失败时,返回当前页和空列表 -- 空关键字搜索时,不发请求,直接返回空列表 - -### 详情 - -- 请求失败或 HTML 无法解析时,返回单条结果 -- 至少保留传入的 `vod_id` -- 其它字段使用空字符串 -- `vod_play_from` 与 `vod_play_url` 为空字符串 - -### 播放 - -- 识别到支持的网盘分享链接时,直接透传 -- 未识别链接时返回空 URL -- 不尝试解析站内播放页 - -## 测试设计 - -测试采用 `unittest` 和 `unittest.mock`,风格与当前仓库一致,通过 `SourceFileLoader` 加载 `欧歌.py`。 - -### 第一组:结构与纯函数 - -覆盖: - -- 固定 6 个分类输出正确 -- `homeVideoContent` 返回空列表 -- URL 组装符合站点规则 -- 网盘类型识别正确 - -### 第二组:列表与搜索 - -覆盖: - -- 分类页卡片解析出短路径 `vod_id` -- 分类接口正确拼 URL 并返回分页结构 -- 搜索接口正确拼 URL 并解析结果 -- 空关键字搜索直接返回空列表且不请求网络 - -### 第三组:详情与播放 - -覆盖: - -- 详情页标题、海报、导演、主演、剧情解析 -- 网盘分享链接识别与去重 -- `vod_play_from` / `vod_play_url` 顺序对齐 -- `playerContent` 对网盘分享链接透传 -- `playerContent` 对未知链接返回空 URL - -## 实现顺序 - -遵循 TDD: - -1. 先写结构与 helper 测试,验证失败 -2. 写最小实现让结构测试通过 -3. 增加分类与搜索测试,验证失败 -4. 写最小实现让列表测试通过 -5. 增加详情与播放测试,验证失败 -6. 写最小实现让详情和播放测试通过 -7. 运行目标模块测试,必要时再跑更大范围回归 - -## 风险与约束 - -- 该站页面结构如果对 `.page-title`、`.module-row-info` 等选择器有明显偏移,需要在实现时增加一层轻量兜底选择器 -- 参考 JS 中年份直接来自列表页卡片,而详情页未明确给出稳定选择器,因此 `vod_year` 允许为空,不强行猜测 -- 页面里的网盘文本可能不是纯 URL,实现时应允许从文本中提取 URL,而不是假设整个节点文本就是链接 -- 不做实时联网核验,本次以用户给出的参考 JS 结构和仓库测试风格为准 - -## 验收标准 - -满足以下条件即可视为完成: - -- 新增 `欧歌.py` 与 `tests/test_欧歌.py` -- `homeContent/homeVideoContent/categoryContent/detailContent/searchContent/playerContent` 均返回符合当前仓库习惯的数据结构 -- 列表和搜索结果使用站内短路径 `vod_id` -- 详情页输出符合预期的网盘线路 -- `playerContent` 只透传网盘分享链接,不做站内解析 -- 新增测试通过 diff --git a/py/docs/superpowers/specs/2026-04-20-rrdy-spider-design.md b/py/docs/superpowers/specs/2026-04-20-rrdy-spider-design.md deleted file mode 100644 index 81d9b23..0000000 --- a/py/docs/superpowers/specs/2026-04-20-rrdy-spider-design.md +++ /dev/null @@ -1,299 +0,0 @@ -# 人人电影 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的人人电影爬虫,覆盖以下能力: - -- 首页分类 -- 分类列表 -- 搜索 -- 详情解析 -- 网盘线路直传 - -实现以用户提供的 JS 规则为行为参考,但落地形式遵循当前仓库的单文件 Spider 约定。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `人人电影.py` -- 使用单一站点主域:`https://www.rrdynb.com` -- 支持 `home/category/detail/search/player` 主链路 -- 首页返回固定四分类 -- 详情页只保留网盘资源,不保留站内播放线路 -- 为新增行为补齐 `unittest` - -本次实现不包含: - -- 保留或兼容 JS 版规则文件 -- 修改 `base/` 公共层 -- 站内播放页解析 -- 迅雷与阿里网盘线路输出 -- 在线联调、动态反爬处理、验证码处理 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分为 URL 拼接、标题清洗、列表卡片解析、详情解析、网盘识别几个 helper -- 不抽象新的通用基类,不修改现有公共逻辑 - -不直接保留 JS 版的原因是: - -- 当前仓库消费的是 Python `Spider` 方法,不是 JS 插件接口 -- 单文件 Spider 与现有仓库结构一致,测试也更直接 -- 这次目标是把参考规则稳定映射到仓库约定,而不是双端维护 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、固定分类 -- `homeContent` - - 返回固定四分类 -- `homeVideoContent` - - 返回空列表 -- `categoryContent` - - 请求分类页并返回分页结果 -- `searchContent` - - 请求搜索页并清洗高亮标题 -- `detailContent` - - 请求详情页并整理元信息和网盘线路 -- `playerContent` - - 对网盘分享链接直接透传 -- 私有 helper - - URL 组装 - - 文本与标题清洗 - - 列表卡片解析 - - 详情字段提取 - - 网盘链接筛选 - -## Host 与请求策略 - -本次只实现单域: - -- `https://www.rrdynb.com` - -请求策略: - -- 所有 HTML 页面请求都走站点页面,不依赖隐藏接口 -- 默认附带固定 `User-Agent` -- `Referer` 默认指向首页 - -异常策略: - -- 单次请求非 200 时返回空结果,不向上抛出未处理异常 -- 列表、搜索、详情局部字段缺失时尽量保留其余字段 -- 不实现自动重试和本地缓存 - -## 分类设计 - -首页分类固定为: - -- `movie/list_2 -> 电影` -- `dianshiju/list_6 -> 电视剧` -- `dongman/list_13 -> 动漫` -- `zongyi/list_10 -> 老电影` - -`homeContent` 返回: - -- `class` - -不返回筛选配置,不扩展额外分类,保持与参考规则一致。 - -`homeVideoContent` 直接返回: - -- `{"list": []}` - -这样与仓库当前多数 Spider 的使用方式一致,避免把分类第一页混入首页推荐。 - -## ID 与 URL 设计 - -为了遵循仓库里的短 ID 约定,不对外暴露完整详情 URL。 - -详情页 ID 设计: - -- 列表与搜索结果中的 `vod_id` 保留站内相对路径 -- 示例:`/movie/12345.html` - -解码规则: - -- `detailContent` 先把相对路径拼回站点完整 URL -- 若传入值已经是完整 URL,则兼容直接使用 - -分类页 URL: - -- `/{classPath}_{page}.html` -- 示例:`movie/list_2` 第 2 页请求 `https://www.rrdynb.com/movie/list_2_2.html` - -搜索页 URL: - -- `/plus/search.php?q=<keyword>&pagesize=10&submit=` -- 当页码大于 1 时追加 `&PageNo=<page>` - -## 列表与搜索设计 - -分类页、搜索页都复用统一卡片解析 helper。 - -优先解析区域: - -- `#movielist li` - -卡片字段提取: - -- 链接:优先取标题链接 `href` -- 标题:优先取 `title`,否则取链接文本或 HTML -- 海报:优先取懒加载 `data-original`,其次取 `src` -- 备注:取评分或备注文本 - -标题清洗规则: - -- 去掉搜索高亮标签 `<font color='red'>` 和 `</font>` -- 若标题中存在 `《...》` 或 `「...」`,优先提取括号内文本 -- 否则返回普通清洗文本 - -分页返回字段: - -- `page` -- `limit` -- `total` -- `list` - -为了符合仓库约定,分类和搜索结果不返回 `pagecount`。由于参考规则没有稳定总页数来源,本次采用保守返回: - -- `limit = len(list)` -- `total = 当前页基础估算值或当前结果数` - -## 详情页设计 - -详情解析基于详情页 HTML,输出单个视频对象,字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -字段提取策略: - -- 标题:`.movie-des h1` -- 海报:`.movie-img img` -- 简介:`.movie-txt` 区域纯文本 - -网盘资源提取策略: - -- 在 `.movie-txt` 内查找所有链接 -- 只保留明确识别为网盘分享链接的 URL -- 显式过滤包含 `xunlei`、`aliyun`、`alipan` 的链接 -- 其余非网盘普通链接一律忽略 - -详情页不输出站内播放线路。 - -若存在网盘链接,则统一组装为单条线路: - -- `vod_play_from = 网盘` -- `vod_play_url = 名称$链接#名称$链接...` - -名称优先使用链接文本;若为空,则回退为对应网盘名或通用标题。 - -若不存在任何可识别网盘链接: - -- 保留基础元信息 -- `vod_play_from` 和 `vod_play_url` 置空 - -## 网盘识别与播放设计 - -支持直接透传的网盘范围: - -- 百度 -- 夸克 -- UC -- 115 -- 123 盘 -- 天翼 -- 139 - -可通过域名关键字识别,例如: - -- `pan.baidu.com` -- `pan.quark.cn` -- `drive.uc.cn` -- `115.com` -- `123pan.com` -- `cloud.189.cn` -- `yun.139.com` - -排除范围: - -- `pan.xunlei.com` -- `aliyundrive.com` -- `alipan.com` - -`playerContent(flag, id, vipFlags)` 行为: - -- 当 `id` 本身是受支持的网盘分享链接时,直接返回透传结果 -- `parse = 0` -- `playUrl = ""` -- `url = 原始分享链接` -- 非网盘链接返回空 URL,明确表示本 Spider 不处理站内播放 - -## 容错策略 - -- 页面请求失败时,列表和搜索返回空列表,详情返回空字段对象 -- 卡片缺少标题或链接时直接跳过,不产生脏数据 -- 标题清洗失败时退回普通文本 -- 单个网盘链接解析失败时跳过该链接,不影响其他链接输出 -- 详情页没有网盘时不构造伪线路 - -## 测试设计 - -测试文件: - -- `py/tests/test_人人电影.py` - -测试采用内嵌 HTML fixture 与 `unittest.mock`,不访问真实网络。 - -覆盖点: - -1. 首页分类 - - 固定四分类输出正确 - - `homeVideoContent` 返回空列表 - -2. URL 与文本 helper - - URL 组装能兼容相对路径和完整 URL - - 标题清洗能处理高亮标签、`《》`、`「」` 和普通文本 - -3. 分类列表 - - 分类页 URL 按 `/{classPath}_{page}.html` 拼接 - - `#movielist li` 卡片能提取 `vod_id`、`vod_name`、`vod_pic`、`vod_remarks` - - 返回结果不包含 `pagecount` - -4. 搜索 - - 搜索 URL 正确 - - 第 2 页及以上会追加 `PageNo` - - 搜索结果标题中的高亮标签会被清理 - -5. 详情 - - 能提取标题、海报、简介 - - 只保留允许的网盘链接 - - 过滤迅雷和阿里链接 - - 正确组装单条“网盘”线路 - -6. 播放 - - 网盘分享链接直接透传 - - 非网盘链接返回空 URL - -## 成功标准 - -满足以下条件时,认为本次设计对应的实现完成: - -- 新增 `人人电影.py` 并符合仓库 `Spider` 接口 -- 固定四分类、分类列表、搜索、详情和网盘透传链路可用 -- 详情页只输出网盘线路,不输出站内播放线路 -- 分类和搜索结果不返回 `pagecount` -- 单测覆盖上述主链路,且全部通过 diff --git a/py/docs/superpowers/specs/2026-04-20-shandian-spider-design.md b/py/docs/superpowers/specs/2026-04-20-shandian-spider-design.md deleted file mode 100644 index 316e56e..0000000 --- a/py/docs/superpowers/specs/2026-04-20-shandian-spider-design.md +++ /dev/null @@ -1,382 +0,0 @@ -# 闪电 Python 爬虫与聚合接入设计 - -**日期:** 2026-04-20 - -## 目标 - -在当前 Python Spider 仓库中完成两项相关工作: - -- 新增独立单站蜘蛛 `闪电.py` -- 将 `闪电` 站点接入 `玩偶聚合.py` - -`闪电` 站点主域名固定为: - -- `https://sd.sduc.site` - -行为边界与用户提供的 JS 参考实现保持一致: - -- 支持分类列表 -- 支持搜索 -- 支持详情页元数据解析 -- 支持网盘分享链接整理 -- `playerContent` 只透传网盘分享链接 -- 不做站内直链播放解析 - -## 范围 - -本次实现包含: - -- 新增独立蜘蛛文件 `py/闪电.py` -- 新增测试文件 `py/tests/test_闪电.py` -- 在 `py/玩偶聚合.py` 中新增 `shandian` 站点配置 -- 在 `py/tests/test_玩偶聚合.py` 中新增 `闪电` 聚合测试 -- 补充对应 spec 和 plan 文档 - -本次实现不包含: - -- 抽取新的公共盘站基类 -- 修改 `base/` 公共层 -- 增加站内播放器解析 -- 实现多备用域名 failover -- 修改现有 `至臻` 行为 - -## 现状 - -仓库中已经存在一批结构相近的盘站蜘蛛,尤其是: - -- `py/至臻.py` -- `py/玩偶哥哥.py` - -这些实现已经验证了同类 DOM 结构的解析方式: - -- 列表容器:`#main .module-item` -- 搜索容器:`.module-search-item` -- 详情标题:`.page-title` -- 详情海报:`.mobile-play .lazyload` -- 详情字段标签:`.video-info-itemtitle` -- 网盘链接容器:`.module-row-info p` - -当前 `py/玩偶聚合.py` 中已有 `site_priority` 对 `shandian` 的优先级预留,但 `self.sites` 还未真正接入 `闪电` 站点。 - -因此,本次工作重点是: - -- 复用现有盘站解析模式落单站实现 -- 以最小配置改动把 `闪电` 接入聚合层 - -## 方案选择 - -采用“单站独立文件 + 聚合配置接入”的方案。 - -### 方案 A:推荐 - -- 新增 `py/闪电.py` -- 结构对齐 `py/至臻.py` -- 在 `py/玩偶聚合.py` 中新增 `shandian` 站点配置 -- 用 `unittest` 覆盖单站与聚合层行为 - -优点: - -- 与当前仓库模式一致 -- 改动边界清晰 -- 便于后续单站修复与聚合调试 - -### 方案 B:不采用 - -- 抽取一个通用盘站基类,让 `至臻/闪电` 共享实现 - -不采用原因: - -- 扩大改动范围 -- 增加重构风险 -- 当前任务目标明确,不需要提前抽象 - -## 独立单站蜘蛛设计 - -### 文件 - -- `py/闪电.py` -- `py/tests/test_闪电.py` - -### Spider 对外行为 - -#### `homeContent` - -返回固定 5 个分类: - -- `1 -> 闪电电影` -- `2 -> 闪电剧集` -- `3 -> 闪电综艺` -- `4 -> 闪电动漫` -- `30 -> 闪电短剧` - -不返回筛选项。 - -#### `homeVideoContent` - -返回: - -```python -{"list": []} -``` - -#### `categoryContent` - -分类 URL: - -- `/index.php/vod/show/id/{tid}/page/{page}.html` - -解析字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_year` - -结果结构包含: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -#### `searchContent` - -搜索 URL: - -- `/index.php/vod/search/page/{page}/wd/{keyword}.html` - -空关键词直接返回空列表。 - -结果字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -#### `detailContent` - -详情页提取: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_year` -- `vod_director` -- `vod_actor` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -只整理网盘线路,不解析站内直链播放页。 - -#### `playerContent` - -已识别网盘分享链接时返回: - -```python -{"parse": 0, "playUrl": "", "url": id} -``` - -非网盘链接返回: - -```python -{"parse": 0, "playUrl": "", "url": ""} -``` - -### URL 与 ID 设计 - -独立单站的 `vod_id` 保持站内短路径,例如: - -- `/index.php/vod/detail/id/123.html` - -详情请求时再与主域名拼接。 - -### 解析策略 - -#### 列表页 - -容器: - -- `#main .module-item` - -提取: - -- 链接:`.module-item-pic a[href]` -- 标题:`.module-item-pic img[alt]` -- 海报:`.module-item-pic img[data-src|src]` -- 备注:`.module-item-text` -- 年份:`.module-item-caption span:first-child` - -#### 搜索页 - -容器: - -- `.module-search-item` - -提取: - -- 链接:`.video-serial[href]` -- 标题:`.video-serial[title]` -- 海报:`.module-item-pic img[data-src|src]` -- 备注:`.video-serial` 文本,缺失时回退 `.module-item-text` - -#### 详情页 - -字段来源: - -- 标题:`.page-title` -- 海报:`.mobile-play .lazyload[data-src|src]` -- 标签区:`.video-info-itemtitle` 与相邻节点 -- 网盘链接:`.module-row-info p` - -字段映射: - -- `年代` -> `vod_year` -- `导演` -> `vod_director` -- `主演` -> `vod_actor` -- `剧情` -> `vod_content` - -### 网盘线路规则 - -支持识别: - -- 百度 -- 139 -- 天翼 -- 123 -- 115 -- 夸克 -- 迅雷 -- 阿里 -- UC - -优先级沿用当前仓库同类盘站: - -1. 百度 -2. 139 -3. 天翼 -4. 123 -5. 115 -6. 夸克 -7. 迅雷 -8. 阿里 -9. UC - -输出规则: - -- `vod_play_from` 使用 `{pan_type}#闪电` -- `vod_play_url` 使用 `{标题}${分享链接}` -- 重复链接去重 -- 非支持网盘链接忽略 - -## 聚合接入设计 - -### 修改文件 - -- `py/玩偶聚合.py` -- `py/tests/test_玩偶聚合.py` - -### 新增站点配置 - -在 `self.sites` 中新增: - -- `id`: `shandian` -- `name`: `闪电` -- `domains`: `["https://sd.sduc.site"]` -- `filter_files`: `[]` -- `list_xpath`: `//*[contains(@class,'module-item')]` -- `search_xpath`: `//*[contains(@class,'module-search-item')]` -- `detail_pan_xpath`: `//*[contains(@class,'module-row-info')]//p` -- `category_url`: `/index.php/vod/show/id/{categoryId}/page/{page}.html` -- `search_url`: `/index.php/vod/search/page/{page}/wd/{keyword}.html` -- `default_categories`: `[("1","电影"),("2","剧集"),("3","综艺"),("4","动漫"),("30","短剧")]` - -这里不配置 `category_url_with_filters`,因为用户给出的参考实现只确认了基础分类分页 URL。 - -### 首页行为 - -`homeContent(False)` 中应新增: - -- `type_id = site_shandian` -- `type_name = 闪电` - -`filters["site_shandian"]` 的首个筛选组应为 `categoryId`,分类值映射到 5 个默认分类。 - -### 分类行为 - -`categoryContent("site_shandian", pg, ..., extend)` 应使用: - -- `extend["categoryId"]` 指定分类 -- 默认值为 `1` -- URL:`https://sd.sduc.site/index.php/vod/show/id/<categoryId>/page/<pg>.html` - -返回结果中的 `vod_id` 应编码为: - -- `site:shandian:<detail_path>` - -### 搜索行为 - -`_fetch_site_search` 应使用: - -- `/index.php/vod/search/page/{page}/wd/{keyword}.html` - -结果进入现有 `_aggregate_search_results`。 - -站点优先级不调整,沿用当前 `site_priority` 中 `shandian` 的位置。 - -### 详情行为 - -聚合详情不改结构,只保证: - -- `_get_site("shandian")` 能找到配置 -- `_fetch_site_detail` 能正确请求并解析 `闪电` 详情页 -- `detailContent` 合并 `闪电` 网盘线路时遵守现有去重与排序规则 - -## 测试策略 - -### 单站测试 - -新增 `py/tests/test_闪电.py`,至少覆盖: - -- 固定分类 -- 空首页 -- URL 构建 -- 网盘识别 -- 分类列表解析 -- 分类 URL 构造 -- 搜索 URL 构造与解析 -- 空关键词回退 -- 详情元数据提取 -- 网盘线路去重和排序 -- `detailContent` -- `playerContent` - -### 聚合测试 - -在 `py/tests/test_玩偶聚合.py` 中新增至少以下场景: - -- 首页暴露 `site_shandian` -- `site_shandian` 分类组正确 -- `闪电` 搜索 URL 构造与编码 -- `闪电` 分类 URL 构造 -- 聚合详情能合并 `闪电` 的网盘线路 - -## 风险 - -- `闪电` 的 URL 规则与旧 `/vodshow/...` 模板不同,若误复用旧模板会导致聚合接入无效 -- `default_categories` 顺序和 ID 若写错,会让 UI 展示与真实站点不一致 -- 若只补聚合配置、不补单站测试,后续修站时很难快速定位问题 - -## 验收标准 - -满足以下条件即可认为完成: - -- `py/闪电.py` 实现独立 Spider -- `py/tests/test_闪电.py` 覆盖核心行为 -- `py/玩偶聚合.py` 接入 `shandian` -- `py/tests/test_玩偶聚合.py` 覆盖 `闪电` 聚合行为 -- `python -m unittest tests.test_闪电 tests.test_玩偶聚合 -v` 通过 diff --git a/py/docs/superpowers/specs/2026-04-20-wanou-aggregate-spider-design.md b/py/docs/superpowers/specs/2026-04-20-wanou-aggregate-spider-design.md deleted file mode 100644 index da92606..0000000 --- a/py/docs/superpowers/specs/2026-04-20-wanou-aggregate-spider-design.md +++ /dev/null @@ -1,429 +0,0 @@ -# 玩偶聚合 Spider 设计 - -**日期:** 2026-04-20 - -## 目标 - -在当前 `py/` Spider 仓库中新增一个“玩偶聚合”源,提供以下能力: - -- 以站点为一级分类展示聚合站。 -- 每个站点在分类页内继续使用原站分类和筛选项。 -- 搜索时并发请求多个站点,并将同片结果聚合为单条记录。 -- 聚合详情页合并多个站点的网盘分享线路。 -- `playerContent` 不展开网盘剧集,只透传分享链接。 - -明确不做: - -- 独立管理后台。 -- 监控页面。 -- 常驻定时域名巡检任务。 -- 在 Spider 内调用网盘驱动展开剧集列表。 - -## 现状与约束 - -当前仓库是单文件 Python Spider 集合,统一继承 `base/spider.py` 中的 `Spider` 基类,测试使用 `unittest` 和 `unittest.mock`。现有仓库已接受以下模式: - -- `homeContent` 返回 `class` 和可选 `filters`。 -- `categoryContent` 使用 `extend` 解析筛选参数。 -- `detailContent` 可以返回网盘分享链接组成的 `vod_play_from` / `vod_play_url`。 -- `playerContent` 可以对网盘链接做原样透传。 -- 返回列表结果时不包含 `pagecount`。 - -因此,新 Spider 必须遵守当前仓库接口和测试风格,而不是照搬原始 Node/Fastify 插件结构。 - -## 方案选择 - -采用“配置驱动的单 Spider 聚合层”方案: - -- 新增单文件 `玩偶聚合.py`。 -- 文件内部包含站点配置、通用抓取逻辑、聚合编排逻辑。 -- 对差异较大的站点保留少量站点钩子,避免把所有逻辑写成纯硬编码分支。 - -不采用“每站点一个适配器文件”的方案,原因是当前仓库以单文件 Spider 为主,这种拆分会引入额外组织成本;也不采用“全部 if/else 硬编码”的方案,原因是后续修站难度会快速上升。 - -## 模块职责 - -`玩偶聚合.py` 内部拆成三层职责: - -### 1. 站点配置层 - -维护聚合站点定义,包括: - -- 站点 ID 和中文名。 -- 域名列表,按优先顺序排列。 -- 分类 URL 模板。 -- 搜索 URL 模板。 -- 列表、搜索、详情解析用选择器或 XPath 规则。 -- 原站默认分类。 -- 可选的本地筛选配置文件映射。 -- 站点优先级。 - -### 2. 通用采集层 - -负责: - -- 请求 HTML。 -- 域名失败切换。 -- 列表卡片解析。 -- 搜索卡片解析。 -- 详情页元数据提取。 -- 网盘分享链接识别与分组。 -- 聚合 ID 编解码辅助。 - -### 3. 聚合编排层 - -负责: - -- 首页按站点输出分类和筛选结构。 -- 分类请求路由到指定站点。 -- 搜索并发拉取多站结果。 -- 基于名称和年份做结果聚合。 -- 聚合详情拉取多站详情并合并线路。 -- `playerContent` 透传网盘链接。 - -## Spider 对外行为 - -### homeContent - -首页使用“站点优先”模式: - -- `class` 中每个条目代表一个聚合站点。 -- `type_id` 使用 `site_<site_id>` 格式。 -- `type_name` 使用站点中文名。 - -每个站点的 `filters` 至少包含: - -- `categoryId` 分组,对应原站分类。 - -若该站有可落地的本地筛选配置,则继续追加该站支持的: - -- `area` -- `year` -- `class` -- `by` 或 `sort` -- 其他与模板兼容的筛选字段 - -这样,使用方在 UI 上会先选站点,再选该站原始分类和筛选项。 - -### categoryContent - -`tid` 必须是 `site_<site_id>`。 - -处理逻辑: - -1. 从 `tid` 提取目标站点。 -2. 从 `extend` 中读取 `categoryId`。 -3. 读取该站支持的附加筛选项。 -4. 按该站模板拼分类 URL。 -5. 请求 HTML,解析列表卡片。 -6. 返回当前页结果。 - -当 `extend` 未提供 `categoryId` 时,默认使用该站首个分类。 - -### searchContent - -搜索默认启用聚合模式: - -1. 对所有站点并发发起搜索请求。 -2. 每个站点单独设置超时,超时只影响该站。 -3. 解析得到站内搜索卡片列表。 -4. 基于名称归一化和年份辅助规则进行跨站聚合。 -5. 选择优先级最高的站点结果作为聚合主信息。 -6. 生成聚合 `vod_id` 并返回聚合后的列表。 - -聚合结果默认不再把同片不同站拆成多条返回。 - -### detailContent - -支持两类 ID: - -- 单站详情 ID。 -- 聚合详情 ID。 - -单站详情: - -- 请求对应站点详情页。 -- 提取片名、海报、年份、导演、演员、简介。 -- 提取并识别所有网盘分享链接。 -- 生成 `vod_play_from` 和 `vod_play_url`。 - -聚合详情: - -- 解码聚合 ID 中包含的站点条目。 -- 按站点优先级依次请求多个站点详情。 -- 跳过失败站点和无网盘站点。 -- 合并多个站点的网盘线路并输出。 - -详情页不展开网盘剧集,不调用外部盘驱动。 - -### playerContent - -仅负责透传常见网盘分享链接。 - -支持: - -- `pan.baidu.com` -- `pan.quark.cn` -- `drive.uc.cn` -- `alipan.com` -- `aliyundrive.com` -- `pan.xunlei.com` -- `115.com` -- `123pan.com` -- `cloud.189.cn` 或等价天翼域名 -- `yun.139.com` 或等价移动云盘域名 - -当 `flag` 或 `id` 能识别为上述网盘分享链接时,返回: - -```python -{"parse": 0, "playUrl": "", "url": "<share-url>"} -``` - -无法识别时返回空 URL,不尝试站内播放页解析。 - -## 数据模型设计 - -### 单站结果 ID - -分类页中的普通条目使用: - -`site:<site_id>:<detail_path>` - -示例: - -`site:wanou:/voddetail/12345.html` - -要求: - -- 保留站点信息。 -- 保留足够重建详情 URL 的短路径。 -- 不直接存储整页 HTML 或冗长 JSON。 - -### 聚合结果 ID - -搜索聚合结果使用: - -`agg:<base64_json>` - -JSON 负载为条目数组,每个条目至少包含: - -- `site` -- `path` -- `name` -- `year` - -这样可以: - -- 避免把多个站点路径用裸字符串硬拼到 `vod_id` 里。 -- 在详情阶段稳定恢复参与聚合的站点列表。 -- 控制 `vod_id` 长度和结构清晰度。 - -### 播放线路 - -`vod_play_from` 使用盘类型加来源站点命名,例如: - -- `baidu#玩偶` -- `quark#木偶` -- `a123#蜡笔` - -`vod_play_url` 直接存分享链接: - -- `百度合集$https://pan.baidu.com/s/...` -- `夸克资源$https://pan.quark.cn/s/...` - -同类型多站资源允许并存,以来源站区分。 - -## 搜索聚合规则 - -### 名称归一化 - -对片名做规范化,用于跨站聚合判断: - -- 转小写。 -- 去除空白和常见标点。 -- 去除常见分辨率或来源尾巴,如 `4K`、`HDR`、`2160P`、`1080P`。 -- 去除常见站点附加标签。 - -### 聚合判定 - -优先使用以下规则: - -1. 归一化片名完全相同,可聚合。 -2. 若双方年份都存在且不相同,则不能聚合。 -3. 若年份一致或其中一方缺失年份,允许聚合。 -4. 对明显不同的片名,即使部分包含,也不强行聚合。 - -本次实现不引入复杂模糊匹配算法,避免误合并;先用保守规则建立稳定版本。 - -### 主信息来源 - -聚合组内按站点优先级排序,优先级最高的记录决定: - -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_year` - -同时保留来源标签供详情阶段使用。 - -## 详情聚合规则 - -### 单站详情解析 - -每个站点详情页尽量提取: - -- 标题 -- 海报 -- 年份 -- 地区 -- 类型 -- 导演 -- 演员 -- 简介 -- 网盘分享链接 - -如果该站只有网盘资源,没有站内剧集线路,也视为有效站点。 - -### 网盘识别 - -根据链接域名识别盘类型,优先以分享域名为准,而不是文案标签: - -- 百度 -- 夸克 -- UC -- 阿里 -- 迅雷 -- 115 -- 123 -- 天翼 -- 移动云盘 - -### 合并策略 - -聚合详情按站点优先级遍历多站结果: - -- 失败站点直接跳过。 -- 无网盘链接的站点直接跳过。 -- 同一站内重复链接去重。 -- 同一分享链接跨站重复时只保留一份。 -- `vod_play_from` 顺序先按盘优先级,再按站点优先级。 - -主详情信息取第一个成功站点。 - -如果所有站点都没有可用网盘链接,则返回空播放线路。 - -## 域名容错 - -不做后台监控,只做请求时故障切换。 - -每个站点维护多个候选域名: - -1. 请求时按当前顺序逐个尝试。 -2. 某个域名成功后,将其提升到列表前部。 -3. 失败时继续尝试下一个域名。 -4. 全部失败则该站本次请求记为空结果或详情失败。 - -该策略适用于: - -- 分类页。 -- 搜索页。 -- 详情页。 - -不会引入线程、定时器或额外 HTTP 管理接口。 - -## 站点筛选策略 - -优先从仓库本地配置文件加载可用筛选项;若某站缺少本地筛选配置,则仅提供: - -- 分类 `categoryId` - -分类分组必须按用户配置顺序输出,避免被 Python 字典顺序或后处理逻辑打乱。 - -对于筛选 URL,支持两种模式: - -- 站点自定义模板。 -- 通用 CMS 模板回退。 - -## 测试设计 - -为新 Spider 新增 `tests/test_玩偶聚合.py`,覆盖三层行为。 - -### 1. 纯函数与辅助方法 - -- 站点 ID 解析。 -- 单站 `vod_id` 编解码。 -- 聚合 `vod_id` 编解码。 -- 片名归一化。 -- 聚合判定规则。 -- 网盘类型识别。 - -### 2. 单站解析与 URL 构建 - -- `homeContent` 输出站点分类和筛选结构。 -- 分类 URL 根据 `extend` 正确拼接。 -- 搜索 URL 根据关键字正确构建。 -- 单站列表卡片正确解析。 -- 单站详情正确抽取网盘链接。 -- 域名切换在首域失败后能使用备用域。 - -### 3. 聚合行为 - -- 多站搜索结果合并为单条。 -- 年份冲突结果不合并。 -- 聚合详情合并多个站点线路。 -- 聚合详情对重复分享链接去重。 -- `playerContent` 透传 quark / baidu / uc / aliyun / xunlei 等常见盘链接。 - -所有测试使用内嵌 HTML 或 mock response,不访问真实网络。 - -## 文件变更计划 - -预计后续实现阶段将修改或新增: - -- 新增 `py/玩偶聚合.py` -- 新增 `py/tests/test_玩偶聚合.py` -- 如有必要,新增本地筛选配置文件,但优先复用已有 `筛选` 目录资源 - -本设计阶段只新增本 spec 文件。 - -## 风险与处理 - -### 1. 站点结构不完全一致 - -处理方式: - -- 使用站点配置驱动。 -- 仅为少量差异保留钩子函数。 - -### 2. 搜索误聚合 - -处理方式: - -- 初版采用保守聚合规则。 -- 以完全归一化片名匹配为主,年份冲突即拆开。 - -### 3. 聚合 ID 过长 - -处理方式: - -- 使用精简 JSON 负载。 -- 仅保存详情恢复必需字段。 - -### 4. 备用域名经常失效 - -处理方式: - -- 保持请求时故障切换。 -- 不引入复杂监控模块。 - -## 实施结论 - -后续实现应严格围绕以下范围展开: - -- Python 单文件聚合 Spider。 -- 站点优先首页结构。 -- 搜索默认聚合。 -- 详情合并网盘分享线路。 -- `playerContent` 只透传分享链接。 - -超出上述范围的监控后台、定时巡检、管理 API 和网盘剧集展开,均不在本次任务中。 diff --git a/py/docs/superpowers/specs/2026-04-20-wanou-aggregate-zhizhen-design.md b/py/docs/superpowers/specs/2026-04-20-wanou-aggregate-zhizhen-design.md deleted file mode 100644 index e4c324b..0000000 --- a/py/docs/superpowers/specs/2026-04-20-wanou-aggregate-zhizhen-design.md +++ /dev/null @@ -1,170 +0,0 @@ -# 玩偶聚合接入至臻设计 - -**日期:** 2026-04-20 - -## 目标 - -在现有聚合蜘蛛 [玩偶聚合.py](/home/harold/workspace/tvbox-resources/py/玩偶聚合.py) 中正式接入 `至臻` 站点,使其参与: - -- 首页站点列表展示 -- 站点分类页抓取 -- 聚合搜索 -- 聚合详情页网盘线路合并 - -主域名固定为: - -- `http://www.miqk.cc` - -本次接入沿用已经确认的单站 `至臻` 解析边界: - -- 只抓列表、搜索、详情元数据和网盘链接 -- `playerContent` 只透传网盘分享链接 -- 不做站内直链播放解析 - -## 范围 - -本次改动包含: - -- 在 [玩偶聚合.py](/home/harold/workspace/tvbox-resources/py/玩偶聚合.py) 的 `self.sites` 中增加 `zhizhen` 配置 -- 让 `homeContent` 暴露 `site_zhizhen` -- 让 `categoryContent` 使用 `至臻` 的分类和 URL 模板 -- 让 `searchContent` 将 `至臻` 结果纳入聚合 -- 让 `detailContent` 能合并 `至臻` 的网盘链接 -- 在 [test_玩偶聚合.py](/home/harold/workspace/tvbox-resources/py/tests/test_玩偶聚合.py) 中补充对应测试 - -本次不包含: - -- 修改聚合 ID 编码格式 -- 抽取新的共享基类 -- 为 `至臻` 增加备用域名 -- 修改单站 [至臻.py](/home/harold/workspace/tvbox-resources/py/至臻.py) 的行为 - -## 现状 - -当前聚合蜘蛛已有: - -- `site_priority` 中的 `zhizhen` 优先级占位 -- 通用的列表、搜索、详情和网盘线路组装逻辑 -- 站点配置驱动的 URL 构建和 XPath 解析 - -当前缺口是: - -- `self.sites` 里还没有真正的 `zhizhen` 配置 -- 测试中也没有覆盖 `至臻` 在聚合层的展示、搜索和详情合并 - -这意味着聚合层逻辑本身基本够用,本次重点是补站点定义并用测试锁定行为。 - -## 方案选择 - -采用“最小配置接入 + 现有聚合逻辑复用”的方案。 - -具体做法: - -- 新增一条 `zhizhen` 站点配置 -- 复用现有 `module-item`、`module-search-item`、`module-row-info` 解析流程 -- 复用现有 `_fetch_site_search`、`_parse_detail_page`、`_build_pan_lines`、`detailContent` 合并逻辑 - -不采用“让聚合层调用单站 `至臻.py`”的方案,原因是: - -- 当前聚合蜘蛛的结构是配置驱动,不是子 Spider 组合 -- 强行调用单站 Spider 会把接口耦合变复杂 -- 本次站点结构与现有聚合模板兼容,没有必要新增调度层 - -## 站点配置设计 - -新增站点项字段: - -- `id`: `zhizhen` -- `name`: `至臻` -- `domains`: `["http://www.miqk.cc"]` -- `filter_files`: `[]` -- `list_xpath`: `//*[contains(@class,'module-item')]` -- `search_xpath`: `//*[contains(@class,'module-search-item')]` -- `detail_pan_xpath`: `//*[contains(@class,'module-row-info')]//p` -- `category_url`: `/index.php/vod/show/id/{categoryId}/page/{page}.html` -- `search_url`: `/index.php/vod/search/page/{page}/wd/{keyword}.html` -- `default_categories`: `[("1","电影"),("2","剧集"),("3","动漫"),("4","综艺"),("5","短剧"),("24","老剧"),("26","严选")]` - -这里不配置 `category_url_with_filters`,因为用户给出的 `至臻` 参考实现只确认了基础分类翻页 URL,没有额外筛选规则。 - -## 行为设计 - -### 首页 - -`homeContent` 应新增一项: - -- `type_id = site_zhizhen` -- `type_name = 至臻` - -`filters["site_zhizhen"]` 中的第一个筛选组应为 `categoryId`,值列表映射到 `至臻` 的 7 个默认分类。 - -### 分类页 - -`categoryContent("site_zhizhen", pg, ..., extend)` 应: - -- 从 `extend["categoryId"]` 读取分类 -- 若未提供,则默认使用 `1` -- 构建 `http://www.miqk.cc/index.php/vod/show/id/<categoryId>/page/<pg>.html` -- 按现有通用列表解析逻辑输出站内条目 - -返回结构保持与现有聚合站一致: - -- `vod_id` 使用 `site:zhizhen:<detail_path>` -- 保留 `_site` 和 `_detail_path` -- 不返回 `pagecount` - -### 搜索 - -`searchContent` 不改接口,只需要确保: - -- `_fetch_site_search` 能对 `zhizhen` 使用其 `search_url` -- 结果进入 `_aggregate_search_results` -- 若同名同年命中多个站点,仍按 `site_priority` 决定主信息来源 - -因为 `site_priority` 中 `zhizhen` 已排在 `labi` 后、`erxiao` 前,本次不调整站点优先级。 - -### 详情 - -聚合详情和单站详情都不改数据模型,只要 `zhizhen` 配置加入后能被现有流程消费。 - -重点保证: - -- `_fetch_site_detail` 能按 `site["domains"][0] + path` 请求 `至臻` 详情页 -- `_parse_detail_page` 可用通用 `.page-title` / `.mobile-play` / `.video-info-itemtitle` / `detail_pan_xpath` 提取字段 -- `detailContent` 合并 `至臻` 的网盘线路时遵守现有去重和排序规则 - -## 测试设计 - -本次至少新增以下测试: - -1. 首页暴露 `site_zhizhen` - - 校验 `homeContent(False)["class"]` 中包含 `site_zhizhen` - - 校验 `filters["site_zhizhen"]` 的分类项包含 `1/2/3/4/5/24/26` - -2. `至臻` 搜索 URL 构造与解析 - - 构造一个 `zhizhen` 站点配置 - - mock `_request_with_failover` - - 断言 `_fetch_site_search` 请求 `http://www.miqk.cc/index.php/vod/search/page/1/wd/<keyword>.html` - - 断言结果被编码为 `site:zhizhen:<path>` - -3. 聚合详情合并 `至臻` 网盘线路 - - mock `_fetch_site_detail`,让一个聚合 payload 同时包含例如 `wanou` 和 `zhizhen` - - 断言 `vod_play_from` / `vod_play_url` 中含 `#至臻` 的线路 - - 断言重复链接仍会按现有规则去重 - -必要时再补一个分类页测试,锁定 `site_zhizhen` 的分类 URL 模板。 - -## 风险 - -- `至臻` 的分类 URL 与现有 `/vodshow/...` 模板不同,若直接沿用旧模板会导致站点在聚合层无法访问分类页 -- `default_categories` 若错误复用其他站的 `29` 或 `21` 等 ID,会让筛选项和真实站点不一致 -- 若测试只验证首页展示、不验证搜索和详情,则很容易出现“站点名显示了,但实际不可用”的假集成 - -## 验收标准 - -满足以下条件即可认为完成: - -- [玩偶聚合.py](/home/harold/workspace/tvbox-resources/py/玩偶聚合.py) 的 `self.sites` 中新增 `zhizhen` 配置 -- [test_玩偶聚合.py](/home/harold/workspace/tvbox-resources/py/tests/test_玩偶聚合.py) 覆盖 `至臻` 的首页、搜索或详情至少三类行为 -- `python -m unittest tests.test_玩偶聚合 -v` 通过 -- 聚合层返回结构不引入新的字段格式变化 diff --git a/py/docs/superpowers/specs/2026-04-20-wanougege-spider-design.md b/py/docs/superpowers/specs/2026-04-20-wanougege-spider-design.md deleted file mode 100644 index b016e62..0000000 --- a/py/docs/superpowers/specs/2026-04-20-wanougege-spider-design.md +++ /dev/null @@ -1,293 +0,0 @@ -# 玩偶哥哥 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个独立单站蜘蛛 `py/玩偶哥哥.py`,参考用户提供的 JS 版本行为,实现符合 `base.spider.Spider` 接口的网盘资源站适配。 - -本次实现需要覆盖: - -- 首页 8 个固定分类 -- 分类列表 -- 搜索 -- 详情解析 -- 网盘线路组织 -- 播放透传 -- 对应 `unittest` - -## 范围 - -本次实现包含: - -- 新增独立蜘蛛文件 `py/玩偶哥哥.py` -- 新增测试文件 `py/tests/test_玩偶哥哥.py` -- 单域名站点适配:`http://wogg.xxooo.cf` -- 固定 8 个分类,分类 ID 与参考 JS 保持一致 -- 分类页和搜索页卡片解析 -- 详情页元数据与网盘链接提取 -- 按网盘类型输出 `vod_play_from` 和 `vod_play_url` -- `playerContent` 对已识别网盘分享链接直接透传 - -本次实现不包含: - -- 聚合多站 -- 本地筛选配置文件 -- 站内视频播放解析 -- 验证码、浏览器执行或复杂反爬绕过 -- 修改 `base/` 公共层 - -## 方案选择 - -采用“单站单文件 + 单测”的现有仓库模式,而不是复用 `玩偶聚合.py` 的聚合结构。 - -原因: - -- 用户明确要求独立单站 -- 参考 JS 本身就是单站逻辑 -- 独立站不需要聚合 ID、跨站去重和主次站排序 -- 单站实现更容易保持接口简单,测试也更聚焦 - -## 接口设计 - -### `homeContent` - -返回 8 个固定分类: - -- `1 -> 玩偶电影` -- `2 -> 玩偶剧集` -- `3 -> 玩偶动漫` -- `4 -> 玩偶综艺` -- `44 -> 臻彩视界` -- `6 -> 玩偶短剧` -- `5 -> 玩偶音乐` -- `46 -> 玩偶纪录` - -不返回筛选项。 - -### `homeVideoContent` - -返回空列表: - -- `{"list": []}` - -### `categoryContent` - -分类页 URL 规则: - -- `/vodshow/{tid}--------{page}---.html` - -解析页面中的卡片,输出: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -分页返回字段: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`,以保持当前仓库对新蜘蛛的约定。 - -### `searchContent` - -搜索 URL 规则: - -- `/vodsearch/-------------.html?wd={keyword}&page={page}` - -空关键词直接返回空列表。 - -搜索结果结构与分类列表保持一致。 - -### `detailContent` - -通过详情页提取: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_year` -- `vod_director` -- `vod_actor` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -不解析站内播放页,只整理网盘分享链接。 - -### `playerContent` - -若 `id` 是支持的网盘分享链接,则返回透传结果: - -```python -{"parse": 0, "playUrl": "", "url": id} -``` - -若不是已识别网盘链接,则返回空 URL: - -```python -{"parse": 0, "playUrl": "", "url": ""} -``` - -## 模块边界 - -新蜘蛛内部拆分为以下职责: - -- 站点配置与固定分类 -- URL 组装 -- 文本清洗 -- 图片 URL 修正 -- 列表卡片解析 -- 搜索卡片解析 -- 详情页字段提取 -- 网盘类型识别 -- 网盘线路拼接 - -不新增公共基类,不抽共享模块。 - -## URL 与 ID 设计 - -详情页 `vod_id` 使用站内短路径,而不是完整 URL。 - -编码方式: - -- 详情链接 `/voddetail/123.html` 对外直接保存为 `/voddetail/123.html` - -原因: - -- 与参考 JS 行为一致 -- 当前仓库已有多个蜘蛛直接使用站内短路径作为 `vod_id` -- 单站实现不需要再引入额外编码层 - -详情请求时再基于主域拼成完整地址。 - -## 请求策略 - -主域固定为: - -- `http://wogg.xxooo.cf` - -请求头包含固定 `User-Agent`,必要时补 `Referer` 为首页。 - -异常处理策略: - -- 页面请求失败时返回空列表或空字段结果 -- 不向上抛出未处理异常 -- 不实现多域名切换 -- 不实现重试 - -## 图片修正规则 - -保留参考 JS 的核心行为: - -- 空图片返回空字符串 -- 以 `/db.php?url=` 开头的图片地址补全为绝对地址 -- 若命中百度 `gimg` 包裹且 `src=` 后是 `data:image/`,则视为无效图片并返回空字符串 -- 其他情况下保留原值,必要时补全为绝对地址 - -这样可以兼容站点现有的懒加载和图片代理形式。 - -## 列表与搜索解析 - -分类列表解析容器: - -- `#main .module-item` - -提取策略: - -- 链接:`.module-item-pic a[href]` -- 标题:`.module-item-pic img[alt]` -- 封面:`.module-item-pic img[data-src|src]` -- 备注:`.module-item-text` - -搜索结果解析容器: - -- `.module-search-item` - -提取策略: - -- 链接和标题优先来自 `.video-serial` -- 封面来自 `.module-item-pic img[data-src|src]` -- 备注优先取 `.video-serial` 文本,缺失时回退 `.module-item-text` - -列表和搜索都应避免空标题、空链接项。 - -## 详情解析 - -详情页从 HTML 中提取以下信息: - -- 标题:`.page-title` -- 封面:`.mobile-play .lazyload[data-src|src]` -- 年份、导演、主演:遍历 `.video-info-itemtitle` 与相邻内容节点 -- 简介:优先从“剧情”字段或对应文本块提取 -- 网盘链接:`.module-row-info p` - -字段处理规则: - -- 多个导演或主演用英文逗号连接 -- 去除多余空白字符 -- 缺失字段保留空字符串 - -## 网盘线路设计 - -详情页中每个分享链接会被识别为网盘类型,再输出为播放线路。 - -首批支持识别: - -- 百度网盘 -- 夸克网盘 -- UC 网盘 -- 阿里云盘 -- 迅雷云盘 -- 115 网盘 -- 天翼云盘 -- 139 网盘 -- 123 云盘 - -输出规则: - -- `vod_play_from` 中每条线路格式为 `<pan_type>#玩偶哥哥` -- `vod_play_url` 中每条线路格式为 `<资源标题>$<分享链接>` -- 线路之间使用 `$$$` 分隔 -- 单站内对重复分享链接去重 -- 同一详情页内按预设网盘优先级排序 - -资源标题使用人类可读名称,例如: - -- `百度资源` -- `夸克资源` -- `阿里资源` - -## 测试设计 - -先写失败测试,再补实现。 - -测试覆盖范围: - -- `homeContent` 返回 8 个固定分类 -- `categoryContent` 正确拼接分类 URL 并解析卡片 -- `searchContent` 对空关键词返回空列表 -- `searchContent` 正确解析搜索结果 -- 图片修正逻辑覆盖代理图和无效百度图 -- `detailContent` 提取标题、封面、年份、导演、主演、简介和网盘链接 -- 网盘线路按类型识别、去重和排序 -- `playerContent` 对网盘链接透传 -- `playerContent` 拒绝非网盘链接 - -测试全部使用内联 HTML 和 `unittest.mock`,不依赖真实网络。 - -## 风险与取舍 - -主要风险: - -- 参考域名可能失效,但不影响离线测试与结构实现 -- 页面字段“剧情”区块结构可能存在变体,需要解析逻辑适度宽松 -- 搜索结果中部分元素可能不是标准 `a` 标签,需要兼容从属性和文本双路径提取 - -本次取舍: - -- 优先实现与参考 JS 一致的静态 HTML 解析 -- 不在首版里扩展筛选或多域名容灾 -- 不实现任何超出网盘透传范围的播放解析 diff --git a/py/docs/superpowers/specs/2026-04-20-yisou-spider-design.md b/py/docs/superpowers/specs/2026-04-20-yisou-spider-design.md deleted file mode 100644 index b521650..0000000 --- a/py/docs/superpowers/specs/2026-04-20-yisou-spider-design.md +++ /dev/null @@ -1,314 +0,0 @@ -# 奕搜 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的奕搜站点爬虫,覆盖以下能力: - -- 首页分类 -- 分类列表 -- 搜索 -- 详情解析 -- 网盘透传播放 - -实现以用户提供的 JS 版本为行为参考,但最终产物遵循当前仓库的单文件 Spider 约定,并对齐仓库中现有网盘类规则的输出格式。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `奕搜.py` -- 使用单一站点主域:`https://ysso.cc` -- 支持 `home/category/detail/search/player` 主链路 -- 首页返回固定分类 -- 详情页提取影片元数据和网盘分享链接 -- `playerContent` 对分享链接做原样透传 -- 为新增行为补齐 `unittest` - -本次实现不包含: - -- 参考 JS 中的 `panUrls` 返回格式复刻 -- 站内播放线路解析 -- 网盘驱动匹配、提取码自动补链或网盘二次解析 -- 浏览器执行、反爬绕过或多域名探活 -- 修改 `base/` 公共层 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分为 URL 组装、文本清洗、标题备注提取、列表解析、详情页元信息提取和网盘分组几个 helper -- 保留参考 JS 的核心页面解析行为,但输出改为仓库现有 Spider 可直接消费的 `vod_play_from` / `vod_play_url` - -不直接照搬参考 JS 返回 `panUrls` 的原因是: - -- 当前 Python 仓库上层消费的是标准播放线路字段 -- 仓库里已有网盘透传型 Spider 可以直接复用输出约定 -- 本次目标是把网页上的网盘资源稳定映射到现有播放器接口,而不是维持 JS 私有数据结构 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头和固定分类 -- `homeContent` - - 返回固定 `class` -- `homeVideoContent` - - 返回空列表 -- `categoryContent` - - 请求分类页并解析卡片列表 -- `searchContent` - - 请求搜索页并解析卡片列表 -- `detailContent` - - 请求详情页,提取影片元数据与网盘分享链接 -- `playerContent` - - 对网盘分享链接或 `push://` 地址做原样透传 -- 私有辅助函数 - - 补全绝对 URL - - 清洗标题文本 - - 从标题中提取备注 - - 解析列表卡片 - - 解析详情元信息 - - 识别网盘类型并构造播放分组 - -## Host 与请求策略 - -本次只实现单域: - -- `https://ysso.cc` - -请求统一通过 `self.fetch` 发起,固定请求头至少包含: - -- `User-Agent` -- `Referer` - -请求原则: - -- HTML 请求超时固定为 10 秒 -- 请求失败时返回空 HTML 或空结果,不抛出未处理异常 -- 不引入额外缓存、重试或 JS 执行环境 - -## 分类设计 - -首页分类固定为: - -- `dy -> 电影` -- `dsj -> 电视剧` -- `zy -> 综艺` -- `dm -> 动漫` -- `jlp -> 纪录片` -- `dj -> 短剧` - -`homeContent` 返回: - -- `class` - -不返回筛选配置,因为参考实现没有分类筛选结构。 - -`homeVideoContent` 直接返回: - -- `{"list": []}` - -## 列表与搜索设计 - -分类页 URL: - -- `/<classId>.html?page=<page>` - -搜索 URL: - -- `/search.html?keyword=<urlencoded keyword>&page=<page>` - -分类页和搜索页共用同一个卡片解析器,目标结构为 `.list-boxes`。 - -卡片字段提取规则: - -- `vod_id` - - 优先取 `a.text_title_p[href]` - - 如果缺失则回退 `.left_ly a[href]` - - 输出站点短路径,不暴露完整详情 URL -- `vod_name` - - 使用标题文本清理 `[]` 标签后的结果 -- `vod_pic` - - 取 `img.image_left[src]` 并补全成绝对地址 -- `vod_remarks` - - 优先从标题中的 `[]` 标签提取 - - 若无可用备注,则回退 `.list-actions span` - -标题备注提取顺序与参考 JS 保持一致: - -1. 更新集数,如 `[更12]` 转成 `更新至12集` -2. 评分,如 `[8.5分]` 转成 `评分:8.5` -3. 年份,如 `[2025]` 转成 `首播:2025` - -分页返回字段: - -- `page` -- `limit` -- `total` -- `list` - -为了符合仓库当前约定,分类和搜索结果不返回 `pagecount`。 - -## 短 ID 设计 - -为了遵循仓库里的短 ID 规则,不对外暴露完整详情页 URL。 - -列表与搜索阶段拿到的详情链接如果为站内 URL,则统一压缩为站点相对路径,例如: - -- `/resource/12345` - -`detailContent` 接收到相对路径后再补成完整详情 URL。 - -如果上层传入的是完整 URL,也兼容直接处理,但 Spider 自身产出仍以短路径为主。 - -## 详情页设计 - -详情页输出单个视频对象,字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_director` -- `vod_actor` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -元信息提取规则: - -- 标题 - - `h1.articl_title` - - 标题中的 `[]` 标签会先用于生成 `vod_remarks`,再从标题中剥离 -- 封面 - - `.tc-box.article-box img` 首张图 -- 导演与编剧 - - 遍历 `#info > span` - - 命中“导演”或“编剧”标签时,读取 `.attrs a` - - 去重后以 `, ` 拼接到 `vod_director` -- 主演与演员 - - 命中“主演”或“演员”标签时,读取 `.attrs a` - - 去重后以 `, ` 拼接到 `vod_actor` -- 简介 - - 遍历正文中长度明显大于标签名的段落 - - 取首个有效长文本并压缩空白 - -提取码策略: - -- 从整页文本中匹配 `提取码[::]XXXX` -- 若命中,则追加到 `vod_remarks` -- 不把提取码写回网盘链接,不做自动拼装 - -## 网盘资源设计 - -详情页只保留网盘分享资源,不保留站内播放线路。 - -网盘链接提取策略: - -- 遍历详情页中所有 `a[target="_blank"][href]` -- 仅保留 `http/https` 绝对地址 -- 按分享链接去重,避免同链接多次输出 - -网盘类型识别范围: - -- `baidu` - - `pan.baidu.com` -- `quark` - - `pan.quark.cn` -- `uc` - - `drive.uc.cn` -- `aliyun` - - `alipan.com` - - `aliyundrive.com` -- `xunlei` - - `pan.xunlei.com` - -输出策略: - -- 能识别盘类型时,按盘类型分组输出 -- 每个分组的单条资源格式为 `<盘名>$<分享链接>` -- 同一分组内多条资源用 `#` 连接 -- 多个分组之间用 `$$$` 连接 -- `vod_play_from` 与 `vod_play_url` 的分组顺序保持一致 - -盘类型排序优先级: - -1. `baidu` -2. `quark` -3. `uc` -4. `aliyun` -5. `xunlei` - -如果详情页存在外链但都不属于上述已识别网盘: - -- 使用单一兜底线路名 `奕搜` -- 对应 `vod_play_url` 为 `1$push://<url>#2$push://<url>...` - -如果详情页不存在任何可用外链: - -- `vod_play_from = 奕搜` -- `vod_play_url = ""` - -这样可以保持与仓库里现有网盘透传规则一致,并且明确表达“本 Spider 只提供网盘分享链接透传”。 - -## 播放设计 - -`playerContent(flag, id, vipFlags)` 不做二次解析,只负责规范化透传: - -- 如果 `id` 以 `push://` 开头 - - 去掉前缀后返回真实分享链接 -- 如果 `id` 已经是 `http/https` 分享链接 - - 直接原样返回 -- 其他情况 - - 也原样返回,不额外猜测和改写 - -返回结构: - -- `parse = 0` -- `url = <透传后的值>` - -不附加鉴权头,不做 `jx` 回退。 - -## 错误处理 - -- 分类页、搜索页请求失败时返回空列表 -- 详情页请求失败时返回 `{"list": []}` -- 单个网盘链接识别失败时跳过盘类型识别,但不影响其他链接 -- 标题、图片、导演、主演、简介等字段缺失时返回空字符串 - -## 测试设计 - -测试覆盖以下行为: - -1. `homeContent` - - 断言固定分类完整且顺序正确 -2. 列表解析 - - 断言能从 `.list-boxes` 解析短路径 `vod_id`、清洗后的标题、图片和备注 - - 断言备注提取优先级符合参考 JS -3. `categoryContent` - - 断言分类页 URL 正确 - - 断言返回字段不包含 `pagecount` -4. `searchContent` - - 断言空关键词返回空列表 - - 断言搜索 URL 包含 URL 编码后的关键词 -5. `detailContent` - - 断言能提取标题、图片、导演、主演、简介和提取码 - - 断言详情页只输出网盘透传线路 - - 断言网盘链接按盘类型分组、去重和排序 - - 断言未识别外链时回退到 `push://` 兜底线路 -6. `playerContent` - - 断言 `push://` 去壳透传 - - 断言普通网盘分享链接原样透传 - -## 验收标准 - -- `homeContent/categoryContent/detailContent/searchContent/playerContent` 均返回符合当前项目习惯的数据结构 -- 列表和搜索结果只输出站点短路径 `vod_id` -- 标题备注提取和标题清洗与参考 JS 的优先级一致 -- 详情页只输出网盘分享线路,不保留站内播放线路 -- `playerContent` 对网盘链接仅做原样透传 -- 新增 `unittest` 能稳定覆盖主要解析分支 diff --git a/py/docs/superpowers/specs/2026-04-20-zhizhen-spider-design.md b/py/docs/superpowers/specs/2026-04-20-zhizhen-spider-design.md deleted file mode 100644 index 0ebc6b2..0000000 --- a/py/docs/superpowers/specs/2026-04-20-zhizhen-spider-design.md +++ /dev/null @@ -1,291 +0,0 @@ -# 至臻 Python 爬虫设计 - -**日期:** 2026-04-20 - -## 目标 - -在当前 Python Spider 仓库中新增一个独立单站蜘蛛 `至臻.py`,参考用户提供的 JS 版本行为,实现符合 `base.spider.Spider` 接口的网盘资源站适配。 - -本次实现需要覆盖: - -- 固定 7 个分类 -- 分类列表 -- 搜索 -- 详情页元数据解析 -- 网盘链接整理 -- 播放透传 -- 对应 `unittest` - -## 范围 - -本次实现包含: - -- 新增独立蜘蛛文件 `py/至臻.py` -- 新增测试文件 `py/tests/test_至臻.py` -- 单域名站点适配:`http://www.miqk.cc` -- 固定 7 个分类,分类 ID 与参考 JS 保持一致 -- 分类页和搜索页卡片解析 -- 详情页元数据与网盘链接提取 -- 按网盘类型输出 `vod_play_from` 和 `vod_play_url` -- `playerContent` 对已识别网盘分享链接直接透传 - -本次实现不包含: - -- 聚合多站 -- 站内直链播放解析 -- 本地筛选配置文件 -- 验证码、浏览器执行或复杂反爬绕过 -- 修改 `base/` 公共层 - -## 方案选择 - -采用“单站单文件 + 少量 helper + 单测”的仓库现有模式,而不是直接保存用户提供的 JS 代码。 - -原因: - -- 用户已确认以独立单站爬虫交付 -- 当前仓库已存在多个相同结构的单文件 Spider -- 私有 helper 能把 URL 组装、文本清洗、卡片解析、详情提取和网盘识别拆开,便于后续修站 -- 解析逻辑可以通过静态 HTML 单测稳定覆盖,不依赖真实网络 - -不采用“提前抽公共盘站基类”的方案,因为本次目标是尽快落一个站点,过早抽象会扩大改动面。 - -## 接口设计 - -### `homeContent` - -返回固定 7 个分类: - -- `1 -> 至臻电影` -- `2 -> 至臻剧集` -- `3 -> 至臻动漫` -- `4 -> 至臻综艺` -- `5 -> 至臻短剧` -- `24 -> 至臻老剧` -- `26 -> 至臻严选` - -不返回筛选项。 - -### `homeVideoContent` - -返回空列表: - -- `{"list": []}` - -### `categoryContent` - -分类页 URL 规则: - -- `/index.php/vod/show/id/{tid}/page/{page}.html` - -解析 `#main .module-item` 卡片并输出: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_year` - -分页返回字段: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -### `searchContent` - -搜索 URL 规则: - -- `/index.php/vod/search/page/{page}/wd/{keyword}.html` - -空关键词直接返回空列表。 - -搜索结果结构与分类列表保持一致,但 `vod_remarks` 优先取 `.video-serial` 文本。 - -### `detailContent` - -通过详情页提取: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_year` -- `vod_director` -- `vod_actor` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -详情页只整理网盘分享链接,不解析站内播放器。 - -### `playerContent` - -若 `id` 是支持的网盘分享链接,则返回透传结果: - -```python -{"parse": 0, "playUrl": "", "url": id} -``` - -若不是已识别网盘链接,则返回空 URL: - -```python -{"parse": 0, "playUrl": "", "url": ""} -``` - -## 模块边界 - -新蜘蛛内部拆分为以下职责: - -- 站点配置与固定分类 -- URL 组装 -- 文本清洗 -- HTML 请求封装 -- 列表卡片解析 -- 搜索结果解析 -- 详情页字段提取 -- 网盘类型识别 -- 网盘线路拼接 - -不新增公共基类,不抽共享模块。 - -## URL 与 ID 设计 - -详情页 `vod_id` 使用站内短路径,而不是完整 URL。 - -编码方式: - -- 详情链接 `/index.php/vod/detail/id/123.html` 对外直接保存为 `/index.php/vod/detail/id/123.html` - -原因: - -- 与用户给出的 JS 行为一致 -- 当前仓库已有多个蜘蛛直接使用站内短路径作为 `vod_id` -- 单站实现不需要额外编码层 - -详情请求时再基于主域拼成完整地址。 - -## 请求策略 - -主域固定为: - -- `http://www.miqk.cc` - -请求头包含固定 `User-Agent` 和首页 `Referer`。 - -异常处理策略: - -- 页面请求失败时返回空列表或空字段结果 -- 不向上抛出未处理异常 -- 不实现多域名切换 -- 不实现重试 - -## 列表与搜索解析 - -分类列表解析容器: - -- `#main .module-item` - -提取策略: - -- 链接:`.module-item-pic a[href]` -- 标题:`.module-item-pic img[alt]` -- 封面:`.module-item-pic img[data-src|src]` -- 备注:`.module-item-text` -- 年份:`.module-item-caption span:first-child` - -搜索结果解析容器: - -- `.module-search-item` - -提取策略: - -- 链接和标题优先来自 `.video-serial` -- 封面来自 `.module-item-pic img[data-src|src]` -- 备注优先取 `.video-serial` 文本,缺失时回退 `.module-item-text` - -列表和搜索都应忽略空标题或空链接项。 - -## 详情解析 - -详情页字段来源按用户提供的 JS 保持一致: - -- 标题:`.page-title` -- 封面:`.mobile-play .lazyload[data-src|src]` -- 标注区:`.video-info-itemtitle` 与其相邻节点 -- 网盘链接:`.module-row-info p` - -字段映射规则: - -- `年代` -> `vod_year` -- `导演` -> `vod_director` -- `主演` -> `vod_actor` -- `剧情` -> `vod_content` - -其中导演、主演优先拼接相邻区域中的链接文本;剧情提取文本内容并做空白清洗。 - -## 网盘线路整理 - -支持识别以下网盘: - -- 百度 -- 139 -- 天翼 -- 123 -- 115 -- 夸克 -- 迅雷 -- 阿里 -- UC - -排序优先级: - -1. 百度 -2. 139 -3. 天翼 -4. 123 -5. 115 -6. 夸克 -7. 迅雷 -8. 阿里 -9. UC - -输出规则: - -- `vod_play_from` 使用 `{pan_type}#至臻` 线路名拼接 -- `vod_play_url` 使用 `{标题}${分享链接}` 拼接 -- 不支持的链接忽略 -- 重复链接去重 - -## 测试策略 - -采用 `unittest` 和 `unittest.mock`,不依赖真实网络。 - -至少覆盖: - -- 固定分类和空首页 -- URL 构建与网盘识别 -- 分类卡片解析 -- 分类接口 URL 拼装 -- 搜索接口 URL 拼装和结果解析 -- 详情元数据提取 -- 网盘线路去重和排序 -- `detailContent` 最终输出 -- `playerContent` 对网盘链接透传和非网盘拒绝 - -## 风险与约束 - -- 站点 DOM 如果与用户提供的 JS 片段不一致,测试需要以当前仓库约定的静态夹具为准 -- 搜索关键词直接拼接到路径中,需做 URL 编码 -- 详情页相邻节点结构若出现空白文本节点,解析时需要回退到 XPath 文本合并,避免取值为空 - -## 验收标准 - -满足以下条件即可认为完成: - -- `py/至臻.py` 实现 `Spider` 所需接口 -- `py/tests/test_至臻.py` 覆盖核心行为 -- 针对 `至臻` 模块的单测全部通过 -- 返回结构与当前仓库同类盘站蜘蛛保持一致 diff --git a/py/docs/superpowers/specs/2026-04-20-zxzjhd-spider-design.md b/py/docs/superpowers/specs/2026-04-20-zxzjhd-spider-design.md deleted file mode 100644 index e00094a..0000000 --- a/py/docs/superpowers/specs/2026-04-20-zxzjhd-spider-design.md +++ /dev/null @@ -1,375 +0,0 @@ -# 在线之家 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的在线之家站点爬虫,覆盖以下能力: - -- 首页分类与筛选 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 - -实现基于站点 HTML 页面结构,不引入 JS 服务端路由层,不修改 `base/` 公共层,并且保留详情页中的网盘分享线路。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `在线之家.py` -- 使用单一站点主域:`https://www.zxzjhd.com` -- 支持 `home/category/detail/search/player` 主链路 -- 首页返回固定分类与静态筛选配置 -- 详情页同时输出普通播放线路和网盘分享线路 -- `playerContent` 对普通线路执行 `zxzj` 解密,对网盘线路直接透传分享链接 - -本次实现不包含: - -- 参考 JS 中的 Fastify 路由封装 -- 网盘驱动对象注入、驱动缓存映射、网盘目录展开 -- 浏览器自动化或 JS 执行环境 -- 多域名探活与自动切换 -- 登录态、Cookie 常驻维护、反爬绕过 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分为请求封装、筛选路径拼接、列表解析、详情解析、播放解析几个 helper -- 保留参考 JS 规则里的关键业务行为,但只实现 Python Spider 实际需要的部分 - -不引入 JS 版 drive 处理层的原因是: - -- 当前 Python 仓库没有统一的 drive 注入协议 -- 现有同类蜘蛛对网盘资源的惯例是保留线路名与分享链接,让上层插件处理 -- 本次重点是 HTML 解析、数据整形和 `zxzj` 播放解密,不是中间层编排 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化主域、请求头、固定分类、筛选项与筛选默认值 -- `homeContent` - - 返回固定 `class` 与 `filters` -- `homeVideoContent` - - 返回空列表,不额外抓首页推荐 -- `categoryContent` - - 根据分类、筛选和分页拼出 URL,请求 HTML 并解析卡片 -- `searchContent` - - 根据关键词请求搜索页并解析结果 -- `detailContent` - - 请求详情页,提取影片元数据、普通播放线路和网盘线路 -- `playerContent` - - 对 `zxzj` 普通线路做播放解密,对网盘线路直接透传分享链接 -- 私有辅助函数 - - 补全相对地址 - - 清理文本 - - 修复 JSON 包裹 HTML - - 拼接筛选路径 - - 解析卡片列表 - - 提取详情字段 - - 提取标签页与剧集列表 - - 提取播放页中的网盘分享链接 - - 识别网盘类型 - - 执行 `zxzj` 解密 - -## Host 与请求策略 - -本次只实现单域: - -- `https://www.zxzjhd.com` - -请求统一通过 `self.fetch` 发起,固定请求头至少包含: - -- `User-Agent` -- `Referer: https://www.zxzjhd.com/` - -请求原则: - -- HTML 请求超时固定为 10 秒 -- 请求失败时返回空 HTML 或空结果,不抛出未处理异常 -- 对字符串响应先尝试修复 JSON 包裹 HTML 的情况,避免站点返回 `"<!doctype html...>"` 这种包裹值时解析失败 -- 不引入浏览器执行,也不依赖外部解压、解密服务 - -## 分类与筛选设计 - -首页分类固定为: - -- `1 -> 电影` -- `2 -> 美剧` -- `3 -> 韩剧` -- `4 -> 日剧` -- `5 -> 泰剧` -- `6 -> 动漫` - -筛选配置直接内置到脚本中,字段与参考实现保持一致: - -- `class` -- `area` -- `year` -- `by` - -筛选默认值保留 `cateId`,通过分类 id 决定。 - -筛选 URL 规则采用模板拼接: - -- `{{fl.cateId}}-{{fl.area}}-{{fl.by}}-{{fl.class}}-----fypage---{{fl.year}}` - -输出路径再包到: - -- `/vodshow/<filter-path>.html` - -规则说明: - -- `cateId` 来自当前分类默认配置 -- `class/area/year/by` 来自传入的 `extend` -- 缺失筛选值时保留空段,不重排位置 -- 当前仓库约定不返回 `pagecount`,分类结果只返回 `list/page/limit/total` - -`homeContent` 返回: - -- `class` -- `filters` - -不返回首页推荐列表。 - -## 列表与搜索设计 - -分类页和搜索页均解析 `stui-vodlist` 卡片结构。 - -卡片提取字段: - -- 标题:`a[title]` -- 图片:`data-original`,缺失时回退到 `data-src` 或 `src` -- 备注:`.pic-text` -- 链接:`a[href]` - -输出统一卡片结构: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -其中 `vod_id` 保留短详情路径,而不是完整 URL,形式为: - -- `voddetail/12345.html` - -或等价的相对详情路径片段。 - -这样可以遵循仓库当前短 id 约定,避免把完整域名泄露到列表结果里。 - -搜索接口使用: - -- `/vodsearch/<关键词>-------------.html` - -搜索直接请求完整搜索页 HTML,不做 POST,也不做二次过滤。 - -分页策略采用保守估计: - -- 分类页 `limit` 固定为 24 -- 搜索结果 `limit` 使用结果数 -- `total` 采用 `page * 30 + len(items)` 的仓库常见保守写法 -- 不返回 `pagecount` - -## 详情页设计 - -详情页解析以下字段: - -- 标题 -- 封面 -- 简介 -- 年份 -- 地区 -- 类型 -- 导演 -- 主演 -- 更新信息或集数备注 - -详情输出字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_content` -- `vod_remarks` -- `vod_year` -- `vod_area` -- `vod_class` -- `vod_lang` -- `vod_director` -- `vod_actor` -- `vod_play_from` -- `vod_play_url` - -图片地址统一补全为完整 URL,修复站点相对路径与 `//` 形式地址。 - -详情页播放来源分为两组: - -1. 普通站内线路 - - 来源于详情页的播放标签和对应剧集列表 - - 不保留原站复杂线路名,统一归并成 `zxzj` - - 每条剧集格式为 `名称$<play-id>` - - 同一普通线路下多集使用 `#` 连接 -2. 网盘线路 - - 来源于详情页标签中明显标记为网盘、百度、夸克、UC、阿里、迅雷的线路 - - 详情页中的剧集链接先指向站内播放页,不直接给出分享链接 - - 需要继续请求这些播放页,读取其中 `player_aaaa.url` - - 如果 `player_aaaa.url` 是分享地址,则按网盘类型分组保留 - -网盘线路识别范围: - -- `baidu` -- `quark` -- `uc` -- `aliyun` -- `xunlei` - -网盘类型优先通过分享链接域名识别: - -- `pan.baidu.com` -> `baidu` -- `pan.quark.cn` -> `quark` -- `drive.uc.cn` -> `uc` -- `alipan.com` 或 `aliyundrive.com` -> `aliyun` -- `pan.xunlei.com` -> `xunlei` - -如果标签名称与域名不一致,以分享链接域名为准。 - -网盘线路保留策略: - -- 每种网盘类型单独作为一条线路 -- 每个条目格式为 `剧集名$分享链接` -- 按分享链接去重,保留第一次出现的标题 -- 没有有效分享链接的网盘项直接跳过 - -最终线路组合规则: - -- `vod_play_from` 为 `zxzj$$$quark$$$baidu` 这类拼接形式 -- `vod_play_url` 与线路顺序严格对应 -- 没有普通线路时,不强行保留 `zxzj` -- 没有网盘线路时,只返回普通线路 - -## 播放设计 - -`playerContent(flag, id, vipFlags)` 按来源分流。 - -### 普通线路 `zxzj` - -输入 `id` 为相对播放路径或短播放 id 时,先补全为完整播放页 URL,再执行以下流程: - -1. 请求播放页 -2. 提取页面中指向 `jx.zxzj` 的中间页 URL -3. 请求中间页 -4. 提取 `result_v2` JSON 中的加密串 -5. 执行参考实现中的解密算法: - - 先翻转字符串 - - 按两位十六进制转字符 - - 移除中间插入的 7 位混淆字符 -6. 如果解密结果为媒体直链,则返回直链 - -成功返回: - -- `parse = 0` -- `jx = 0` -- `url = 解密后的媒体地址` -- `header.Referer = 中间页 URL` - -如果任一步失败,则回退为原播放页地址,返回: - -- `parse = 1` -- `jx = 1` -- `url = 播放页 URL` - -### 网盘线路 - -当 `flag` 为以下值之一时视为网盘线路: - -- `baidu` -- `quark` -- `uc` -- `aliyun` -- `xunlei` - -对网盘线路不做二次解析,直接透传分享链接: - -- `parse = 0` -- `jx = 0` -- `url = 原始分享链接` -- `header = {}` - -这样可以保持和仓库内现有带网盘蜘蛛的一致行为,让上层网盘插件继续接管。 - -## 解析与兼容性策略 - -为了降低页面结构波动影响,实现上采用“XPath/HTML 结构优先,正则兜底”的策略: - -- 列表与搜索主走 XPath -- 详情字段主走 XPath 或相邻文本提取 -- 播放页 `player_aaaa` 与中间页 `result_v2` 使用正则提取 -- 网盘分享链接通过播放页内嵌 JSON 提取 - -文本处理原则: - -- 缺失字段返回空字符串 -- 标题、备注、简介统一做空白折叠 -- 多值字段使用逗号连接 -- 简介保留段落换行,不保留多余空白 - -URL 处理原则: - -- 相对路径补全为完整 URL -- 列表和搜索输出短 `vod_id` -- 详情内普通剧集输出短 `play-id` -- 网盘线路保留完整分享链接 - -## 测试设计 - -新增测试文件: - -- `tests/test_在线之家.py` - -测试全部使用 `unittest` 和 `unittest.mock`,不访问真实网络,覆盖以下行为: - -1. 首页 - - 断言分类 id 和筛选 key 正确输出 -2. 分类 URL - - 断言筛选模板拼接出的路径符合站点规则 -3. 列表解析 - - 断言能提取短 `vod_id`、标题、封面和备注 -4. 搜索解析 - - 断言能解析搜索页卡片 -5. 详情解析 - - 断言能提取标题、封面、简介、年份、地区、类型、导演、主演 - - 断言普通线路统一输出为 `zxzj` - - 断言网盘播放页会被继续请求并转成 `baidu/quark/uc/aliyun/xunlei` 线路 - - 断言网盘链接按分享链接去重 -6. 播放解密 - - 断言 `zxzj` 解密算法能还原媒体地址 - - 断言 `playerContent` 对普通线路成功返回直链 - - 断言解密失败时回退到播放页地址 -7. 网盘播放分流 - - 断言 `playerContent` 对 `baidu/quark/uc/aliyun/xunlei` 直接透传分享链接 - -## 风险与约束 - -- 站点反爬较强,真实页面可能偶发返回包裹字符串或异常结构,因此实现只保证对已知 HTML 结构稳健解析 -- 网盘分享链接需要额外请求网盘播放页,详情阶段请求数会比普通站点更高 -- 普通线路统一命名为 `zxzj` 是刻意收敛行为,丢弃了站点原始标签名,但换来更稳定的播放分流 -- 该实现不承担网盘目录展开和鉴权,依赖上层现有网盘插件消费分享链接 - -## 验收标准 - -完成后应满足以下结果: - -- 新增 `在线之家.py`,且不修改 `base/` -- `home/category/search/detail/player` 五条主链路都有单测覆盖 -- 列表和搜索输出短 `vod_id` -- 详情页能同时输出普通线路和网盘线路 -- 网盘线路按 `baidu/quark/uc/aliyun/xunlei` 标准名返回 -- `playerContent("zxzj", ...)` 能执行解密并优先返回直链 -- `playerContent(<网盘线路>, ...)` 直接返回分享链接 -- 相关单测可在本仓库的 `unittest` 环境中稳定运行 diff --git a/py/docs/superpowers/specs/2026-04-23-butailing-spider-design.md b/py/docs/superpowers/specs/2026-04-23-butailing-spider-design.md deleted file mode 100644 index 52c3c99..0000000 --- a/py/docs/superpowers/specs/2026-04-23-butailing-spider-design.md +++ /dev/null @@ -1,283 +0,0 @@ -# 不太灵 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的不太灵站点爬虫,直接对接 `https://web5.mukaku.com/prod/api/v1/`,覆盖以下能力: - -- 首页分类 -- 首页推荐 -- 分类列表 -- 搜索 -- 详情解析 -- 网盘分享链接透传播放 - -实现以用户提供的 JS 版本为行为参考,但最终产物遵循当前仓库的单文件 Spider 约定,不引入 JS 端的网盘驱动依赖。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `不太灵.py` -- 使用单一 API 根地址 `https://web5.mukaku.com/prod/api/v1/` -- 支持 `home/category/detail/search/player` 主链路 -- 首页返回固定五个分类 -- 电影和电视剧支持 API 筛选参数映射 -- 详情页提取影片元数据和网盘分享资源 -- 网盘资源按分享链接独立成线路 -- `playerContent` 对分享链接做原样透传 -- 为新增行为补齐 `unittest` - -本次实现不包含: - -- 参考 JS 中的磁力画质线路输出 -- 网盘驱动匹配、`getVod` 预解析或 `play` 二次解析 -- 提取码自动补链 -- 浏览器执行、反爬绕过或多域名探活 -- 修改 `base/` 公共层 - -## 方案选择 - -采用仓库现有的“单站点单文件 + 单测”方案: - -- 对外保持 `Spider` 接口兼容 -- 对内拆分为 API 请求、参数归一、列表项归一、分页处理、网盘类型识别、详情播放源提取几个 helper -- 保留参考 JS 的核心接口映射和过滤参数含义,但输出改为仓库可直接消费的 `vod_play_from` / `vod_play_url` - -不复刻参考 JS 的驱动解析逻辑,原因是: - -- 当前 Python 仓库没有对应的运行时网盘驱动注入机制 -- 用户已明确要求“直接返回分享链接” -- 单测应保持纯离线,不依赖外部驱动行为 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -脚本内部职责拆分如下: - -- `init` - - 初始化 API 地址、鉴权参数、请求头、分类和缓存 -- `homeContent` - - 返回固定 `class`,并附带电影/电视剧筛选项 -- `homeVideoContent` - - 调用热门接口,返回首页推荐 -- `categoryContent` - - 根据分类和扩展参数请求对应 API,并归一化结果 -- `searchContent` - - 请求搜索接口,做名称过滤、本地去重和分页 -- `detailContent` - - 请求详情接口,提取影片元数据与网盘分享线路 -- `playerContent` - - 对分享链接直接透传 -- 私有辅助函数 - - 构造带鉴权的 API URL - - 发送请求并解析 JSON - - 归一化列表与详情对象 - - 解析扩展筛选参数 - - 本地去重与分页 - - 识别网盘类型并构造播放线路 - -## API 与请求策略 - -固定配置如下: - -- API 根地址:`https://web5.mukaku.com/prod/api/v1/` -- `app_id`:`83768d9ad4` -- `identity`:`23734adac0301bccdcb107c4aa21f96c` - -接口映射如下: - -- `homeContent` - - `getVideoTypeList` -- `homeVideoContent` - - `getVideoList`,参数 `sc=3` -- `categoryContent` - - 电影、电视剧:`getVideoMovieList` - - 热门分类:`getVideoList` -- `searchContent` - - `getVideoList` -- `detailContent` - - `getVideoDetail` - -请求原则: - -- 统一通过 `self.fetch` 发起 GET 请求 -- 超时固定为 10 秒 -- 请求头包含桌面浏览器 `User-Agent` -- JSON 解析兼容普通 JSON 字符串和带 `callback(...)` 包装的响应 -- 请求失败时返回空列表或空对象,不抛出未处理异常 -- 不额外引入重试和持久缓存 - -## 分类与筛选设计 - -首页固定分类为: - -- `1 -> 电影` -- `2 -> 电视剧` -- `3 -> 近日热门` -- `4 -> 本周热门` -- `5 -> 本月热门` - -`homeContent` 返回: - -- `class` -- `filters` - -筛选项只对电影和电视剧提供,来源于 `getVideoTypeList`,字段沿用参考实现: - -- `sc` 影视类型 -- `sd` 制片地区 -- `se` 上映年份 -- `sf` 资源画质 -- `sh` 影视标签 -- `sg` 排序方式 -- `iswp` 仅网盘资源 -- `status` 剧集状态,仅电视剧可用 - -筛选值归一原则: - -- `不限`、`0`、空字符串统一视为未设置 -- 排序默认值使用 `1` -- `iswp` 只接受 `0/1` -- `ext` 兼容 JSON、URL 编码 JSON 和 Base64 JSON - -## 列表、搜索与分页设计 - -列表项统一映射为: - -- `vod_id` - - 使用上游 `doub_id` -- `vod_name` - - 使用 `title` -- `vod_pic` - - 使用 `image` 或 `epic` -- `vod_remarks` - - 优先 `ejs`,回退 `zqxd` -- `vod_year` - - 使用 `years` -- `vod_content` - - 使用 `abstract` -- `vod_actor` - - 使用 `performer` -- `vod_director` - - 使用 `director` -- `vod_area` - - 使用 `production_area` - -分类处理分两类: - -- 电影、电视剧 - - 直接使用后端分页接口 `getVideoMovieList` - - 单页结果按 `doub_id` 去重 - - 返回 `page/limit/total/list` -- 热门分类 - - 因上游分页不稳定,固定抓取较大批量结果后本地去重和分页 - - 返回 `page/limit/total/list` - -搜索处理规则: - -- 调用 `getVideoList` -- 为避免非标题命中造成噪声,额外按标题包含关键字做一次过滤 -- 结果按 `doub_id` 去重 -- 再做本地分页 - -为了符合仓库当前约定,分类和搜索结果不返回 `pagecount`。 - -## 详情页设计 - -详情页输出单个视频对象,字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_year` -- `vod_content` -- `vod_actor` -- `vod_director` -- `vod_area` -- `vod_play_from` -- `vod_play_url` - -详情数据源直接使用 `getVideoDetail` 返回对象,不依赖列表缓存。 - -播放源策略只处理 `movies_online_seed`: - -- 忽略参考 JS 中的 `ecca` 和 `all_seeds` 磁力资源 -- 遍历 `movies_online_seed` 的每个分享项 -- 每个有效分享链接独立生成一条线路 -- 线路名格式为 `驱动类型#序号`,例如 `quark#1`、`baidu#2` -- 如果无法识别类型,则归类为 `other#序号` -- 同一链接去重,避免详情页重复输出 - -`vod_play_from` 示例: - -- `baidu#1$$$quark#1$$$aliyun#1` - -`vod_play_url` 示例: - -- `baidu#1$https://pan.baidu.com/s/xxx$$$quark#1$https://pan.quark.cn/s/yyy` - -## 网盘类型识别设计 - -按分享链接域名识别常见网盘类型: - -- `pan.quark.cn` -> `quark` -- `pan.baidu.com` -> `baidu` -- `pan.xunlei.com` -> `xunlei` -- `www.alipan.com` 或 `aliyundrive.com` -> `aliyun` -- `123865.com`、`123684.com`、`123pan.com` -> `a123` -- 其余 -> `other` - -如果 API 返回的分组名本身包含可用类型,但链接域名识别不到,则回退使用分组名归一后的结果。 - -## 播放设计 - -`playerContent` 不做任何解析: - -- 如果传入的是普通分享链接,直接返回 `{"parse": 0, "url": link}` -- 如果未来上层传入了 `push://` 前缀,也兼容去前缀后返回原始分享链接 - -本次不支持: - -- 直链视频嗅探 -- 网盘二次跳转 -- 播放头注入 -- 网盘分享码自动处理 - -## 错误处理设计 - -错误处理以“返回空结果,不中断链路”为原则: - -- API 请求失败 - - 列表接口返回空列表 - - 详情接口返回空对象,`detailContent` 最终返回空列表 -- JSON 解析失败 - - 记录日志后按空结果处理 -- 详情中无网盘资源 - - 返回正常元信息,`vod_play_from` 和 `vod_play_url` 置空 -- 搜索关键字为空 - - 直接返回空列表 - -## 测试设计 - -按 TDD 补充 `py/tests/test_灵机搜盘.py`,全部使用 `unittest.mock` 隔离网络请求。 - -首批测试覆盖: - -1. `homeContent` 返回固定分类,并只给电影、电视剧生成筛选项。 -2. `homeVideoContent` 使用 `sc=3` 请求热门列表并归一化字段。 -3. `categoryContent` 在电影分类下正确构造筛选参数,并且不返回 `pagecount`。 -4. `categoryContent` 对热门分类执行本地去重分页。 -5. `searchContent` 对标题关键字做二次过滤并去重。 -6. `detailContent` 能把多个网盘链接拆成独立线路并生成 `quark#1` 这类线路名。 -7. `detailContent` 忽略磁力资源,只保留网盘分享链接。 -8. `playerContent` 直接透传分享链接或去掉 `push://` 前缀。 -9. 网盘类型识别覆盖百度、夸克、迅雷、阿里、123 盘和兜底类型。 - -## 实施约束 - -- 文件命名使用中文站点名:`不太灵.py` -- 保持 helper 粒度适中,避免把 API 参数、详情播放源和分页逻辑堆在单个大函数里 -- 不修改现有 Spider 的行为 -- 先写测试并验证失败,再写实现代码 diff --git a/py/docs/superpowers/specs/2026-04-23-jikanyingshi-spider-design.md b/py/docs/superpowers/specs/2026-04-23-jikanyingshi-spider-design.md deleted file mode 100644 index ea220d6..0000000 --- a/py/docs/superpowers/specs/2026-04-23-jikanyingshi-spider-design.md +++ /dev/null @@ -1,263 +0,0 @@ -# 即看影视 Python 蜘蛛设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的即看影视蜘蛛,覆盖以下能力: - -- 首页分类与筛选 -- 首页推荐列表 -- 分类列表 -- 搜索 -- 详情解析 -- 播放解析 - -实现以用户提供的 Node/JS 版本为行为参考,但落地形式遵循当前仓库的单文件 Spider 约定。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `即看影视.py` -- 使用即看影视的 `SKApp` 加密协议 -- 启动时通过 `https://skyappdata-1321528676.cos.accelerate.myqcloud.com/4kapp/appipr.txt` 获取真实 `API_HOST` -- 通过 `/get_config` 获取授权 token -- 通过 `/app/config` 获取应用配置 -- 支持 `home/homeVideo/category/search/detail/player` 全链路 -- 为新增行为补齐 `unittest` - -本次实现不包含: - -- 参考代码中的 HTTP 路由导出层 -- 公共 `SKApp` helper 抽象 -- 动态播放规则配置 -- 重试、缓存持久化和多域名探活 - -## 方案选择 - -候选方案有三种: - -1. 单文件简化版 -2. 单文件加初始化缓存 -3. 抽公共 `SKApp` helper - -选用方案 2。 - -原因: - -- 保持与仓库现有蜘蛛的一致结构 -- 避免在一次 Spider 生命周期内重复拉取 `appipr.txt`、`/get_config` 和 `/app/config` -- 不扩大本次任务范围到公共层改造 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -文件内部按职责拆分为以下私有 helper: - -- `_ensure_ready` - - 负责初始化 `API_HOST`、加密参数、token 和应用配置 -- `_resolve_api_host` - - 请求 `appipr.txt` 并提取真实 host -- `_fetch_auth_token` - - 调用 `/get_config` 获取 Bearer token -- `_fetch_app_config` - - 调用 `/app/config` 获取播放相关配置 -- `_sk_decrypt` - - 处理 `FROMSKZZJM` 前缀响应 -- `_ck_encrypt` - - 生成 `/get_config` 所需的 `ck` -- `_fetch_api` - - 统一拼接查询参数、附加认证头、发起请求和解密 JSON -- `_fetch_filters` - - 逐分类请求 `/sk-api/type/alltypeextend` 并转换筛选结构 - -Spider 对外方法: - -- `init` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `searchContent` -- `detailContent` -- `playerContent` - -## 初始化与鉴权设计 - -固定配置保留在站点文件内部: - -- `host_config_url` - - `https://skyappdata-1321528676.cos.accelerate.myqcloud.com/4kapp/appipr.txt` -- `aes_key` - - `ygcnbckhcuvygdyb` -- `aes_iv` - - `4023892775143708` -- `ck_key` - - `ygcnbcrvaervztmw` -- `ck_iv` - - `1212164105143708` -- `user_agent` - - `Dart/2.10 (dart:io)` - -当前 `appipr.txt` 解析出的真实地址为: - -- `https://appsky2025999.ideasz.net` - -初始化流程: - -1. 读取 `appipr.txt` 得到 `API_HOST` -2. 校验 AES key/iv 长度为 16 -3. 计算 `sign=md5(aes_key + aes_iv)` -4. 生成 `ck=<API_HOST>##5483##<timestamp>##ckzmbc` 后做 `ckEncrypt` -5. POST `/get_config` 获取授权 token -6. GET `/app/config` 获取应用配置 -7. 将 `API_HOST`、`AUTH_TOKEN`、`CONFIG_DATA` 缓存在 Spider 实例上 - -`_ensure_ready` 在首次请求时执行,后续方法复用缓存。 - -## 数据映射设计 - -### 首页 - -`homeContent` 请求 `/sk-api/type/list`。 - -分类映射: - -- 输出 `type_id` -- 输出 `type_name` - -筛选映射: - -- 逐分类请求 `/sk-api/type/alltypeextend` -- 将 `extendtype/area/lang/year` 分别映射成 `类型/地区/语言/年份` -- 增加固定排序项: - - `最新 -> updateTime` - - `人气 -> hot` - - `评分 -> score` - -首页最终返回: - -- `class` -- `filters` -- `list` - -其中 `list` 由 `homeVideoContent` 提供。 - -### 首页推荐 - -`homeVideoContent` 请求: - -- `/sk-api/vod/list?page=1&limit=12&type=randomlikeindex` - -返回: - -- `{"list": data.data or []}` - -### 分类 - -`categoryContent` 请求 `/sk-api/vod/list`,参数为: - -- `typeId=tid` -- `page=pg` -- `limit=18` -- `type=extend.sort or updateTime` -- `area=extend.area or ""` -- `lang=extend.lang or ""` -- `year=extend.year or ""` -- `mtype=""` -- `extendtype=extend.class or ""` - -返回结构遵循仓库约定: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -### 搜索 - -`searchContent` 请求 `/sk-api/search/pages`,参数为: - -- `keyword` -- `page` -- `limit=10` -- `typeId=-1` - -返回结构同分类列表,不返回 `pagecount`。 - -### 详情 - -`detailContent` 请求 `/sk-api/vod/one?vodId=<id>`。 - -优先策略: - -- 如果上游 `data.data` 已经是仓库可直接消费的标准 `vod` 结构,则直接返回 -- 如果上游字段不完整,再做最小必要字段补齐 - -详情阶段不引入额外二次端点回退,保持与参考实现一致。 - -## 播放设计 - -用户确认的固定规则是: - -- 总是先请求 `/sk-api/vod/skjson` -- 失败后回退原始 `id` - -`playerContent` 逻辑: - -1. 调用 `/sk-api/vod/skjson?url=<id>&skjsonindex=0` -2. 如果返回 `data.url` 且为 `http` 链接,则返回: - - `parse=0` - - `jx=0` - - `url=<playUrl>` -3. 如果解析失败,则回退原始 `id` -4. 如果原始 `id` 命中 `iqiyi/qq/youku/mgtv/bilibili` 域名,则返回: - - `parse=0` - - `jx=1` - - `url=<id>` -5. 其他情况返回: - - `parse=0` - - `jx=0` - - `url=<id>` - -播放器请求头固定为移动 Safari UA,以保持与参考实现一致。 - -本次不实现基于 `/app/config` 中 `direct_json_link` 或 `direct_link` 的动态分支,`CONFIG_DATA` 仅作为初始化链路保留。 - -## 容错策略 - -- `_sk_decrypt` 只在响应带 `FROMSKZZJM` 前缀时尝试解密,否则按明文返回 -- `appipr.txt` 为空、解密失败、JSON 解析失败时,抛出明确异常给站点内部处理 -- `homeContent` 获取筛选时,单个分类失败仅跳过该分类筛选 -- 列表和搜索接口异常时返回空结果结构 -- 详情接口异常时返回空 `list` -- 播放解析异常时回退原始 `id` - -## 测试设计 - -新增 `tests/test_即看影视.py`,使用 `unittest` 和 `unittest.mock`,不访问真实网络。 - -首轮测试覆盖: - -1. 读取 `appipr.txt` 并解析真实 `API_HOST` -2. `_sk_decrypt` 对明文和加密前缀响应的处理 -3. `_ck_encrypt` 返回非空密文 -4. `homeContent` 正确输出分类和筛选 -5. `homeVideoContent` 返回推荐列表 -6. `categoryContent` 参数映射正确且不返回 `pagecount` -7. `searchContent` 返回标准分页结构 -8. `detailContent` 返回 `{"list": [vod]}` 或空列表 -9. `playerContent` 优先使用 `skjson` 结果 -10. `playerContent` 在 `skjson` 失败时回退原始链接,并对站外视频域名设置 `jx=1` - -测试顺序: - -- 先跑单模块测试 -- 再确认该模块无回归 - -## 风险与边界 - -- `appipr.txt` 返回的真实域名可能变化,测试中必须 mock,不依赖当前线上值 -- `/app/config` 当前在简化版里不驱动播放分支,后续如果站点改成强依赖配置,播放器逻辑需要补齐 -- 若上游详情字段不是标准 `vod` 结构,可能需要在实现阶段补一个轻量映射层,但范围仅限当前站点文件 diff --git a/py/docs/superpowers/specs/2026-04-23-kuwo-tingshu-spider-design.md b/py/docs/superpowers/specs/2026-04-23-kuwo-tingshu-spider-design.md deleted file mode 100644 index dcb6de5..0000000 --- a/py/docs/superpowers/specs/2026-04-23-kuwo-tingshu-spider-design.md +++ /dev/null @@ -1,389 +0,0 @@ -# 酷我听书 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的酷我听书爬虫,行为参考用户提供的 JS 版本,但交付物遵循现有仓库的单文件 Spider 结构和测试约定。 - -本次设计覆盖以下能力: - -- 首页分类与筛选 -- 首页推荐 -- 分类浏览 -- 搜索 -- 专辑详情 -- 章节播放 -- VIP 章节外部解析 - -## 范围 - -本次实现包含: - -- 新增独立脚本 `py/酷我听书.py` -- 新增独立测试 `py/tests/test_酷我听书.py` -- 固定站点分类、筛选项和默认排序 -- 首页按四个主分类聚合推荐 -- 分类接口支持子类型、权限、排序筛选 -- 详情页输出专辑元数据和章节列表 -- 免费章节走酷我官方播放接口 -- VIP 章节保留外部解析接口支持 -- 针对非标准搜索 JSON、空页翻页和付费判断补齐离线测试 - -本次实现不包含: - -- 修改 `base/` 公共层 -- 引入新的第三方依赖 -- 新增缓存层、持久化状态或代理层 -- 对公益解析接口做探活、限流或多地址切换 -- 真实联网集成测试 - -## 方案选择 - -采用“Python 原生适配,保留核心能力”的方案,而不是直接翻译参考 JS。 - -原因如下: - -- 当前仓库的交付物是 Python `Spider` 单文件,不是 Express 风格插件 -- 仓库约定中分类和搜索结果通常返回 `page/limit/total/list`,且多数模块不返回 `pagecount` -- 参考实现中的全局状态、路由入口和长播放 URL 更适合在 Python 中收敛成内部 helper -- 参考实现的核心能力仍可完整保留:固定分类、筛选、强制翻页、详情、免费播放、VIP 解析 - -## 模块边界 - -新增模块 `py/酷我听书.py` 仅负责该站点逻辑,不修改 `base/`。 - -模块对外实现以下接口: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `detailContent` -- `searchContent` -- `playerContent` - -模块内部使用 helper 收敛行为: - -- `_api_get` - - 访问主站 API `http://tingshu.kuwo.cn` -- `_search_get` - - 访问搜索接口 `http://search.kuwo.cn` -- `_normalize_vod` - - 统一列表项字段 -- `_format_play_count` - - 格式化播放量显示 -- `_is_paid_track` - - 判断章节是否为 VIP -- `_parse_search_payload` - - 兼容搜索接口返回的非标准 JSON -- `_fetch_category_page` - - 获取分类页原始数据 -- `_force_page_fetch` - - 处理空页补齐和本地 VIP 过滤 -- `_build_filters` - - 组装首页筛选结构 -- `_build_play_id` - - 构造短播放 ID -- `_resolve_free_play` - - 解析免费章节直链 -- `_resolve_vip_play` - - 解析 VIP 章节外部接口 - -## 站点配置 - -固定配置来源于参考实现,直接内置在模块常量中: - -- 站点名:`酷我听书` -- 主站:`http://tingshu.kuwo.cn` -- 搜索站:`http://search.kuwo.cn` -- 播放站:`http://mobi.kuwo.cn` -- VIP 解析接口:`https://music-api.gdstudio.xyz/api.php` -- 默认请求头: - - `User-Agent: kwplayer_ar_9.1.8.1_tvivo.apk` - -固定分类如下: - -- `2 -> 有声小说` -- `37 -> 音乐金曲` -- `5 -> 相声评书` -- `62 -> 影视原声` - -固定筛选项沿用参考实现: - -- 每个主分类带一个 `class` 子类型筛选 -- 所有分类追加统一的 `vip` 权限筛选 -- 所有分类追加统一的 `sort` 排序筛选 - -其中: - -- `vip` - - `全部权限` - - `免费权限` - - `会员权限` -- `sort` - - `综合排序` - - `最新上架` - - `按总播放` - -## 首页设计 - -`homeContent` 只返回静态分类和筛选定义,并附带首页推荐列表。 - -返回结构包含: - -- `class` -- `filters` -- `list` - -`homeVideoContent` 会按四个主分类分别请求默认子分类第一页,每类提取列表后聚合返回。默认子分类取该分类第一个筛选项的 `init` 值。 - -列表项统一映射为: - -- `vod_id` - - `albumId` -- `vod_name` - - `albumName` -- `vod_pic` - - `coverImg` -- `vod_remarks` - - `会员|免费 + 播放量 + 分类名或作者` - -播放量格式化规则: - -- `>= 1e8` 显示为 `x.x亿` -- `>= 1e4` 显示为 `x.x万` -- 其他按整数原样显示 - -## 分类与分页设计 - -`categoryContent` 支持主分类、页码和扩展筛选参数。 - -筛选参数映射如下: - -- `class` - - 子类型 ID,默认取当前分类的 `init` -- `vip` - - `""`、`0`、`1` -- `sort` - - `tsScore`、`pubDate`、`playCnt` - -返回遵循仓库现有习惯: - -- `page` -- `limit` -- `total` -- `list` - -默认不返回 `pagecount`。 - -上游分类接口可能出现中间页为空的情况,因此保留参考实现中的“强制翻页补齐”策略: - -- 用户请求第 `pg` 页时,从该页开始请求 -- 若当前页无数据,则继续尝试后续页 -- 最多尝试 `maxPage=10` 次 -- 一旦拿到有效数据,则将该页作为当前返回数据源 - -VIP 权限筛选在本地二次处理: - -- `vip=""` - - 不过滤 -- `vip="0"` - - 仅保留免费专辑 -- `vip="1"` - - 仅保留会员专辑 - -`total` 和 `limit` 处理原则: - -- `limit` 固定返回 `21` -- 若还有后续可翻页空间,`total` 返回一个足够大的估算值,以维持翻页体验 -- 若已无后续数据,则 `total` 退化为当前页结果长度 - -## 搜索设计 - -`searchContent` 访问 `search.kuwo.cn` 的专辑搜索接口,关键词参数对应参考实现的 `all`。 - -搜索接口的响应可能是单引号 JSON 或普通 JSON 字符串,因此需要容错解析: - -- 优先按字符串读取 -- 若存在单引号包裹的对象结构,先做最小修正再解析 -- 解析失败则返回空结果 - -搜索结果映射字段: - -- `vod_id` - - `DC_TARGETID` -- `vod_name` - - `name` -- `vod_pic` - - `img` -- `vod_remarks` - - `会员|免费 + artist` - -搜索返回结构: - -- `page` -- `limit` - - 固定 `21` -- `total` -- `list` - -搜索空关键词时直接返回空结果: - -- `{"page": 1, "limit": 0, "total": 0, "list": []}` - -## 详情设计 - -`detailContent` 根据专辑 ID 请求专辑详情接口,返回单个专辑对象。 - -详情字段映射: - -- `vod_id` - - 专辑 ID -- `vod_name` - - 根级 `name` -- `vod_pic` - - 根级 `img`,若为相对路径则补全为完整封面地址 -- `vod_remarks` - - `会员|免费 + 完结状态 + 集数 + 总播放量` -- `vod_content` - - `info`,回退 `title` -- `vod_actor` - - `artist` -- `vod_director` - - `company` -- `vod_year` - - 从 `pub` 提取年份,格式为 `2026年` -- `vod_area` - - `lang` -- `vod_lang` - - `lang` -- `vod_time` - - `pub` -- `vod_tag` - - `完结` 或 `连载` - -章节列表使用单线路输出: - -- `vod_play_from = "酷我听书"` -- `vod_play_url = 章节1$播放ID#章节2$播放ID...` - -章节名称规则: - -- 使用章节名 `track.name` -- 缺失时回退为 `第N集` -- VIP 章节前面加 `💎` - -### 播放 ID 设计 - -不直接把长播放 URL 写进详情,而是构造成短载荷: - -- 免费章节:`free|<musicrid>` -- VIP 章节:`vip|<musicrid>` - -这样可以把真实播放解析逻辑集中在 `playerContent`,降低 `vod_play_url` 的耦合度,也方便单测断言。 - -## 播放设计 - -`playerContent` 根据播放 ID 类型分两条链路处理。 - -### 免费章节 - -当播放 ID 形如 `free|musicrid` 时: - -- 请求 `http://mobi.kuwo.cn/mobi.s` -- 参数: - - `f=web` - - `type=convert_url_with_sign` - - `rid=<musicrid>` - - `br=320kmp3` -- 若接口返回 JSON 中存在最终音频 URL,则返回直链播放 - -输出结构: - -- `parse: 0` -- `url: <final url>` -- `header` - - `User-Agent` - - `Referer` - -### VIP 章节 - -当播放 ID 形如 `vip|musicrid` 时: - -- 请求外部解析接口 `https://music-api.gdstudio.xyz/api.php` -- 保留参考实现中的固定查询参数 -- 若返回 `url` 字段,则作为最终音频地址 - -输出结构: - -- `parse: 0` -- `url: <final url>` -- `header` - - `User-Agent: LX-Music-Mobile` - - `Referer: https://music-api.gdstudio.xyz` - -### 回退策略 - -若播放解析失败: - -- 免费章节返回一个基于 `musicrid` 拼装的官方播放请求地址作为兜底 `url` -- VIP 章节返回外部解析接口请求地址作为兜底 `url` -- 不抛出未处理异常 - -## 错误处理 - -整体遵循当前仓库的“失败返回空结果或兜底结果”策略。 - -- 首页、分类、搜索接口失败 - - 返回空列表结果 -- 详情接口失败或章节为空 - - 返回 `{"list": []}` -- 搜索 JSON 解析失败 - - 返回空结果,不中断流程 -- 播放解析失败 - - 返回兜底 `url`,保持 `playerContent` 可消费 - -所有网络请求都通过 `self.fetch` 实现,便于在单测中统一 mock。 - -## 测试设计 - -测试采用 `unittest + SourceFileLoader + unittest.mock`,全部离线执行,不依赖真实网络。 - -覆盖范围: - -- `homeContent` - - 固定分类、筛选结构、`vip/sort` 追加逻辑 -- `homeVideoContent` - - 四类首页推荐聚合、分类名写入 `vod_remarks` -- `categoryContent` - - 分类参数映射、空页后移、VIP 本地过滤、不返回 `pagecount` -- `searchContent` - - 非标准 JSON 解析、字段映射、空关键词返回空结果 -- `detailContent` - - 专辑元数据映射、封面补全、章节名和播放 ID 构造 -- `playerContent` - - 免费章节直链解析 - - VIP 章节外部解析 - - 失败回退路径 -- helper - - 播放量格式化 - - VIP 判断 - - 播放 ID 编解码 - -## 风险与约束 - -- 搜索接口返回结构不是严格标准 JSON,解析策略需要尽量保守,避免误替换内容 -- 公益解析接口是外部依赖,稳定性不可控,因此必须保留失败回退 -- 详情章节数可能很大,播放列表生成应保持简单,不在本次实现中引入额外排序和缓存 -- 站点是音频内容,不涉及仓库里常见的视频网盘线路,因此 `playerContent` 返回头信息时应按音频接口的需求最小化设置 - -## 交付物 - -- `py/酷我听书.py` -- `py/tests/test_酷我听书.py` - -本次设计已经明确: - -- 站点模块名使用 `酷我听书` -- VIP 外部解析接口默认保留并启用 -- 结果结构遵循当前 Python 仓库习惯,而不是直接复刻 JS 插件路由层行为 diff --git a/py/docs/superpowers/specs/2026-04-23-tengxun-spider-design.md b/py/docs/superpowers/specs/2026-04-23-tengxun-spider-design.md deleted file mode 100644 index 2eca2ed..0000000 --- a/py/docs/superpowers/specs/2026-04-23-tengxun-spider-design.md +++ /dev/null @@ -1,266 +0,0 @@ -# 腾讯视频 Python 蜘蛛设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的腾讯视频蜘蛛,覆盖以下能力: - -- 首页分类与推荐 -- 分类列表 -- 搜索 -- 详情解析 -- 播放透传 - -实现以用户提供的 Node/JS 版本为行为参考,但落地形式遵循当前仓库的单文件 Spider 约定。 - -## 范围 - -本次实现包含: - -- 新增独立脚本,文件名为 `腾讯视频.py` -- 首页使用腾讯频道页 HTML 抓取推荐卡片 -- 分类页使用腾讯频道页 HTML 抓取卡片并支持常见筛选参数 -- 详情页调用 `float_vinfo2` 与批量详情接口,组装单条播放线路 -- 搜索调用 `MultiTerminalSearch` -- 播放阶段只做原始链接透传 -- 为新增行为补齐 `unittest` - -本次实现不包含: - -- 参考代码中的 HTTP 路由导出层 -- 任何 parse 相关逻辑 -- 解析器线路生成 -- 花絮/预告拆分为独立线路 -- 公共 helper 抽象 - -## 方案选择 - -候选方案有三种: - -1. 单文件直译版 -2. 单文件加 `lxml` 解析列表页 -3. 站点文件加多个私有 helper 重构 - -选用方案 1,并保留少量私有 helper。 - -原因: - -- 与用户提供的参考实现最接近 -- 列表页测试夹具最容易构造 -- 不额外扩大实现范围 - -## 模块边界 - -新增脚本只在站点文件内部维护逻辑,不修改 `base/`。 - -Spider 对外方法: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `detailContent` -- `searchContent` -- `playerContent` - -站点内部私有 helper: - -- `_headers` - - 统一请求头 -- `_parse_list_items` - - 从频道页 HTML 中提取卡片 -- `_get_batch_video_info` - - 批量请求分集信息 -- `_safe_json` - - 解析 JSON 和 JSONP 载荷 - -## 请求与 ID 设计 - -固定主域: - -- `https://v.qq.com` - -固定请求头: - -- `User-Agent: PC_UA` - -`vod_id` 设计: - -- 首页直接使用卡片里的 `data-float` -- 分类页使用 `频道$sourceId` -- 搜索结果直接使用 `doc.id` - -详情阶段: - -- 如果传入的是 `频道$sourceId`,取 `$` 后面的值作为 `cid` -- 如果没有 `$`,直接把原值作为 `cid` - -## 首页与分类设计 - -### 首页 - -首页请求: - -- `/x/bu/pagesheet/list?_all=1&append=1&channel=cartoon&listpage=1&offset=0&pagesize=21&iarea=-1&sort=18` - -解析规则: - -- 用正则提取 `list_item` -- 取 `img alt` 作为 `vod_name` -- 取 `img src` 作为 `vod_pic` -- 取第一个链接文本作为 `vod_remarks` -- 取 `a[data-float]` 作为 `vod_id` - -首页只返回: - -- `class` -- `list` - -不返回 `filters`。 - -### 分类 - -分类请求基础路径: - -- `/x/bu/pagesheet/list` - -固定参数: - -- `_all=1` -- `append=1` -- `channel=<id>` -- `listpage=1` -- `offset=(page-1)*21` -- `pagesize=21` -- `iarea=-1` - -可选筛选参数: - -- `sort` -- `iyear` -- `year` -- `itype` -- `ifeature` -- `iarea` -- `itrailer` -- `gender` - -分类页返回结构遵循当前参考实现: - -- `list` -- `page` -- `pagecount` -- `limit` -- `total` - -## 详情设计 - -详情请求: - -- `https://node.video.qq.com/x/api/float_vinfo2?cid=<targetCid>` - -详情字段映射: - -- `vod_name <- json.c.title` -- `type_name <- json.typ` -- `vod_actor <- json.nam` -- `vod_year <- json.c.year` -- `vod_content <- json.c.description` -- `vod_remarks <- json.rec` -- `vod_pic <- json.c.pic` - -播放列表生成: - -1. 读取 `json.c.video_ids` -2. 调用批量接口: - - `https://union.video.qq.com/fcgi-bin/data?...&idlist=<vid1,vid2,...>` -3. 按原始 `video_ids` 顺序回填标题 -4. 正片与花絮/预告不拆线路,统一合并为一条 `腾讯视频` -5. 每个播放项格式为: - - `标题$https://v.qq.com/x/cover/<cid>/<vid>.html` - -最终详情仅保留一条线路: - -- `vod_play_from = "腾讯视频"` - -## 搜索设计 - -搜索请求: - -- `https://pbaccess.video.qq.com/trpc.videosearch.mobile_search.MultiTerminalSearch/MbSearch?vplatform=2` - -请求头: - -- 桌面浏览器 UA -- `Content-Type: application/json` -- `Origin: https://v.qq.com` -- `Referer: https://v.qq.com/` - -请求体字段沿用参考实现: - -- `version` -- `clientType` -- `query` -- `pagenum` -- `pagesize` -- `extraInfo` - -结果提取: - -- 同时遍历 `data.normalList.itemList` -- 同时遍历 `data.areaBoxList[].itemList` -- 只保留 `doc.id` 存在且长度大于 11 的结果 -- 标题去掉 `<em>` 标签 - -分页结构保持参考行为: - -- `list` -- `page` -- `pagecount` -- `limit` -- `total` - -## 播放设计 - -用户明确要求: - -- 不要 parse 相关内容 -- 不保留任何解析器线路 - -因此 `playerContent` 只做原始链接透传,固定返回: - -- `parse = 1` -- `jx = 1` -- `url = id` -- `header = {"User-Agent": "PC_UA"}` - -不做任何站内解析或解析器回退。 - -## 容错策略 - -- 首页和分类页 HTML 抓取失败时返回空列表 -- 批量详情接口单次失败时忽略该批次,保留已成功条目 -- 详情接口失败时跳过当前 `id` -- 搜索接口异常时返回空分页结构 -- 播放阶段不抛异常,直接透传原始 `id` - -## 测试设计 - -新增 `tests/test_腾讯视频.py`,使用 `unittest` 与 `unittest.mock`,不访问真实网络。 - -首轮测试覆盖: - -1. 首页 HTML 提取推荐卡片 -2. 首页返回固定分类 -3. 分类页正确拼接筛选参数 -4. 分类页返回 `频道$sourceId` 形式的 `vod_id` -5. `_get_batch_video_info` 解析 `QZOutputJson=` 响应 -6. 详情页把正片和花絮/预告合并为单条 `腾讯视频` 线路 -7. 搜索同时处理 `normalList` 与 `areaBoxList` -8. 播放透传返回 `parse=1/jx=1` - -## 风险与边界 - -- 列表页 HTML 结构若改动,正则提取会失效,但这与参考实现保持一致 -- 腾讯搜索返回结构可能包含多种卡片形态,首轮只覆盖参考代码已处理的两种容器 -- `PC_UA` 是否需要替换成真实 UA 可在实现阶段决定,但行为上保持统一 header 即可 diff --git a/py/docs/superpowers/specs/2026-04-24-dyrs-spider-design.md b/py/docs/superpowers/specs/2026-04-24-dyrs-spider-design.md deleted file mode 100644 index 09034a4..0000000 --- a/py/docs/superpowers/specs/2026-04-24-dyrs-spider-design.md +++ /dev/null @@ -1,172 +0,0 @@ -# 电影人生 Spider 设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 `电影人生` spider,参考用户提供的 JS 站点逻辑,实现首页、分类、搜索、详情和播放能力,目标站点为 `https://dyrsok.com`。 - -## 范围 - -本次实现包含: - -- 新增 `py/电影人生.py` -- 新增 `py/tests/test_电影人生.py` -- 固定 4 个主分类与静态筛选项 -- 首页推荐、分类页与搜索页卡片解析 -- 详情页元数据与多线路剧集解析 -- 播放页 `/api/m3u8` 提取、重定向跟随和主清单二次解析 - -本次实现不包含: - -- 修改 `py/base/` 共享基类 -- 接入 JS 版本中的 `OmniBox.log`、播放历史、媒体信息探测 -- 真实联网测试 -- 站外解析器或额外第三方线路适配 - -## 分类与筛选 - -固定返回以下分类: - -- `dianying` 电影 -- `dianshiju` 电视剧 -- `dongman` 动漫 -- `zongyi` 综艺 - -筛选结构采用静态定义,保持与参考脚本一致的语义: - -- `class` - - 不同主分类下返回预设类型值 -- `sort_field` - - 默认、热度、更新时间 - -Python 版本沿用仓库现有格式,即筛选值使用 `{"n": "...", "v": "..."}`。 - -## 接口映射 - -- `homeContent` - - 返回固定分类和筛选配置 - - 可附带首页推荐列表 -- `homeVideoContent` - - 请求首页 - - 复用卡片解析函数返回推荐列表 -- `categoryContent` - - 构造 `/{categoryId}.html`,并按筛选拼接查询参数 - - 解析卡片并返回分页字段 -- `detailContent` - - 请求详情页 - - 解析标题、封面、简介、年份、地区、语言、导演、主演、标签 - - 解析多线路及每条线路下的剧集 -- `searchContent` - - 构造 `/s.html?name=<keyword>` - - 解析卡片并执行结果精排 -- `playerContent` - - 输入为详情页剧集项里编码的播放上下文 - - 请求播放页并提取 `/api/m3u8?origin=...&url=...` - - 跟随 302 或 Location 跳转 - - 若主清单中存在 `raw=1` 子清单,则继续下钻到最终可播 m3u8 - - 失败时回退为 `parse=1` - -## 数据模型 - -### 列表卡片 - -列表、首页推荐和搜索统一复用卡片解析逻辑: - -- 从 `a[data-url][title]` 提取标题和详情链接 -- 仅保留命中 `/dyrscom-` 的详情链接 -- 图片优先取 `data-src`,其次 `src` -- 备注优先取角标文本 -- 补全相对链接与相对图片地址 - -`vod_id` 保留站内短路径,不返回绝对 URL。 - -### 详情 - -详情页字段按“DOM 优先、正则兜底”的策略提取: - -- 标题优先 `h1/h2`,再回退到 `title` -- 封面优先 `og:image`,再回退到详情主图 -- 简介优先“剧情简介”区域,回退到 `description` -- 年份、地区、语言、更新时间通过标签文本或正则抽取 -- 导演、主演、标签通过各自区块中的链接文本抽取 - -播放线路通过详情页上的 `origin` 链接展开: - -- 收集 `originTabs` 中的线路入口 -- 每条线路单独抓取页面 -- 在 `.seqlist` 中提取剧集 -- 剧集 ID 使用 JSON 串编码,至少携带: - - `title` - - `origin` - - `page` - - `vodName` - - `pic` - -最终输出为仓库当前通用格式: - -- `vod_play_from`: `$$$` 拼接线路名 -- `vod_play_url`: `$$$` 拼接每条线路的 `剧集名$playId` - -## 搜索精排 - -搜索结果在卡片解析后再做一次轻量精排,以贴近参考脚本行为: - -- 先标准化关键词和片名 -- 去除空白、标点与部分年份噪音 -- 完全匹配优先 -- 前缀匹配次之 -- 包含匹配再次之 -- 若所有项都未命中,则回退原始顺序 - -## 播放解析 - -播放解析只实现站内主链,不把 JS 版本中的外围运行时逻辑迁移进来。 - -处理顺序如下: - -1. 解析传入的 `playId` - - 若是 JSON,读取 `page/title/origin/vodName/pic` - - 若不是 JSON,则当作原始播放页地址 -2. 请求播放页 HTML -3. 用正则提取 `/api/m3u8?origin=...&url=...` -4. 请求该接口并检查响应头里的 `Location` - - 有跳转时以跳转后的地址为候选结果 -5. 若跳转结果是 m3u8 主清单,则读取文本 - - 若存在 `/api/m3u8?id=...raw=1` 行,则拼成绝对地址并作为最终结果 -6. 若任一步失败,则返回 `parse=1`,由外部继续解析 - -播放器返回头至少包含: - -- `User-Agent` -- `Referer` -- 必要时补 `Origin` - -## 错误处理 - -- HTML 请求失败时返回空字符串,不向上抛站点异常 -- 详情页解析失败时返回空列表 -- 分类、搜索失败时返回空列表和基本分页字段 -- 播放解析失败时回退到播放页 URL 或空地址,不报未处理异常 -- 所有 helper 对缺失字段、坏 JSON、空节点、无匹配正则保持容错 - -## 测试策略 - -使用 `unittest` 与 `unittest.mock`,不走真网。 - -至少覆盖: - -- `homeContent` 返回 4 个分类和对应 filters -- 卡片解析正确提取 `vod_id`、标题、图片、备注 -- `categoryContent` 正确拼装 URL 与查询参数 -- `detailContent` 正确提取元数据并组装多线路剧集 -- `searchContent` 调用搜索 URL 并执行精排 -- `playerContent` 成功提取 `/api/m3u8` -- `playerContent` 能处理 302 跳转 -- `playerContent` 能从主清单中继续提取 `raw=1` 子清单 -- 无法提取直链时回退 `parse=1` - -## 验收标准 - -- 新增 spider 与测试文件后,可独立运行 `tests.test_电影人生` -- 首页、分类、搜索、详情在离线夹具下输出稳定 -- 播放支持“接口提取 -> 跳转 -> 主清单下钻 -> 失败回退”四条路径 -- 不修改共享基类即可完成接入 diff --git a/py/docs/superpowers/specs/2026-04-24-fktv-spider-design.md b/py/docs/superpowers/specs/2026-04-24-fktv-spider-design.md deleted file mode 100644 index d0e2244..0000000 --- a/py/docs/superpowers/specs/2026-04-24-fktv-spider-design.md +++ /dev/null @@ -1,292 +0,0 @@ -# FKTV Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的凡客TV爬虫,行为参考用户提供的 FKTV JS 版本,但实现形式遵循仓库现有的单文件 Spider 约定。 - -本次实现范围: - -- 首页分类 -- 分类浏览 -- 搜索 -- 详情解析 -- 多线路剧集组装 -- 播放接口直链优先 -- 接口失败时回退详情页 - -本次不实现: - -- 弹幕 -- 页面嗅探 -- 本机 `curl` 回退 -- 验证码绕过 - -## 方案选择 - -采用“详情页解析状态,播放时调用官方切换接口”的方案,而不是只依赖详情页内嵌 `play_links`。 - -原因如下: - -- FKTV 的剧集和线路是两套独立状态,详情页内嵌 `play_links` 可能只对应当前默认剧集 -- 如果只复用默认 `play_links`,用户切换到别的剧集时容易串集 -- 参考实现的核心可靠性来自 `link_id + line_id` 组合后再请求官方播放接口 -- 在当前 Python 仓库里保留接口直链优先、失败回退页面,已经覆盖核心需求,不必移植 OmniBox 专属能力 - -## 模块边界 - -新增 `py/凡客TV.py`,不修改 `py/base/`。 - -模块对外实现: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `searchContent` -- `detailContent` -- `playerContent` - -模块内部 helper 负责: - -- 请求头与 cookie 管理 -- HTML / JSON 请求 -- 列表卡片解析 -- 详情页状态脚本解析 -- 线路 tab 提取 -- `play_id` 编解码 -- 播放接口请求与结果归一化 -- 直链与回退结果构造 - -## 站点策略 - -固定站点参数: - -- host:`https://fktv.me` -- 固定桌面端 `User-Agent` -- 固定 cookie:`_did=57nTmEknMZ146xw4KXGHDCHk1MjshRyY` - -请求策略: - -- 列表、搜索、详情页使用普通 GET -- 播放切换接口使用 POST -- 播放接口请求头必须包含: - - `User-Agent` - - `Referer` - - `Origin` - - `X-Requested-With` - - `Content-Type: application/x-www-form-urlencoded; charset=UTF-8` - - 固定 cookie - -## 分类与首页 - -`homeContent` 返回固定分类: - -- `1` 电影 -- `2` 剧集 -- `4` 动漫 -- `3` 综艺 -- `8` 短剧 -- `6` 纪录片 -- `7` 解说 -- `5` 音乐 - -`homeVideoContent` 不单独抓推荐,返回空列表,和仓库中多个站点保持一致。 - -分类 URL 固定为: - -- `/channel?page={page}&cat_id={type_id}&page_size=32&order=new` - -分类页解析规则: - -- 优先从 `.meta-wrap` 所在卡片提取数据 -- 兜底解析 `.hover-wrap` -- 提取详情链接中的 `/movie/detail/{id}` -- 提取标题、封面、标签和备注 -- `vod_id` 保留短 ID,不返回完整 URL - -返回结构遵循仓库当前约定: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -## 搜索 - -搜索 URL 固定为: - -- `/search?keyword=<urlencoded keyword>` - -搜索页解析复用分类卡片逻辑: - -- 提取详情短 ID、标题、封面、标签 -- 去重后返回 -- 关键字为空时直接返回空列表 - -搜索返回: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -## 详情解析 - -详情页 URL 固定为: - -- `/movie/detail/{vod_id}` - -详情页需要同时解析 HTML 可见信息和页面脚本状态。 - -基础元信息: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_content` -- `vod_remarks` - -页面脚本状态: - -- `movieId` -- `linkId` -- `links` -- `play_links` -- `play_error_type` - -其中: - -- `links` 代表剧集列表,是详情页组装剧集的主数据源 -- `play_links` 仅用于诊断和线路兜底,不作为目标剧集的真值来源 - -线路提取规则: - -- 优先从带 `data-line` 的线路 tab 提取线路 ID 和线路名 -- 如果页面没有线路 tab,则退化使用 `play_links` 中的线路 ID 和名称 -- 如果两者都没有,则详情页返回空播放字段 - -剧集提取规则: - -- 逐个读取 `links` 中的剧集项 -- 每个剧集至少提取 `id` 和展示名 -- 如果没有名称,则按 `name`、`title`、`id` 顺序兜底 - -详情页播放字段组装规则: - -- `vod_play_from` 为线路名称列表,使用 `$$$` 拼接 -- `vod_play_url` 为每条线路下的剧集列表,使用 `#` 拼接单集、`$$$` 拼接线路 -- 每个单集条目格式为 `剧集名$play_id` - -这里会为每条线路复用同一批剧集。原因是 FKTV 的线路和剧集是分离状态: - -- 剧集由 `links` 定义 -- 线路由线路 tab 或 `play_links` 定义 -- 真正播放地址必须在 `playerContent` 中通过 `link_id + line_id` 实时换取 - -`vod_remarks` 只保留少量站点状态提示: - -- `play_error_type == captcha` 时提示站点需要验证码 -- `play_error_type == need_vip` 时提示站点存在 VIP 限制 - -## Play ID 设计 - -`play_id` 不直接保存页面 URL,而是编码为一个紧凑 JSON 字符串,至少包含: - -- `movie_id` -- `link_id` -- `line_id` -- `line_name` -- `episode_name` -- `type` -- `page` - -设计目标: - -- 播放时能准确定位目标剧集和目标线路 -- 即使详情阶段只拿到短 ID,也能回退到详情页 -- 在测试里可以稳定断言 `play_id` 的结构和往返解码行为 - -## 播放解析 - -`playerContent` 分三层处理。 - -第一层,直接透传: - -- 如果传入就是 `.m3u8`、`.mp4`、`.flv` 等明显媒体直链,直接返回 `parse=0` - -第二层,播放接口直链优先: - -- 解码 `play_id` -- 回读详情页,重新获取当前 `movieId`、默认 `linkId` 和页面状态 -- 使用 `play_id` 中的 `link_id` 作为目标剧集,缺失时回退页面默认 `linkId` -- POST `https://fktv.me/movie/detail/{movie_id}` -- 表单固定为 `link_id={link_id}&is_switch=1` -- 从响应 JSON 的 `data.play_links` 中提取各线路直链 -- 如果指定了 `line_id`,则只返回对应线路 -- 如果没有指定 `line_id`,则返回该剧集下接口给出的全部线路 - -第三层,失败回退: - -- 接口没有可播地址 -- 接口返回 `need_vip` -- `play_id` 非法但还能推导出详情页 -- 详情页能打开但无法换取直链 - -这些场景统一返回 `parse=1`,回退到详情页地址。 - -## 播放结果规范 - -直链结果: - -- `parse=0` -- `playUrl=""` -- `url` 为首个可播地址 -- `header` 至少包含: - - `User-Agent` - - `Referer` - - `Origin` - -当接口返回多线路时: - -- 额外返回 `urls` -- 每项格式为 `{"name": "<线路名>", "url": "<直链>"}` - -回退结果: - -- `parse=1` -- `playUrl=""` -- `url` 为详情页地址 -- `header` 带页面请求所需的 `User-Agent` 和 `Referer` - -不实现页面嗅探,不返回 OmniBox 风格的额外字段。 - -## 测试策略 - -新增 `py/tests/test_凡客TV.py`,全部使用离线 HTML / JSON fixture 和 mock,不做真实联网。 - -重点覆盖: - -- `homeContent` 返回固定分类 -- `categoryContent` 正确构造 `/channel` URL 并解析卡片 -- `searchContent` 正确构造 `/search` URL,空关键字直接返回空列表 -- 详情页状态解析:`movieId`、`linkId`、`links`、`play_links`、`play_error_type` -- 线路 tab 提取和剧集名称兜底 -- `detailContent` 组装 `vod_play_from` / `vod_play_url` -- `play_id` 编解码往返一致 -- `playerContent` 对直链直接返回 -- `playerContent` 请求官方切换接口并按 `line_id` 过滤 -- `playerContent` 在线路未指定时返回全部接口线路 -- `playerContent` 在 `need_vip`、空结果、非法标识等场景下回退详情页 - -## 风险与边界 - -- FKTV 详情页里的脚本状态依赖站点当前模板,本次只兼容参考结构 -- 固定 cookie 未来可能失效,失效后播放接口可能退化为页面回退 -- 如果站点把线路 tab 或脚本变量名称整体改版,详情组装和播放接口都需要同步调整 -- 不实现验证码处理,遇到 `captcha` 只给出提示并回退页面 -- 不做 `curl` 回退和页面嗅探,意味着接口彻底失效时只能降级到页面解析 diff --git a/py/docs/superpowers/specs/2026-04-24-jinpai-spider-design.md b/py/docs/superpowers/specs/2026-04-24-jinpai-spider-design.md deleted file mode 100644 index de80b99..0000000 --- a/py/docs/superpowers/specs/2026-04-24-jinpai-spider-design.md +++ /dev/null @@ -1,342 +0,0 @@ -# 金牌 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的金牌爬虫,行为参考用户提供的 JS 版本,覆盖以下能力: - -- 首页分类与筛选 -- 首页推荐 -- 分类浏览 -- 搜索 -- 详情解析 -- 播放解析 - -实现形式遵循当前仓库的单文件 Spider 约定和离线单测约定,不引入新的公共基类。 - -## 范围 - -本次实现包含: - -- 新增独立脚本 `py/金牌.py` -- 新增独立测试 `py/tests/test_金牌.py` -- 实现带签名头的匿名 API 请求 -- 动态拉取分类与筛选 -- 首页返回热门推荐 -- 分类支持类型、剧情、地区、语言、年份、排序筛选 -- 搜索支持分页与空关键词保护 -- 详情组装基础元数据与单线路剧集 -- 播放接口返回多清晰度直链列表 - -本次实现不包含: - -- 修改 `base/` 公共层 -- 引入新的第三方依赖 -- 真实联网集成测试 -- 站点探活、容灾切换或缓存 -- 参考 JS 的 HTTP 路由包装层 - -## 方案选择 - -采用“单文件 Spider + helper + 单测”的直接适配方案,而不是抽象新的签名 API 基类。 - -原因如下: - -- 当前仓库绝大多数站点都以单文件 Spider 交付,新增公共基类会放大本次范围 -- 参考实现已经清晰给出了接口路径、签名算法和字段映射,直接适配风险最低 -- 当前需求重点是完整还原站点能力,而不是为未知后续站点提前抽象 - -## 模块边界 - -新增 `py/金牌.py`,只在模块内部维护站点逻辑,不修改 `base/`。 - -对外实现以下接口: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `searchContent` -- `detailContent` -- `playerContent` - -模块内部拆分以下 helper: - -- `_obj_to_form` - - 将请求参数按 `k=v&...` 拼接,忽略空值 -- `_signed_headers` - - 根据请求参数、`APP_KEY` 和时间戳生成签名头 -- `_fetch_json` - - 统一发起 GET 请求、校验状态码并解析 JSON -- `_map_vod` - - 统一列表项字段映射 -- `_build_filters` - - 请求分类接口与筛选接口并组装首页筛选结构 -- `_page_result` - - 统一分类和搜索的分页返回结构 -- `_build_play_id` - - 将主视频 ID 与分集 ID 组合成短播放 ID -- `_parse_play_id` - - 解析 `主ID@子ID` 形式的播放 ID - -## 站点配置与签名策略 - -固定配置来自参考实现: - -- 站点名:`金牌` -- 主站:`https://m.jiabaide.cn` -- 默认 UA:移动端 Chrome UA -- `Referer`:`${host}/` -- `APP_KEY`:`cb808529bae6b6be45ecfab29a4889bc` - -签名规则保持与参考实现一致: - -1. 取当前毫秒时间戳作为 `t` -2. 将请求参数与 `key=APP_KEY`、`t` 合并 -3. 按 `k=v&...` 形式拼接 -4. 先对拼接字符串做 `MD5` -5. 再对 `MD5` 结果做 `SHA1` -6. 将 `t` 和 `sign` 放入请求头 - -请求策略: - -- 接口请求统一使用 `GET` -- 参数通过 query string 传递 -- 请求头统一带 `User-Agent`、`Referer`、`t`、`sign` -- 若响应状态码不是 `2xx`,或业务 `code` 不是成功值,则返回空结果而非抛出未处理异常 - -## 分类与首页设计 - -`homeContent` 负责同时返回分类、筛选和首页推荐。 - -分类来源: - -- `/api/mw-movie/anonymous/get/filer/type` - -筛选来源: - -- `/api/mw-movie/anonymous/v1/get/filer/list` - -首页推荐来源: - -- `/api/mw-movie/anonymous/home/hotSearch` - -筛选字段映射遵循参考实现: - -- `typeList -> key=type, name=类型` -- `plotList -> key=class, name=剧情` -- `districtList -> key=area, name=地区` -- `languageList -> key=lang, name=语言` -- `yearList -> key=year, name=年份` -- `serialList -> key=by, name=排序` - -排序选项固定为: - -- 最近更新 -> `1` -- 添加时间 -> `2` -- 人气高低 -> `3` -- 评分高低 -> `4` - -每个筛选项都默认插入“全部”。 - -`homeVideoContent` 保持仓库现有习惯,返回: - -- `{"list": []}` - -## 列表与搜索设计 - -分类接口使用: - -- `/api/mw-movie/anonymous/video/list` - -请求参数如下: - -- `type1` - - 当前分类 ID -- `pageNum` - - 当前页码 -- `pageSize` - - 固定 `30` -- `sort` - - 由筛选项 `by` 映射,默认 `1` -- `sortBy` - - 固定 `1` -- `type` - - 子类型筛选 -- `v_class` - - 剧情筛选 -- `area` - - 地区筛选 -- `lang` - - 语言筛选 -- `year` - - 年份筛选 - -搜索接口使用: - -- `/api/mw-movie/anonymous/video/searchByWordPageable` - -请求参数如下: - -- `keyword` -- `pageNum` -- `pageSize` - -返回结构遵循当前仓库约定: - -- `page` -- `limit` -- `total` -- `list` - -其中: - -- `limit` 固定为 `30` -- 默认不返回 `pagecount` -- 空关键词搜索直接返回空列表 - -## 列表字段映射 - -列表页与搜索页的单项统一映射为: - -- `vod_id` - - `vodId` -- `vod_name` - - `vodName` -- `vod_pic` - - `vodPic` -- `vod_remarks` - - `vodRemarks` 与 `vodDoubanScore` 以 `_` 连接,空值自动忽略 -- `vod_year` - - 优先从 `vodPubdate` 提取年份 -- `type_id` - - `typeId` -- `type_name` - - `typeName` - -这样首页推荐、分类和搜索的列表结构保持一致。 - -## 详情设计 - -详情接口使用: - -- `/api/mw-movie/anonymous/video/detail` - -请求参数: - -- `id` - -详情返回只保留当前仓库需要的核心字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `type_name` -- `vod_remarks` -- `vod_year` -- `vod_area` -- `vod_lang` -- `vod_director` -- `vod_actor` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -剧集列表来自 `episodeList`,每一集的播放 ID 设计为: - -- `<vodId>@<nid>` - -播放线路先收敛为单条: - -- `金牌线路` - -这样可以兼容仓库现有以 `vod_play_from` / `vod_play_url` 为核心的详情结构,同时避免把上游清晰度层级提前展开到详情页。 - -## 播放设计 - -播放接口使用: - -- `/api/mw-movie/anonymous/v2/video/episode/url` - -请求参数如下: - -- `clientType=3` -- `id` - - 主视频 ID -- `nid` - - 分集 ID - -播放页逻辑: - -- 从 `主ID@子ID` 解析出 `id` 和 `nid` -- 请求上游接口拿到 `list` -- 将上游返回的多清晰度地址整理为播放器可消费的结果 - -播放器返回结构: - -- `parse: 0` -- `playUrl: ""` -- `url` - - 优先返回首个可用地址 -- `header` - - 至少包含移动端 `User-Agent` - -不额外引入 `pagecount`、`urls` 或其他参考 JS 的扩展字段,优先保持与当前 Python 仓库播放器返回习惯一致。 - -同时,为了兼容仓库中部分站点会保留扩展信息,模块内部仍会保留原始清晰度列表的整理逻辑;若调用方仅消费 `url`,则使用首个可用地址即可。 - -异常策略: - -- 播放 ID 不是 `主ID@子ID` 格式时,返回空 URL -- 上游返回空列表时,返回空 URL - -## 错误处理 - -所有公开接口都以“尽量返回空结果”作为失败策略: - -- `homeContent` - - 失败时返回空分类、空筛选、空推荐 -- `categoryContent` - - 失败时返回 `page/limit/total/list` 的空结构 -- `searchContent` - - 失败时返回空分页结果 -- `detailContent` - - 失败时返回 `{"list": []}` -- `playerContent` - - 失败时返回空 URL - -不向上抛出未处理异常,保持与现有仓库 Spider 的容错风格一致。 - -## 测试设计 - -新增 `py/tests/test_金牌.py`,全部通过 mock 网络层验证离线行为。 - -至少覆盖以下测试: - -1. `_signed_headers` 会写入 `t` 和正确的 `sign` -2. `homeContent` 能组合分类、筛选和首页推荐 -3. `categoryContent` 能把 `by/class/area/lang/year/type` 正确映射到请求参数 -4. `searchContent` 对空关键词直接返回空列表,对正常结果正确映射 -5. `detailContent` 能输出基础元数据、单线路名与 `主ID@子ID` 剧集串 -6. `playerContent` 能拆分播放 ID、请求上游并返回首个可用播放地址 -7. 公开接口在上游异常或空数据时返回约定的空结构 - -测试原则: - -- 不依赖真实网络 -- 尽量验证最小行为单元 -- 对签名和请求参数做精确断言,避免只测结果不测协议 - -## 风险与约束 - -主要风险如下: - -- 上游匿名接口的业务 `code` 语义可能并不完全稳定,需要测试中明确覆盖成功与失败分支 -- 参考 JS 的 `play` 返回了多清晰度数组,而仓库 Python Spider 通常以单个 `url` 为主,需要在实现中做兼容收敛 -- 详情中的 `vodClass`、`vodYear`、`vodArea` 等字段可能出现缺失,需要统一空值兜底 - -对应约束如下: - -- 优先保证仓库消费兼容性,而不是 1:1 复刻 JS 返回结构 -- 不额外改动公共播放器约定 -- 所有行为变化都必须由离线测试先定义再实现 diff --git a/py/docs/superpowers/specs/2026-04-24-lumman-spider-design.md b/py/docs/superpowers/specs/2026-04-24-lumman-spider-design.md deleted file mode 100644 index 5a6946e..0000000 --- a/py/docs/superpowers/specs/2026-04-24-lumman-spider-design.md +++ /dev/null @@ -1,163 +0,0 @@ -# 路漫漫 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 `路漫漫` 爬虫,参考用户提供的 JS 站点逻辑,实现首页、分类、搜索、详情和播放能力,并将站点能力收口为动漫与动画电影分类。 - -## 范围 - -本次实现包含: - -- 新增 `py/路漫漫.py` -- 新增 `py/tests/test_路漫漫.py` -- 固定 6 个动漫/动画电影分类 -- 首页推荐、分类、搜索卡片解析 -- 详情元数据与多线路播放列表解析 -- 播放页的基础解码、主解析链与失败回退 - -本次实现不包含: - -- 动态扩展站点其他分类 -- 修改 `py/base/` 公共层 -- 真实联网测试 -- 为未知播放器分支补齐所有站外解析逻辑 - -## 分类范围 - -固定返回以下分类,与参考脚本一致: - -- `6` 日本动漫 -- `7` 国产动漫 -- `8` 欧美动漫 -- `3` 日本动画电影 -- `4` 国产动画电影 -- `5` 欧美动画电影 - -## 方案 - -采用“单 Spider 文件 + 站点内解析 helper + 离线 fixture 测试”的方案,而不是直接移植 Node.js 服务端入口。 - -原因: - -- 仓库交付格式是 Python 单文件蜘蛛 -- 当前测试体系要求 `unittest + mock` -- 列表、详情与播放页逻辑可以映射到 Spider 接口 -- 参考脚本的播放解析链较长,Python 版本需要保留核心能力并允许安全回退 - -## 接口映射 - -- `homeContent` - - 返回固定 6 个分类 -- `homeVideoContent` - - 请求首页 - - 解析 `.video-img-box` 卡片 - - 去重后返回前若干推荐 -- `categoryContent` - - 构造 `/vod/show/id/<tid>/page/<pg>.html` - - 预留 `extend` 的 `年代`、`排序` 片段拼接能力 - - 解析卡片列表 -- `searchContent` - - 构造 `/vod/search/page/<pg>/wd/<keyword>.html` - - 解析搜索卡片 -- `detailContent` - - 请求详情页 - - 解析标题、封面、简介、附加信息 - - 解析 tab 与播放列表,输出 `vod_play_from` / `vod_play_url` -- `playerContent` - - 先识别直接媒体链接 - - 再解析播放页中的 `player_*` 数据 - - 支持 `encrypt=1/2` 解码 - - 命中主解析链时继续请求播放器 JS 与中间页换取直链 - - 失败时回退 `parse=1` - -## ID 策略 - -- 详情 `vod_id` 保留站内短路径,例如 `vod/detail/123.html` 或站点原始相对路径的紧凑形式 -- 播放条目中的 episode id 保留站内短路径或站内播放页相对路径 -- Spider 内部统一提供 helper,将短路径恢复为绝对 URL - -约束: - -- 不把完整绝对 URL 作为列表页默认 `vod_id` -- 仅在播放器解析流程内部使用绝对 URL - -## 解析细节 - -### 列表与搜索 - -- 复用 `.video-img-box` 卡片结构 -- 提取标题、图片、备注、链接 -- 图片统一补全为绝对 URL -- 过滤空标题和空链接项目 - -### 详情页 - -- 标题取 `.page-title` -- 简介取 `.video-info-content` -- 封面优先取 `.module-item-pic` 下懒加载图片 -- 备注整合 `.video-info-items` -- 线路优先按 tab 顺序与对应播放列表容器配对 -- 如果缺少 tab,则直接扫描 `.module-player-list` -- 如果仍无播放数据,则允许返回空播放线路,不强行伪造可播集 - -### 播放页 - -分三层处理: - -1. 直接链接 - - URL 已经是 `m3u8/mp4/flv` 等媒体地址时直接返回 `parse=0` -2. 站内主解析链 - - 提取 `player_* = {...}` - - 读取 `url/from/encrypt` - - 处理 URL 编码和 base64 编码 - - 如果结果已经是媒体直链则直接返回 - - 否则继续拉取 `/static/player/<from>.js` - - 根据脚本中的 `src` 规则请求中间页 - - 若命中带 `vid/t/token/act/play` 的 POST 流程,则按参考脚本执行 AES 解密 token 后换取真实 URL - - 若命中另一种 POST body 结构,则按页面中拼接出的参数请求接口 -3. 失败回退 - - 无法提取 `player_*` - - 中间页结构不符合预期 - - 接口返回空 URL - - 以上场景统一回退 `parse=1` - -## AES 解密策略 - -参考脚本中的固定 key/iv: - -- key: `ejjooopppqqqrwww` -- iv: `1348987635684651` - -Python 版本使用本仓库可用的加密依赖实现 AES-CBC-PKCS7 解密;如果运行环境缺少该依赖,则实现应当保证安全失败并回退解析,而不是抛出未处理异常。 - -## 错误处理 - -- 任一网络请求失败时返回空 HTML 或解析回退,不向上抛出站点异常 -- 详情页解析失败时返回空列表 -- 播放解析失败时返回 `{"parse": 1, "url": 原始播放页}`,并带最小必要请求头 -- 所有 helper 对缺失字段、无效 JSON、空 XPath 结果保持容错 - -## 测试策略 - -使用 `unittest` 和内嵌 fixture,不走真网。 - -至少覆盖: - -- 固定 6 分类输出 -- 首页推荐卡片解析与去重 -- 分类 URL 拼接与卡片解析 -- 搜索 URL 拼接与结果解析 -- 详情页基础元数据解析 -- 详情页多线路播放列表组装 -- 播放页 `encrypt=1` URL 解码 -- 播放页 `encrypt=2` base64 解码 -- 直接媒体链接返回 `parse=0` -- 缺失播放器数据时回退 `parse=1` -- AES token 解密 helper 的成功与失败分支 - -## 验收标准 - -- `homeContent` 仅返回 6 个动漫相关分类 -- 列表、搜索、详情在离线 HTML 夹具下稳定输出预期字段 -- `playerContent` 至少覆盖“直链返回、`player_*` 解码、主解析链成功、失败回退”四条路径 -- 新增模块测试可独立运行通过 diff --git a/py/docs/superpowers/specs/2026-04-24-nsvod-spider-design.md b/py/docs/superpowers/specs/2026-04-24-nsvod-spider-design.md deleted file mode 100644 index b951792..0000000 --- a/py/docs/superpowers/specs/2026-04-24-nsvod-spider-design.md +++ /dev/null @@ -1,287 +0,0 @@ -# 耐视点播 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的耐视点播爬虫,行为参考用户提供的 JS 版本,覆盖以下能力: - -- 首页分类 -- 首页推荐 -- 分类浏览 -- 搜索 -- 详情解析 -- 播放解析 - -实现形式遵循当前仓库的单文件 Spider 约定和离线单测约定,不引入新的公共基类或第三方依赖。 - -## 范围 - -本次实现包含: - -- 新增独立脚本 `py/耐视点播.py` -- 新增独立测试 `py/tests/test_耐视点播.py` -- 固定输出参考脚本中的分类 `1/2/3/4/37/40` -- 解析首页与分类页的视频卡片 -- 搜索页关键词检索 -- 详情页基础元数据与多线路剧集组装 -- 播放页 `player_aaaa`、直链 `m3u8` 与播放页回退 - -本次实现不包含: - -- 修改 `base/` 公共层 -- 引入新的站点模板基类 -- 真实联网集成测试 -- Cloudflare、验证码或 JS 执行型反爬 -- 对参考 JS 运行时接口的直接兼容层 - -## 方案选择 - -采用“单文件 Spider + HTML helper + 单测”的直接适配方案,而不是先抽象一个通用 MacCMS 基类。 - -原因如下: - -- 当前仓库同类 HTML 站点多为单文件实现,直接适配更符合现有形态 -- 参考脚本已经给出稳定页面路径和字段来源,没有必要扩大到通用抽象 -- 当前任务的主要风险在页面结构解析,不在复用层设计 - -## 模块边界 - -新增 `py/耐视点播.py`,只在模块内部维护站点逻辑,不修改 `base/`。 - -对外实现以下接口: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `searchContent` -- `detailContent` -- `playerContent` - -模块内部拆分以下 helper: - -- `_stringify` - - 统一空值转字符串 -- `_clean_text` - - 清理空白、` ` 与残余 HTML 文本 -- `_build_url` - - 将相对路径转换为完整 URL -- `_extract_vod_id` - - 从详情地址中抽取短 `vod_id` -- `_build_detail_url` - - 将短 `vod_id` 还原为详情页 URL -- `_request_html` - - 统一发起站点请求、合并请求头、校验状态码 -- `_parse_cards` - - 解析首页/分类页卡片列表 -- `_extract_section_cards` - - 在首页 HTML 中按“最新电影/最新连续剧”等区块回退提取分类结果 -- `_parse_search_cards` - - 解析搜索结果 -- `_parse_play_groups` - - 解析详情页线路 tab 与对应剧集链接 -- `_parse_detail_page` - - 解析详情基础字段并组装 `vod_play_from` / `vod_play_url` -- `_extract_player_data` - - 提取 `player_aaaa` JSON -- `_pick_direct_media_url` - - 提取页面中的直链媒体地址 - -## 站点配置 - -固定配置来自参考实现: - -- 站点名:`耐视点播` -- 主站:`https://nsvod.me` -- 默认 UA:桌面 Chrome UA -- 默认 `Referer`:`https://nsvod.me/` - -分类固定为: - -- `1` 电影 -- `2` 连续剧 -- `3` 综艺 -- `4` 动漫 -- `37` Netflix -- `40` 纪录片 - -## 首页与分类设计 - -`homeContent` 仅返回固定分类: - -- `{"class": [...]}` - -`homeVideoContent` 请求首页 `/`,解析通用卡片列表并返回: - -- `{"list": [...]}` - -卡片字段统一映射为: - -- `vod_id` - - 从 `/index.php/vod/detail/id/<id>.html` 中提取 `<id>` -- `vod_name` - - 卡片标题或图片 `alt/title` -- `vod_pic` - - `data-src` 优先,缺失时退回 `src` -- `vod_remarks` - - 卡片角标、更新信息或副标题 - -`categoryContent` 优先请求: - -- `/index.php/vod/show/id/<tid>.html?page=<pg>` - -如果分类页未解析出结果,则回退请求首页并根据以下区块标题抓取: - -- `1 -> 最新电影` -- `2 -> 最新连续剧` -- `3 -> 最新综艺` -- `4 -> 最新动漫` -- `37 -> 最新Netflix` -- `40 -> 最新纪录片` - -返回结构遵循当前仓库约定: - -- `page` -- `limit` -- `total` -- `list` - -其中: - -- 不返回 `pagecount` -- `limit` 为当前页结果数 -- `total` 使用 `page * 20 + len(list)` 这一仓库内常见保守估算 - -## 搜索设计 - -搜索接口使用: - -- `/index.php/vod/search.html?wd=<keyword>` - -搜索结果优先解析 `module-search-item` 或详情链接卡片。 - -搜索返回结构为: - -- `page` -- `total` -- `list` - -空关键词直接返回空列表,不发请求。 - -## 详情设计 - -详情页 URL 使用: - -- `/index.php/vod/detail/id/<vod_id>.html` - -详情解析保留当前仓库需要的核心字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_year` -- `vod_area` -- `vod_director` -- `vod_actor` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -字段来源与解析策略: - -- `vod_name` - - 优先从标题或详情主标题提取 -- `vod_pic` - - 从详情海报图提取 `data-src/src` -- `vod_year` - - 从“年份”字段或文本中的四位年份提取 -- `vod_area` - - 从“地区”字段提取 -- `vod_director` - - 从“导演”字段提取,值为“未知”时置空 -- `vod_actor` - - 从“主演”字段提取,值为“未知”时置空 -- `vod_content` - - 从简介容器提取纯文本,缺失时返回 `暂无简介` - -播放线路解析策略: - -- 先读取 `.anthology-tab .swiper-slide` 作为线路名 -- 再读取 `.anthology-list .anthology-list-box` 内的播放链接 -- 每条线路的剧集列表按页面顺序保留 -- 若多线路容器不存在,则退化为扫描整个页面中的播放链接并生成单线路 -- 线路名缺失时默认 `线路1`、`线路2` - -`vod_play_from` 使用 `$$$` 连接线路名。 - -`vod_play_url` 中每条剧集按 `剧集名$播放ID` 编码,线路间使用 `$$$` 连接,线路内使用 `#` 连接。 - -其中 `播放ID` 保持站内相对路径短格式: - -- `/index.php/vod/play/id/...` - -## 播放设计 - -`playerContent` 接收站内播放路径或完整 URL。 - -解析顺序如下: - -1. 请求播放页 HTML -2. 提取 `var player_aaaa = {...}` JSON -3. 若 JSON 中存在 `url`,直接返回该地址 -4. 若页面中存在直链 `.m3u8`,直接返回该地址 -5. 若以上都失败,则回退返回原播放页地址,并设置 `jx=1` - -返回结构对齐当前仓库习惯: - -- 直链命中: - - `{"parse": 0, "jx": 0, "playUrl": "", "url": "<real-url>", "header": {...}}` -- 回退播放页: - - `{"parse": 0, "jx": 1, "playUrl": "", "url": "<play-page-url>", "header": {...}}` - -请求头策略: - -- 直链解析成功时返回 `User-Agent`,并将 `Referer` 设为当前播放页 URL -- 回退播放页时返回基础请求头,其中 `Referer` 为站点首页 - -## 错误处理 - -所有公开接口在异常或空响应场景下返回当前仓库接受的最小空结构: - -- `homeVideoContent -> {"list": []}` -- `categoryContent -> {"page": <pg>, "limit": 0, "total": 0, "list": []}` -- `searchContent -> {"page": <pg>, "total": 0, "list": []}` -- `detailContent -> {"list": []}` -- `playerContent -> {"parse": 0, "jx": 0, "playUrl": "", "url": "", "header": {}}` - -这样可以避免未处理异常穿透到调用侧。 - -## 测试设计 - -新增 `py/tests/test_耐视点播.py`,覆盖以下行为: - -- `homeContent` 返回固定分类 -- `_build_url` 与 `_extract_vod_id` 正确处理相对路径和短 ID -- `_parse_cards` 解析首页/分类卡片 -- `homeVideoContent` 调用首页并返回列表 -- `categoryContent` 优先走分类页,空结果时回退首页分区 -- `searchContent` 空关键词短路,非空关键词正确组装 URL 并解析结果 -- `_parse_detail_page` 提取元数据与多线路剧集 -- `detailContent` 读取详情页并返回单条详情 -- `playerContent` 覆盖 `player_aaaa`、页面直链、失败回退三条路径 - -测试全部使用内联 HTML 与 `unittest.mock` 隔离网络请求,不依赖站点在线状态。 - -## 风险与约束 - -已知风险如下: - -- 站点卡片和搜索页 DOM 可能存在轻微变体,需要在 XPath 上做适度容错 -- `player_aaaa.url` 可能是编码值或中间地址;当前实现仅按参考脚本优先直接透传 -- 分类页使用 `?page=` 参数而不是路径分页,测试需要锁定该约定,避免误写为 `/page/` - -本次实现优先保证: - -- 行为与参考脚本核心能力一致 -- 接口输出符合当前仓库既有约定 -- 测试可离线稳定运行 diff --git a/py/docs/superpowers/specs/2026-04-24-ppnix-spider-design.md b/py/docs/superpowers/specs/2026-04-24-ppnix-spider-design.md deleted file mode 100644 index ccfc317..0000000 --- a/py/docs/superpowers/specs/2026-04-24-ppnix-spider-design.md +++ /dev/null @@ -1,263 +0,0 @@ -# PPnix Spider Design - -## Goal - -Implement a new Python spider at `py/PPnix.py` for PPnix based on the provided Node reference, adapted to this repository's existing `Spider` interface and testing conventions. - -Scope for this iteration: - -- Support two categories: movie and tv -- Expose stable filters only: type/class and sort/by -- Parse homepage recommendations -- Parse category listings -- Parse keyword search -- Parse detail pages and playlist data -- Return direct playback URLs from PPnix m3u8 endpoints - -Explicitly out of scope for this iteration: - -- Dynamic filter discovery and caching -- TVBox-specific `data:m3u8` rewriting -- Subtitle aggregation in `playerContent` -- JavaScript server route parity with the Node plugin - -## Target API Shape - -The spider will implement the standard methods used in this repo: - -- `homeContent(filter)` -- `homeVideoContent()` -- `categoryContent(tid, pg, filter, extend)` -- `searchContent(key, quick, pg="1")` -- `detailContent(ids)` -- `playerContent(flag, id, vipFlags)` - -The spider will follow current repo conventions: - -- Single-file site adapter -- Deterministic parsing helpers -- Short site-local IDs rather than full URLs -- No `pagecount` field in list/search responses - -## Data Model - -### Categories - -Use stable repo-style numeric category IDs instead of the Node plugin's string IDs: - -- `1` => movie -- `2` => tv - -Display names: - -- `1` => `电影` -- `2` => `电视剧` - -### Filters - -Only expose stable filters: - -- `class` -- `by` - -For both category IDs, `homeContent` will return: - -- `class`: values extracted from the Node reference and hard-coded per category -- `by`: `time`, `hits`, `score` - -Sort mapping for request URLs: - -- `time` => `newstime` -- `hits` => `onclick` -- `score` => `rating` - -Default sort: - -- `time` - -`filter_def` caching is unnecessary because filters are static. - -### IDs - -To match repository guidance, the spider will keep IDs short. - -- List/detail `vod_id`: relative detail path such as `movie/123.html` or `tv/456.html` -- Play IDs: compact encoded payload containing only `infoId` and `param` - -Recommended play ID format: - -- `infoId|urlencoded(param)` - -This is sufficient because the final playback URL is deterministic: - -- `https://www.ppnix.com/info/m3u8/{infoId}/{encoded_param}.m3u8` - -No referer or display name needs to be stored in the ID for this scope. - -## Architecture - -`PPnix.py` will keep logic in a single class with focused helpers. - -### Core helpers - -- `_build_url(path)` - - Normalizes relative paths against `https://www.ppnix.com` - - Supports raw absolute URLs and root-relative paths - -- `_request_html(path_or_url, referer=None, extra_headers=None)` - - Performs GET requests through `fetch` - - Applies browser-like headers - - Returns response text or empty string on non-200 - -- `_clean_text(value)` - - Strips HTML whitespace noise and entities used in visible text - -- `_fix_image(url)` - - Converts relative poster URLs to absolute URLs - -### List/category/search helpers - -- `_map_type_slug(tid)` - - `1 -> movie`, `2 -> tv` - -- `_build_category_url(tid, pg, extend)` - - Converts repo category/filter input into PPnix path format - - First page omits the page index segment - - Path format: - - `/cn/{type_slug}/{genre}---{page_index}-{sort}.html` - - For this scope, unsupported filters are ignored - -- `_build_search_url(keyword, pg)` - - Uses the Node reference pattern: - - `/cn/search/{encoded_keyword}--.html` - - page > 1 appends `-page-{pg}` - -- `_parse_cards(html, type_hint="")` - - Parses list cards from homepage/category/search HTML - - Extracts: - - `vod_id` - - `vod_name` - - `vod_pic` - - `vod_remarks` - - Filters out malformed entries - - Deduplicates by `vod_id` - -### Detail/play helpers - -- `_extract_m3u8_items(html)` - - Reads: - - `infoid = 123` - - `m3u8 = [...]` - - Returns `infoId` plus ordered episode/item labels - -- `_build_play_id(info_id, param)` - - Returns compact playback ID - -- `_parse_play_id(play_id)` - - Safely reverses the compact playback ID - -- `_parse_detail_page(html, vod_id)` - - Extracts title, poster, year, director, actor, content - - Determines `type_name` from `vod_id` - - Builds a single `PPnix` play group using the extracted m3u8 items - -## Request and Parsing Flow - -### homeContent - -Returns static categories and static filters only. - -### homeVideoContent - -Requests `/cn/`, parses homepage blocks, merges movie and tv cards, and caps to 20-24 items. The implementation should prefer deterministic extraction over mirroring every homepage section. - -### categoryContent - -1. Build category URL from `tid`, `pg`, and `extend` -2. Fetch category page -3. Parse cards -4. Filter cards so returned `vod_id` matches the requested type slug -5. Return: - - `page` - - `limit` - - `total` - - `list` - -`total` can follow the repo’s common approximate pattern: - -- `page * limit + len(list)` - -This is acceptable because the site does not expose a clean total count in the provided reference. - -### searchContent - -1. Build search URL -2. Parse cards from search result page -3. Keep only IDs matching `movie/<id>.html` or `tv/<id>.html` -4. Return `page`, `limit`, `total`, `list` - -### detailContent - -1. Normalize incoming ID into a relative detail path -2. Fetch detail page -3. Parse metadata -4. Parse `infoid` and `m3u8` item names from inline JS -5. Build: - - `vod_play_from = "PPnix"` if episodes exist - - `vod_play_url = "名称$playId#名称$playId..."` - -### playerContent - -1. Decode `play_id` into `infoId` and `param` -2. If either is missing, fall back to parse-required response -3. Build direct source URL: - - `https://www.ppnix.com/info/m3u8/{infoId}/{encoded_param}.m3u8` -4. Return direct-play payload with headers: - - `Referer: https://www.ppnix.com/cn/` - - `Origin: https://www.ppnix.com` - - `User-Agent: browser UA` - -No m3u8 text rewriting will be attempted. - -## Error Handling - -- Request helpers return empty string on non-200 to keep parser code simple -- Parser helpers should tolerate missing nodes and return empty/default fields -- `detailContent` returns `{"list": []}` on fetch/parse failure -- `playerContent` returns parse-required fallback when `play_id` is malformed - -This matches the current repo’s defensive style better than raising exceptions. - -## Testing Strategy - -Tests will be added at `py/tests/test_PPnix.py` using `unittest` and `unittest.mock`. - -### Red tests to write first - -1. `homeContent` returns categories `1` and `2`, and only stable filter keys -2. `_build_category_url` maps default and selected sort/class values correctly -3. `_parse_cards` extracts short IDs, names, posters, and remarks -4. `homeVideoContent` merges homepage movie/tv sections and truncates result size -5. `categoryContent` requests the expected PPnix listing URL and returns repo-style page payload without `pagecount` -6. `searchContent` requests the expected PPnix search URL and filters valid movie/tv IDs -7. `_extract_m3u8_items` reads `infoid` and ordered item names from inline JS -8. `detailContent` builds metadata and `PPnix` play group from fixture HTML -9. `playerContent` returns direct m3u8 URL for a valid compact play ID -10. `playerContent` falls back to parse-required response when play ID is malformed - -### Fixture design - -Use embedded HTML snippets rather than live requests. Fixtures should cover: - -- Homepage lists -- Category/search cards -- Detail metadata block -- Inline script with `infoid` and `m3u8` - -No network access will be used in tests. - -## Implementation Notes - -- Prefer `BeautifulSoup` because the project already depends on it and many spiders use HTML-string parsing without heavy DOM abstractions -- Keep helper methods small and deterministic so test failures identify one parser responsibility at a time -- Avoid introducing caching or feature flags until they are required by a failing test diff --git a/py/docs/superpowers/specs/2026-04-24-ruyi-spider-design.md b/py/docs/superpowers/specs/2026-04-24-ruyi-spider-design.md deleted file mode 100644 index d8821a9..0000000 --- a/py/docs/superpowers/specs/2026-04-24-ruyi-spider-design.md +++ /dev/null @@ -1,329 +0,0 @@ -# 如意资源 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的如意资源爬虫,行为参考用户提供的 JS 版本,但范围只保留基础采集能力: - -- 首页分类与筛选 -- 首页推荐 -- 分类浏览 -- 搜索 -- 详情解析 -- 播放解析 - -实现形式遵循当前仓库的单文件 Spider 约定和离线单测约定,不引入 OmniBox 专有能力,也不修改公共基类。 - -## 范围 - -本次实现包含: - -- 新增独立脚本 `py/如意资源.py` -- 新增独立测试 `py/tests/test_如意资源.py` -- 顺序尝试多个采集 API,直到拿到可用 JSON -- 使用硬编码主分类、子分类和筛选项 -- 首页返回分类、筛选和推荐列表 -- 分类支持类型筛选和分页 -- 搜索支持分页与空关键词保护 -- 详情组装基础元数据与多线路播放列表 -- 播放接口区分直链与待解析链接 - -本次实现不包含: - -- 参考 JS 中的刮削重命名 -- 弹幕匹配 -- 嗅探能力 -- 播放页探测与二次解析页面构造 -- 修改 `base/` 公共层 -- 真实联网集成测试 - -## 方案选择 - -采用“仓库风格重写”的方案,而不是直接把 JS 逻辑逐行搬到 Python。 - -原因如下: - -- 当前仓库是 Python 单文件 Spider 模式,直接适配更一致 -- 用户已明确只要基础采集,没必要保留 OmniBox 的包装层和状态管理 -- 仓库测试习惯更适合覆盖字段映射、fallback 和播放分支,而不是复制 JS 中的运行时行为 - -## 模块边界 - -新增 `py/如意资源.py`,只在模块内部维护站点逻辑,不修改 `base/`。 - -对外实现以下接口: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `searchContent` -- `detailContent` -- `playerContent` - -模块内部拆分以下 helper: - -- `_request_json` - - 统一对多个 API 执行 GET 请求和 fallback -- `_build_query` - - 拼接 query string,忽略空值 -- `_get_pic_url` - - 统一补全图片 URL -- `_format_vod_list` - - 将列表接口数据映射为仓库统一字段 -- `_page_result` - - 统一列表/搜索分页返回结构 -- `_resolve_type_id` - - 根据主类和筛选结果推导最终请求的类型 ID -- `_parse_play_groups` - - 把详情接口返回的播放来源和剧集字符串组装成 `vod_play_from` / `vod_play_url` -- `_is_direct_media_url` - - 判断播放链接是否为明显的直链媒体地址 - -## 站点配置 - -固定配置来自参考实现: - -- 站点名:`如意资源` -- API 列表: - - `https://cj.rycjapi.com/api.php/provide/vod` - - `https://cj.rytvapi.com/api.php/provide/vod` - - `https://bycj.rytvapi.com/api.php/provide/vod` -- 图片主机回退: - - `https://ps.ryzypics.com` - - `https://ry-pic.com` - - `https://img.lzzyimg.com` -- 默认请求头: - - `User-Agent` - - `Accept: application/json` - - `Accept-Language: zh-CN,zh;q=0.9` - - `Referer: https://cj.rycjapi.com/` -- 请求超时: - - `10` 秒 - -请求策略: - -- 所有采集接口统一使用 `GET` -- 若显式传入完整 URL,则直接请求该 URL -- 否则按 API 列表顺序发起请求 -- 单个 API 非 `200`、JSON 解析失败或业务字段异常时,继续尝试下一个 API -- 所有 API 均失败时返回空结果而不是抛出未处理异常 - -## 分类与筛选设计 - -分类和筛选直接采用硬编码配置,避免依赖站点分类接口的不稳定性。 - -主分类固定为: - -- `1` 电影片 -- `2` 连续剧 -- `3` 综艺片 -- `4` 动漫片 -- `35` 电影解说 -- `36` 体育 - -子分类映射沿用参考实现,例如: - -- 电影片默认落到 `7` -- 连续剧默认落到 `13` -- 综艺片默认落到 `25` -- 动漫片默认落到 `29` -- 体育默认落到 `37` - -`homeContent` 返回: - -- `class` -- `filters` - -同时补充首页推荐列表,便于与仓库中支持推荐的蜘蛛保持一致。 - -筛选结构遵循当前仓库常见格式: - -- 每个主分类下只暴露一个 `key=type` -- `name=类型` -- `value` 为该主分类可选的硬编码子分类 - -无子分类的主类不返回筛选项。 - -`homeVideoContent` 保持仓库现有习惯,返回: - -- `{"list": []}` - -## 首页、分类与搜索设计 - -首页推荐接口使用: - -- `ac=list` -- `pg=1` -- `pagesize=20` - -分类接口使用: - -- `ac=list` -- `t=<最终类型ID>` -- `pg=<页码>` -- `pagesize=20` - -最终类型 ID 的推导规则: - -1. 若 `extend.type` 存在且非空,则直接使用该值 -2. 否则若主分类存在子分类映射,则使用该主类的第一个子分类 -3. 否则使用主分类 ID 本身 - -搜索接口使用: - -- `ac=list` -- `wd=<关键词>` -- `pg=<页码>` -- `pagesize=30` - -搜索结果保留接口返回的列表映射,但会额外按标题包含关键词做一次本地过滤,尽量贴近参考实现。 - -返回结构遵循当前仓库约定: - -- `page` -- `limit` -- `total` -- `list` - -其中: - -- 首页推荐只返回 `list` -- 分类的 `limit` 固定为 `20` -- 搜索的 `limit` 固定为 `30` -- 默认不返回 `pagecount` - -## 列表字段映射 - -首页推荐、分类和搜索统一映射为: - -- `vod_id` - - `vod_id` -- `vod_name` - - `vod_name` -- `vod_pic` - - 优先使用 `vod_pic`,必要时补全为完整 URL -- `vod_remarks` - - 优先使用 `vod_remarks`,否则退回年份 -- `vod_year` - - `vod_year` -- `type_id` - - `type_id` - -数据清洗规则: - -- 过滤空对象、空 `vod_id` 和 `vod_id=0` -- `vod_name` 为空时回退为“未知标题” -- 图片字段为 `<nil>`、`nil`、`null` 或空值时返回空字符串 -- 图片字段为相对路径时,拼接第一个图片主机 - -## 详情设计 - -详情接口使用: - -- `ac=videolist` -- `ids=<vod_id>` - -详情返回只保留当前仓库需要的核心字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `type_name` -- `vod_year` -- `vod_area` -- `vod_remarks` -- `vod_actor` -- `vod_director` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -播放列表解析规则: - -- 从 `vod_play_from` 读取线路名,逗号分隔 -- 从 `vod_play_url` 读取分集,`#` 分隔 -- 每个分集项按 `标题$地址` 解析 -- 若分集缺少标题,则回退为 `第N集` -- 若缺少地址,则跳过 - -为了兼容仓库现有数据结构,详情输出不保留 JS 版的嵌套 `vod_play_sources`,而是直接组装为: - -- `vod_play_from`: 用 `$$$` 连接的线路名 -- `vod_play_url`: 与线路一一对应、用 `$$$` 分组、每组内部用 `#` 连接的剧集串 - -若参考站点只返回一组剧集但含多个线路名,则默认把同一组剧集复制到每个线路名下,以保持与参考实现的可播放行为一致。 - -## 播放设计 - -播放阶段只做基础采集,不做嗅探和刮削增强。 - -输入为详情里拼出的剧集地址。处理规则如下: - -1. 若地址为空,返回空播放结果 -2. 若地址是明显的媒体直链,返回: - - `parse=0` - - `playUrl=""` - - `url=<直链>` -3. 若地址是普通 `http/https` 链接但不是明显媒体直链,返回: - - `parse=1` - - `playUrl=""` - - `url=<原始链接>` -4. 若地址不是 URL,仍按待解析地址返回,交给上游处理 - -明显媒体直链的判定后缀包括: - -- `.m3u8` -- `.mp4` -- `.flv` -- `.avi` -- `.mkv` -- `.ts` - -本次实现不为播放结果附加自定义 header,除非测试或站点字段证明有明确必需值。 - -## 错误处理 - -错误处理原则是“接口尽量返回空结果,不向外抛异常”。 - -具体策略: - -- 请求失败时继续 fallback -- 分类、首页、搜索失败时返回空列表 -- 详情失败时返回 `{"list": []}` -- 播放失败时返回 `{"parse": 0, "playUrl": "", "url": "", "header": {}}` -- 局部字段缺失只影响对应字段,不使整条记录失效 - -## 测试设计 - -测试文件新增为 `py/tests/test_如意资源.py`,全部使用 `unittest` 和 `mock`,不依赖真实网络。 - -首轮测试覆盖: - -- `homeContent` 暴露硬编码分类和筛选 -- `_get_pic_url` 处理绝对路径、相对路径和空值 -- `_request_json` 在首个 API 失败时会 fallback 到下一个 API -- `homeContent` 或首页推荐正确映射列表字段 -- `categoryContent` 能从主类推导默认子类,也能接受 `extend.type` -- `searchContent` 对空关键词直接返回空结果 -- `searchContent` 正确传递搜索参数并做标题过滤 -- `detailContent` 正确映射基础字段并拼出 `vod_play_from` / `vod_play_url` -- `playerContent` 对直链返回 `parse=0` -- `playerContent` 对非直链 URL 返回 `parse=1` - -测试遵循 TDD: - -1. 先写失败测试 -2. 确认失败原因正确 -3. 再补最小实现 -4. 最后跑目标模块测试 - -## 验收标准 - -完成后应满足以下标准: - -- 新蜘蛛文件和对应测试文件均已存在 -- 所有新增测试离线可运行 -- 分类、推荐、分类列表、搜索、详情、播放六类基础能力可用 -- 返回结构符合当前仓库 Spider 约定 -- 不引入刮削、弹幕、嗅探等额外行为 diff --git a/py/docs/superpowers/specs/2026-04-24-tingyoufm-spider-design.md b/py/docs/superpowers/specs/2026-04-24-tingyoufm-spider-design.md deleted file mode 100644 index 93341e6..0000000 --- a/py/docs/superpowers/specs/2026-04-24-tingyoufm-spider-design.md +++ /dev/null @@ -1,259 +0,0 @@ -# 听友FM Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的听友 FM 站点爬虫,行为参考用户提供的 JS 版本,但交付物遵循当前仓库的单文件 Spider 结构和测试约定。 - -本次设计覆盖以下能力: - -- 首页分类 -- 首页推荐 -- 分类列表 -- 搜索 -- 专辑详情 -- 章节播放 - -## 范围 - -本次实现包含: - -- 新增独立脚本 `py/听友FM.py` -- 新增独立测试 `py/tests/test_听友FM.py` -- 首页与分类优先解析页面结构和 `__NUXT_DATA__` -- 搜索优先解析搜索页中的 Nuxt 数据,失败时回退 DOM -- 详情页解析专辑元信息和章节列表 -- 播放优先请求 `/api/play_token` 获取直链 -- `/api/play_token` 支持参考实现中的加密响应解包逻辑 -- 播放失败时回退到 `/audios/{albumId}/{chapterIdx}` 页面 -- 为新增行为补齐离线 `unittest` - -本次实现不包含: - -- 修改 `base/` 公共层 -- 引入新的第三方依赖 -- 真实联网集成测试 -- 浏览器嗅探执行能力 -- 长期可用的硬编码匿名凭证管理 - -## 方案选择 - -采用“页面解析为主,播放接口为主链”的方案,而不是把所有能力都绑定到加密 API。 - -原因如下: - -- 用户已明确要求分类和详情优先走页面结构与 Nuxt 数据 -- 当前仓库的 Python Spider 更适合做 HTML 与 JSON 的离线可测解析 -- 听友 FM 的播放加密链路只对 `playerContent` 是刚需,列表和详情没必要强耦合 -- 当鉴权或解密链路波动时,首页、分类、搜索和详情仍可独立工作 - -## 模块边界 - -新增模块 `py/听友FM.py` 只负责站点逻辑,不修改 `base/`。 - -模块对外实现以下接口: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `detailContent` -- `searchContent` -- `playerContent` - -模块内部拆分以下 helper: - -- `_get_headers` - - 统一请求头和可选鉴权头 -- `_get_html` - - 发起页面请求并返回文本 -- `_api_post` - - 统一请求 `/api/*` 接口 -- `_encrypt_payload` - - 发送 API 请求前按站点规则加密请求体 -- `_decrypt_payload` - - 解包接口返回的十六进制 payload -- `_decode_nuxt_value` - - 还原 `__NUXT_DATA__` 中的引用表 -- `_parse_home_cards` - - 首页推荐卡片解析 -- `_parse_category_nuxt` - - 分类页专辑数据解析 -- `_parse_detail_page` - - 专辑详情和章节列表解析 -- `_parse_search_nuxt` - - 搜索结果解析 -- `_extract_play_url` - - 从 `play_token` 解包结果中抽取音频直链 - -## 站点配置 - -固定站点配置如下: - -- 站点名:`听友FM` -- 站点根地址:`https://tingyou.fm` -- 请求头包含桌面浏览器 `User-Agent` -- 页面请求默认带 `Referer` 与 `Origin` -- 分类使用固定映射: - - `46 -> 有声小说` - - `11 -> 武侠小说` - - `19 -> 言情通俗` - - `21 -> 相声小品` - - `14 -> 恐怖惊悚` - - `17 -> 官场商战` - - `15 -> 历史军事` - - `9 -> 百家讲坛` - -请求体加密配置沿用参考实现中的固定值: - -- `PAYLOAD_KEY_HEX` -- `PAYLOAD_VERSION = 1` - -这些值只在模块内部使用,不外露到公共层。 - -## 首页、分类与搜索设计 - -`homeContent` 返回: - -- `class` -- `list` - -首页解析策略: - -- 先抓取站点首页 HTML -- 从页面上的 `/categories/{id}` 链接提取分类 -- 若页面未给出足够分类,则回退到固定分类映射 -- 从首页中的 `/albums/{id}` 卡片提取推荐内容 -- 推荐卡片按 `vod_id` 去重 - -列表项统一映射为: - -- `vod_id` - - 来自专辑 ID -- `vod_name` - - 来自标题、图片 alt 或卡片文本 -- `vod_pic` - - 来自卡片封面 -- `vod_remarks` - - 组合期数、连载状态、播音或作者 - -`categoryContent` 解析策略: - -- 请求 `/categories/{categoryId}?sort=comprehensive&page={pg}` -- 优先解析 `__NUXT_DATA__` 中的 `categoryAlbums-{categoryId}` -- 若 Nuxt 数据不存在,则回退页面卡片 DOM -- 返回 `page/limit/total/list` -- `limit` 固定为当前页实际条数或使用站点默认分页大小 -- 不返回 `pagecount` - -`searchContent` 解析策略: - -- 请求 `/search?q={keyword}` -- 先从 `__NUXT_DATA__` 中定位搜索结果数据块 -- 若 Nuxt 结果缺失,则回退 DOM 中的专辑卡片 -- 结果按 `vod_id` 去重 -- 再按标题、备注和简介做一次本地关键词过滤 -- 返回 `page/limit/total/list` - -空关键字直接返回空结果: - -- `{"page": 1, "limit": 0, "total": 0, "list": []}` - -## 详情设计 - -`detailContent` 根据专辑页 `/albums/{id}` 解析单个专辑对象。 - -详情字段至少包含: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_content` -- `type_name` -- `vod_play_from` -- `vod_play_url` - -详情页解析策略: - -- 专辑名称优先取面板内标题,回退 `og:title` -- 封面优先取正文图片,回退 `og:image` -- 简介优先取 `description` 或正文简介 -- 分类从信息区的 `分类:` 字段提取 -- 章节列表从 `ul.chapter-list > li.chapter-item` 提取 -- 每个章节的播放 ID 格式为 `{albumId}|{chapterIdx}` - -播放线路固定为单线路: - -- `vod_play_from = 听友FM` -- `vod_play_url = 章节名$albumId|chapterIdx#...` - -## 播放与解密设计 - -`playerContent` 的主链路为 `/api/play_token`。 - -播放流程如下: - -1. 解析 `id` 为 `{albumId}|{chapterIdx}` -2. 组装请求体 `{"album_id": int(albumId), "chapter_idx": int(chapterIdx)}` -3. 调用 `/api/play_token` -4. 解包接口返回内容 -5. 遍历解包后的对象,抽取可能的音频直链 -6. 若获取成功,返回 `parse=0` 的直链结果 -7. 若失败,回退到 `/audios/{albumId}/{chapterIdx}` 页面并返回 `parse=1` - -解密规则沿用用户提供说明: - -- 发送请求体前使用 AES-GCM 加密为十六进制字符串 -- 响应若为十六进制 payload,需要按首字节识别版本 -- `version === 1` - - 按 `version + iv(12) + cipher` 的结构做 AES-GCM 解密 -- `version === 2` - - `nonce = bytes[1..24]` - - `cipher = bytes[25..]` - - 解密前先对 `cipher` 做 `reverse` - - 再按 XChaCha20-Poly1305 规则解密 - -Python 版本不引入外部加密库,因此实现策略如下: - -- 先覆盖 AES-GCM 的 v1 收发逻辑 -- 对 v2 解密提供明确的 helper 边界和测试桩 -- 若本地环境缺少可用的 XChaCha20-Poly1305 实现,则保留为可注入 helper,并在测试中验证“倒序 + 调用解密器”的行为 -- 生产代码中优先走可用解密器;不可用时直接回退播放页,而不是抛未处理异常 - -这样可以兼顾仓库的无新增依赖要求与播放链路的可维护性。 - -## 错误处理设计 - -错误处理原则为“尽量返回空结果或可回退结果,不中断主链路”: - -- 页面请求失败 - - 首页、分类、搜索返回空列表 -- Nuxt 数据解析失败 - - 回退 DOM 解析 -- 详情页无章节 - - 返回正常元信息,播放字段置空 -- `play_token` 请求失败 - - 回退到播放页 URL -- `play_token` 解密失败 - - 回退到播放页 URL -- 播放结果中未找到直链 - - 回退到播放页 URL - -## 测试设计 - -新增 `py/tests/test_听友FM.py`,使用 `unittest` 和 `unittest.mock` 做离线测试。 - -测试覆盖以下行为: - -- 首页从 DOM 提取分类与推荐卡片 -- 分类页优先解析 `__NUXT_DATA__` -- 分类页在 Nuxt 缺失时回退 DOM -- 搜索页优先 Nuxt、失败回退 DOM -- 搜索空关键字返回空结果 -- 详情页生成正确的 `vod_play_from` 和 `vod_play_url` -- AES-GCM 请求加密与 v1 响应解密 -- v2 解密路径会先反转密文再调用解密器 -- `playerContent` 成功返回直链 -- `playerContent` 在 API 失败或无直链时回退播放页 - -测试不做真实联网,不依赖站点在线状态。 diff --git a/py/docs/superpowers/specs/2026-04-24-世纪音乐-spider-design.md b/py/docs/superpowers/specs/2026-04-24-世纪音乐-spider-design.md deleted file mode 100644 index b565e12..0000000 --- a/py/docs/superpowers/specs/2026-04-24-世纪音乐-spider-design.md +++ /dev/null @@ -1,384 +0,0 @@ -# 世纪音乐 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的世纪音乐爬虫,能力参考用户提供的 OmniBox JS 实现,但交付物遵循当前仓库的单文件 Spider 结构和 `unittest` 测试约定。 - -首版仅覆盖仓库里最常用的五个接口: - -- 首页 -- 分类 -- 搜索 -- 详情 -- 播放 - -## 范围 - -本次实现包含: - -- 新增独立脚本 `py/世纪音乐.py` -- 新增独立测试 `py/tests/test_世纪音乐.py` -- 首页固定分类与推荐内容解析 -- 分类列表解析 -- 关键词搜索解析 -- 歌曲、MV、歌单、歌手、排行榜详情解析 -- 歌曲与 MV 播放直链拼装 -- 针对短 ID、字段回退、空结果和播放结果增加离线单测 - -本次实现不包含: - -- 歌词抓取与返回 -- 首页运行时缓存 -- 修改 `py/base/` 公共层 -- 引入新的第三方依赖 -- 真实联网集成测试 -- 复杂重试、反爬绕过和占位图补全 - -## 方案选择 - -采用“按仓库 Python Spider 形态重写站点逻辑”的方案,而不是直接把 OmniBox JS 原样翻译成 Python。 - -原因如下: - -- 当前仓库交付物是 Python Spider,而不是 OmniBox 脚本 -- 仓库现有测试体系围绕 `homeContent/categoryContent/detailContent/searchContent/playerContent` -- 参考 JS 中的核心能力可以映射到仓库接口,但其缓存和返回结构不适合直接搬运 -- 直接使用短 ID 和离线 HTML 测试,能降低 host 变化、缓存缺失和运行时状态对结果的影响 - -## 模块边界 - -新增模块 `py/世纪音乐.py` 只负责该站点逻辑,不修改 `py/base/`。 - -模块对外实现以下接口: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `detailContent` -- `searchContent` -- `playerContent` - -模块内部使用 helper 收敛 URL 和解析逻辑: - -- `_build_url` - - 补全站点绝对地址 -- `_fetch_html` - - 统一请求页面并返回文本 -- `_load_html` - - 将 HTML 转成可 XPath 的文档对象 -- `_clean_text` - - 规范化文本、去掉多余空白和站点装饰词 -- `_extract_site_id` - - 从站点链接中提取原始资源 ID -- `_encode_vod_id` - - 将站点资源编码为仓库短 `vod_id` -- `_decode_vod_id` - - 将短 `vod_id` 还原为站点路径和资源类型 -- `_encode_play_id` - - 将播放资源编码为短播放 ID -- `_decode_play_id` - - 将短播放 ID 还原为直链参数 -- `_parse_home_items` - - 首页推荐解析 -- `_parse_list_cards` - - 分类和搜索卡片解析 -- `_parse_rank_detail` - - 排行榜详情解析 -- `_parse_song_detail` - - 歌曲详情解析 -- `_parse_mv_detail` - - MV 详情解析 -- `_parse_playlist_detail` - - 歌单详情解析 -- `_parse_singer_detail` - - 歌手详情解析 - -## 站点配置 - -固定站点配置如下: - -- 站点名:`世纪音乐` -- 根地址:`https://www.4c44.com` -- 默认请求头包含桌面浏览器 `User-Agent` -- 默认请求头带 `Referer: https://www.4c44.com/` - -固定首页分类如下: - -- `home -> 首页推荐` -- `rank_list -> 排行榜` -- `playlist -> 歌单` -- `singer -> 歌手` -- `mv -> MV` - -首版保留与参考实现一致的内容面,但不引入运行时缓存层。 - -## ID 设计 - -为避免在列表层暴露完整 URL,统一使用仓库短 ID。 - -`vod_id` 编码规则: - -- `rank:<榜单标识>` -- `song:<song_id>` -- `mv:<mv_id>` -- `playlist:<slug或id>` -- `singer:<slug或id>` - -`play_id` 编码规则: - -- `music:<song_id>` -- `vplay:<mv_id>:1080` - -内部路径还原规则: - -- `song:<id>` -> `/mp3/<id>.html` -- `mv:<id>` -> `/mp4/<id>.html` -- `playlist:<id>` -> `/playlist/<id>.html` -- `singer:<id>` -> `/singer/<id>.html` -- `rank:<id>` -> `/list/<id>.html` - -首页推荐卡片直接编码为真实短 ID,不依赖额外缓存命中。 - -## 首页设计 - -`homeContent` 返回: - -- `class` -- `filters` -- `list` - -首页推荐解析策略: - -- 请求首页 HTML -- 从歌曲推荐区提取歌曲卡片 -- 从 MV 推荐区提取 MV 卡片 -- 根据链接前缀判断资源类型 -- 统一映射为短 `vod_id` -- 按 `vod_id` 去重 - -首页推荐列表项统一字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -`homeVideoContent` 直接复用首页的 `list`,避免重复解析逻辑。 - -## 分类设计 - -`categoryContent` 支持以下分类: - -- `home` -- `rank_list` -- `playlist` -- `singer` -- `mv` - -返回结构遵循仓库当前约定: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -各分类策略如下: - -### `home` - -- 直接复用首页推荐列表 -- `page=1` -- `limit` 与 `total` 使用当前列表长度 - -### `rank_list` - -- 使用模块内固定榜单映射 -- 每页返回固定数量的榜单卡片 -- 榜单项 `vod_id` 为 `rank:<榜单标识>` -- 榜单项不依赖站点页面结构 - -### `playlist` - -- 根据筛选参数构造歌单页 URL -- 从歌单列表容器解析 `/playlist/` 链接 -- 列表项编码为 `playlist:<id>` - -### `singer` - -- 根据筛选参数构造歌手页 URL -- 从歌手列表容器解析 `/singer/` 链接 -- 列表项编码为 `singer:<id>` - -### `mv` - -- 根据筛选参数构造 MV 页 URL -- 从 MV 列表容器解析 `/mp4/` 链接 -- 列表项编码为 `mv:<id>` - -筛选器首版沿用参考实现中的三组能力: - -- `singer` - - 性别、地区、字母 -- `mv` - - 地区、类型、排序 -- `playlist` - - 语种、风格 - -## 搜索设计 - -`searchContent` 行为如下: - -- 空关键词直接返回空结构 -- 非空关键词请求站点搜索页 -- 统一扫描搜索结果中的歌曲、MV、歌单、歌手链接 -- 根据链接前缀判断资源类型并编码为短 `vod_id` -- 按 `vod_id` 去重 - -返回结构: - -- `page` -- `limit` -- `total` -- `list` - -空关键词返回固定结果: - -- `{"page": 1, "limit": 0, "total": 0, "list": []}` - -## 详情设计 - -`detailContent` 按 `vod_id` 前缀分流,不依赖首页缓存。 - -### 排行榜详情 - -- 请求 `/list/<rank>.html` -- 解析榜单页歌曲链接 -- 每首歌生成 `music:<song_id>` 播放 ID -- 输出单线路 `vod_play_from` - -### 歌曲详情 - -- 请求 `/mp3/<song_id>.html` -- 解析标题、歌手、封面 -- 输出单条播放项 -- `vod_play_url` 格式为 `歌曲名$music:<song_id>` - -### MV 详情 - -- 请求 `/mp4/<mv_id>.html` -- 解析标题、歌手、封面 -- 输出单条播放项 -- `vod_play_url` 格式为 `标题$vplay:<mv_id>:1080` - -### 歌单详情 - -- 请求 `/playlist/<id>.html` -- 解析歌单标题、封面和歌曲列表 -- 播放列表中的每个条目编码为 `music:<song_id>` - -### 歌手详情 - -- 请求 `/singer/<id>.html` -- 解析歌手名称、封面、简介和歌曲列表 -- 播放列表中的每个条目编码为 `music:<song_id>` - -详情对象至少包含以下字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_content` -- `vod_play_from` -- `vod_play_url` - -站点能稳定提供时再补充: - -- `vod_actor` -- `type_name` - -## 播放设计 - -`playerContent` 只负责短播放 ID 到可播放 URL 的还原,不抓歌词。 - -规则如下: - -- `music:<song_id>` - - 还原为 `https://www.4c44.com/data/down.php?ac=music&id=<song_id>` -- `vplay:<mv_id>:1080` - - 还原为 `https://www.4c44.com/data/down.php?ac=vplay&id=<mv_id>&q=1080` - -返回结构遵循仓库常见模式: - -- `parse=0` -- `url=<直链>` -- `header` - -默认请求头返回: - -- `User-Agent` -- `Referer` - -若播放 ID 无法解析,则返回空 URL 和 `parse=0`,不抛异常。 - -## 错误处理设计 - -错误处理原则为“优先返回空结果,不中断主链路”。 - -- 页面请求失败 - - 首页、分类、搜索返回空列表 -- 分类页结构变化 - - 当前分类返回空列表,不影响其他分类 -- 搜索页无匹配结果 - - 返回空列表 -- 详情页解析失败 - - 返回 `{"list": []}` -- 播放 ID 无法解析 - - 返回空 URL - -不在首版加入复杂重试和缓存回退。 - -## 测试设计 - -新增测试文件 `py/tests/test_世纪音乐.py`,使用 `unittest` 和 `unittest.mock`,不访问真实网络。 - -测试范围如下: - -- `homeContent` - - 返回固定分类与筛选器 - - 能从首页 HTML 解析歌曲和 MV 推荐 -- `homeVideoContent` - - 复用首页列表 -- `categoryContent` - - `rank_list` 返回固定榜单分页 - - `playlist` 能解析歌单卡片 - - `singer` 能解析歌手卡片 - - `mv` 能解析 MV 卡片 - - 未知分类返回空结果 -- `searchContent` - - 空关键词返回空结果 - - 非空关键词能将歌曲、MV、歌单、歌手映射成短 `vod_id` -- `detailContent` - - `rank:<id>` 能组装歌曲播放列表 - - `song:<id>` 能组装歌曲详情 - - `mv:<id>` 能组装 MV 详情 - - `playlist:<id>` 能组装歌单详情 - - `singer:<id>` 能组装歌手详情 -- `playerContent` - - `music:<id>` 返回歌曲直链 - - `vplay:<id>:1080` 返回 MV 直链 - - 非法播放 ID 返回空 URL - -## 风险与约束 - -首版实现依赖站点当前 HTML 结构,因此有以下约束: - -- 列表解析对 CSS 类名和链接前缀有一定依赖 -- 歌单和歌手详情依赖页面内歌曲列表容器 -- 搜索结果若结构分散,需要在实现中优先选择稳定容器,并在测试中覆盖字段回退 - -这些风险都限制在站点模块内部,不扩散到公共层。 diff --git a/py/docs/superpowers/specs/2026-04-24-两个BT-design.md b/py/docs/superpowers/specs/2026-04-24-两个BT-design.md deleted file mode 100644 index e5c4c81..0000000 --- a/py/docs/superpowers/specs/2026-04-24-两个BT-design.md +++ /dev/null @@ -1,185 +0,0 @@ -# 两个BT Spider 设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 `两个BT` spider,参考用户提供的 JS 站点逻辑,实现首页、分类、搜索、详情和播放能力,目标站点为 `https://www.bttwoo.com`。 - -## 范围 - -本次实现包含: - -- 新增 `py/两个BT.py` -- 新增 `py/tests/test_两个BT.py` -- 固定 14 个分类 -- 首页推荐、分类页与搜索页卡片解析 -- 详情页元数据与单线路剧集解析 -- 播放页直链提取、iframe 二次提取与失败回退 - -本次实现不包含: - -- 修改 `py/base/` 共享基类 -- 迁移 JS 版本中的 `OmniBox.log`、`processScraping`、`getScrapeMetadata`、`sniffVideo` -- 真实联网测试 -- 站外解析器或额外第三方线路适配 - -## 分类与 URL - -首页固定返回以下分类: - -- `zgjun` 国产剧 -- `meiju` 美剧 -- `jpsrtv` 日韩剧 -- `movie_bt_tags/xiju` 喜剧 -- `movie_bt_tags/aiqing` 爱情 -- `movie_bt_tags/adt` 冒险 -- `movie_bt_tags/at` 动作 -- `movie_bt_tags/donghua` 动画 -- `movie_bt_tags/qihuan` 奇幻 -- `movie_bt_tags/xuanni` 悬疑 -- `movie_bt_tags/kehuan` 科幻 -- `movie_bt_tags/juqing` 剧情 -- `movie_bt_tags/kongbu` 恐怖 -- `gf` 高分电影 - -`homeContent` 只返回 `class`,不返回 `filters`。参考脚本没有定义筛选项,Python 版本也不额外引入未经验证的筛选参数。 - -URL 规则如下: - -- 首页推荐走 `/` -- 分类页默认走 `/{tid}` -- `movie_bt_tags/*` 保留原始路径 -- 页码大于 1 时追加 `paged` 查询参数 -- 搜索走 `/xssssearch?q=<keyword>` -- 搜索翻页追加 `p=<page>` - -分类和搜索结果返回仓库当前通用字段: - -- `list` -- `page` -- `limit` -- `total` - -不返回 `pagecount`。 - -## 接口映射 - -- `homeContent` - - 返回固定分类 -- `homeVideoContent` - - 请求首页 - - 复用列表卡片解析函数返回推荐列表 -- `categoryContent` - - 构造分类 URL - - 解析卡片并返回分页字段 -- `searchContent` - - 构造搜索 URL - - 解析卡片并做轻量相关性过滤 -- `detailContent` - - 请求详情页 - - 解析标题、封面、简介、导演、主演 - - 解析所有 `/v_play/` 剧集链接并组装单线路播放列表 -- `playerContent` - - 解码 `play_id` - - 请求播放页并优先提取站内直链 - - 必要时进入 iframe 二次提取 - - 提取失败时回退 `parse=1` - -## 数据模型 - -### 列表卡片 - -首页推荐、分类和搜索统一复用卡片解析逻辑: - -- 从包含 `/movie/<id>.html` 的链接提取详情入口 -- `vod_id` 只保留数字 ID,不返回绝对 URL -- 标题按 `h3 a`、`h3`、`a[title]`、`.title`、`.name` 顺序提取 -- 图片按 `data-original`、`data-src`、`src` 顺序提取 -- 图片地址统一补全为绝对 URL -- 备注从评分、状态或常见角标文本提取 -- 对重复卡片按 `vod_id` 去重 - -### 搜索精排 - -搜索结果在卡片解析后做一层轻量过滤,以贴近参考脚本: - -- 标题直接包含关键词时保留 -- 否则按去空白后的字符集合计算交集比例 -- 关键词长度不超过 2 时只做直接包含判断 -- 未命中过滤条件的结果丢弃 - -### 详情 - -详情页字段按“DOM 优先,兜底选择器回退”的方式提取: - -- 标题优先 `h1`、`h2`、`title` -- 封面优先 `img.poster`、`.poster img`、首张可用图片 -- 简介优先 `.intro`、`.description`、`.desc` -- 主演与导演优先标签文本,再用正则清理 `主演`、`导演` 前缀 - -播放列表统一输出为单线路: - -- `vod_play_from`: `两个BT` -- `vod_play_url`: `剧集名$playId#剧集名$playId` - -选集按详情页上的 `/v_play/...html` 链接提取,顺序保持页面顺序。 - -### 播放 ID - -`play_id` 使用轻量 JSON 编码后再 base64,至少包含: - -- `pid`: 站内 `/v_play/` 的核心 ID -- `sid`: 当前影片 `vod_id` -- `name`: 原始剧集名 - -这样可以在播放解析阶段直接恢复播放页地址,并保留回退所需上下文。 - -## 播放解析 - -播放解析优先返回站内直链,处理顺序如下: - -1. 解码 `play_id` -2. 如果输入本身已是媒体直链,则直接返回 `parse=0` -3. 构造并请求 `/v_play/<pid>.html` -4. 优先从播放页 HTML 中提取显式媒体地址,例如 `.m3u8`、`.mp4` -5. 若存在 iframe,则继续请求 iframe 页面并重复提取 -6. 若仍无法拿到直链,则回退返回播放页 URL,并设置 `parse=1` - -播放器返回头至少包含: - -- `User-Agent` -- `Referer` -- `Origin` - -这部分只实现仓库现有能力边界内的站内解析,不迁移参考脚本对外部运行时的依赖。 - -## 错误处理 - -- HTML 请求失败时返回空字符串,不向上抛站点异常 -- 首页、分类、搜索失败时返回空列表和基本分页字段 -- 详情页解析失败时返回空列表 -- 播放解析失败时回退 `parse=1` -- 坏的 `play_id`、空节点和无匹配正则都按空值处理,不抛未捕获异常 - -## 测试策略 - -使用 `unittest` 与 `unittest.mock`,不走真网。 - -至少覆盖: - -- `homeContent` 返回固定 14 个分类 -- 列表卡片正确提取 `vod_id`、标题、图片、备注 -- `categoryContent` 正确拼接分类 URL 与页码参数 -- `searchContent` 正确请求搜索 URL 并执行相关性过滤 -- `detailContent` 正确提取元数据和剧集列表 -- `play_id` 编码与解码保持一致 -- `playerContent` 能直接返回已给定的媒体地址 -- `playerContent` 能从播放页提取媒体直链 -- `playerContent` 能进入 iframe 页面继续提取 -- `playerContent` 在拿不到直链时回退 `parse=1` - -## 验收标准 - -- 新增 spider 与测试文件后,可独立运行 `tests.test_两个BT` -- 首页、分类、搜索、详情在离线夹具下输出稳定 -- 播放支持“直接媒体地址 -> 播放页提取 -> iframe 二次提取 -> 失败回退”四条路径 -- 不修改共享基类即可完成接入 diff --git a/py/docs/superpowers/specs/2026-04-24-四万影视-design.md b/py/docs/superpowers/specs/2026-04-24-四万影视-design.md deleted file mode 100644 index f8a55f9..0000000 --- a/py/docs/superpowers/specs/2026-04-24-四万影视-design.md +++ /dev/null @@ -1,293 +0,0 @@ -# 四万影视 Spider 设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 `四万影视` spider,参考用户提供的 JS 采集脚本,接入 `https://40000.me/api/maccms`,实现分类、搜索、详情和播放能力。 - -## 范围 - -本次实现包含: - -- 新增 `py/四万影视.py` -- 新增 `py/tests/test_四万影视.py` -- 固定主分类与静态筛选 -- 分类接口请求与列表字段标准化 -- 搜索接口请求与列表字段标准化 -- 详情接口请求与多线路播放字段组装 -- 播放直链透传与非直链 `parse=1` 回退 - -本次实现不包含: - -- 首页推荐抓取 -- 动态分类发现 -- 站点 HTML 页面解析 -- 真实联网测试 -- 第三方解析器实现 - -## 目标接口形态 - -Spider 将实现以下标准方法: - -- `homeContent(filter)` -- `homeVideoContent()` -- `categoryContent(tid, pg, filter, extend)` -- `searchContent(key, quick, pg="1")` -- `detailContent(ids)` -- `playerContent(flag, id, vipFlags)` - -遵循当前仓库约定: - -- 单文件 spider -- 使用 `fetch` 发起请求,不改共享基类 -- 列表与搜索结果不返回 `pagecount` -- 详情结果使用 `vod_play_from` / `vod_play_url` - -## 分类与筛选 - -沿用参考脚本的固定分类 ID 与名称: - -- `20` 电影 -- `30` 电视剧 -- `39` 动漫 -- `45` 综艺 -- `32` 欧美 - -筛选为静态定义,不从远端动态读取。 - -每个主分类都返回三类筛选: - -- `subType` - - 使用参考脚本中的子分类映射 - - 默认值为当前主分类 `type_id` -- `year` - - `全部` 加 `2026` 到 `2000` -- `sort` - - `time` - - `hits` - - `score` - - `up` - -Python 版本筛选值沿用仓库现有格式: - -- `{"n": "显示名", "v": "实际值"}` - -## 数据模型 - -### 列表项 - -分类和搜索返回的每个视频项统一标准化为: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_year` -- `type_name` - -字段来源以 maccms JSON 为准。 - -额外规范: - -- `vod_pic` 为空时回退到 `https://40000.me/public/favicon.png` -- `type_name` 优先使用接口值,缺失时按固定主分类映射兜底 -- `vod_actor` 中的 `&#039;` 还原为 `'` -- 演员列表中的逗号空白归一化为英文逗号 - -### 详情项 - -详情页输出保持仓库当前约定,不保留 JS 版本里的 `vod_play_sources` 数组,而是转换为: - -- `vod_play_from` -- `vod_play_url` - -组装规则: - -- 上游 `vod_play_from` 按 `$$$` 拆成线路名 -- 上游 `vod_play_url` 按 `$$$` 拆成线路内容 -- 每条线路内部按 `#` 拆分剧集 -- 每个剧集按 `剧集名$playId` 编码 -- 若某条线路名缺失,回退为 `线路N` -- 若某个剧集标题缺失,回退为 `第N集` - -最终: - -- `vod_play_from = 线路A$$$线路B` -- `vod_play_url = 第1集$playId#第2集$playId$$$正片$playId` - -### 播放 ID - -播放 ID 不再额外编码,直接复用接口里的剧集值: - -- 若值本身是 `http/https` 链接,则播放器直接返回 -- 若值不是完整 URL,则播放器返回 `parse=1` 交给外部解析 - -## 请求映射 - -Spider 只请求一个接口端点: - -- `https://40000.me/api/maccms` - -### homeContent - -不请求远端,直接返回固定分类和固定筛选。 - -### homeVideoContent - -按用户确认,固定返回: - -- `{"list": []}` - -### categoryContent - -请求参数: - -- `ac=detail` -- `t=<subType>` -- `pg=<page>` -- `h=<year>`,仅在年份非空时传入 -- `by=<sort>` - -其中: - -- `subType` 来自 `extend["subType"]`,缺失时回退 `tid` -- `sort` 来自 `extend["sort"]`,默认 `time` -- `year` 来自 `extend["year"]` - -返回: - -- `page` -- `limit` -- `total` -- `list` - -`limit` 使用当前返回列表长度,`total` 优先使用接口 `total`,缺失时回退为列表长度。 - -### searchContent - -请求参数: - -- `ac=detail` -- `wd=<keyword>` -- `pg=<page>` - -空关键字直接返回空列表。 - -返回: - -- `page` -- `limit` -- `total` -- `list` - -### detailContent - -请求参数: - -- `ac=detail` -- `ids=<videoId>` - -流程: - -1. 读取第一条详情记录 -2. 标准化基础字段 -3. 解析 `vod_play_from` / `vod_play_url` -4. 输出单个详情对象 - -重点字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `type_name` -- `vod_year` -- `vod_area` -- `vod_director` -- `vod_actor` -- `vod_content` -- `vod_remarks` -- `vod_play_from` -- `vod_play_url` - -### playerContent - -处理规则保持与参考脚本一致: - -1. 空播放 ID 返回: - - `parse = 1` - - `jx = 1` - - `url = ""` -2. 若 `id` 以 `http://` 或 `https://` 开头: - - `parse = 0` - - `jx = 0` - - `url = id` -3. 否则: - - `parse = 1` - - `jx = 1` - - `url = id` - -播放器返回头固定包含: - -- `User-Agent` -- `Referer: https://40000.me/` - -## 架构与辅助函数 - -`py/四万影视.py` 保持单类实现,但拆成小 helper,避免方法过长。 - -建议 helper: - -- `_build_filters()` - - 生成固定筛选配置 -- `_type_name_by_id(type_id)` - - 固定主分类名称映射 -- `_normalize_vod(item)` - - 统一标准化列表/详情基础字段 -- `_api_get(params)` - - 调用 maccms JSON 接口 - - 非 200 或非对象响应时抛出 `ValueError` - - 上层入口方法负责捕获并返回空结果 -- `_parse_play_groups(item)` - - 把上游播放源字段转换成仓库需要的 `vod_play_from` / `vod_play_url` -- `_page_result(items, page, total)` - - 构造列表返回体 - -## 错误处理 - -- 请求失败时不向外抛未处理异常 -- 分类失败返回: - - `{"page": 当前页, "limit": 0, "total": 0, "list": []}` -- 搜索失败返回: - - `{"page": 当前页, "limit": 0, "total": 0, "list": []}` -- 详情失败返回: - - `{"list": []}` -- 播放失败返回: - - `{"parse": 1, "jx": 1, "url": "", "header": {...}}` - -这样与仓库现有 spider 的防御性风格保持一致。 - -## 测试策略 - -使用 `unittest` 与 `unittest.mock`,不走真网。 - -至少覆盖以下行为: - -1. `homeContent` 返回预期分类 ID 与筛选 key -2. `_build_filters` 生成 `subType/year/sort` 三组筛选 -3. `_api_get` 正确请求 `/api/maccms`,并处理非 200 / 非对象响应 -4. `categoryContent` 正确映射 `subType/year/sort/page` -5. `categoryContent` 不返回 `pagecount` -6. `searchContent` 正确映射 `wd` 和分页 -7. `searchContent` 在空关键字时直接返回空列表 -8. `detailContent` 正确提取详情基础字段并组装 `vod_play_from` / `vod_play_url` -9. `_parse_play_groups` 能处理多线路、多剧集、缺失线路名与缺失剧集名 -10. `playerContent` 对直链返回 `parse=0` -11. `playerContent` 对非直链返回 `parse=1` - -## 验收标准 - -- 新增 `py/四万影视.py` 和 `py/tests/test_四万影视.py` -- 可独立运行 `python -m unittest tests.test_四万影视 -v` -- 分类、搜索、详情的离线夹具输出稳定 -- `homeVideoContent()` 固定返回空列表 -- 播放器对直链和非直链的行为与参考脚本一致 -- 不修改共享基类即可完成接入 diff --git a/py/docs/superpowers/specs/2026-04-25-aaz-music-design.md b/py/docs/superpowers/specs/2026-04-25-aaz-music-design.md deleted file mode 100644 index 32aa117..0000000 --- a/py/docs/superpowers/specs/2026-04-25-aaz-music-design.md +++ /dev/null @@ -1,369 +0,0 @@ -# AAZ音乐 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 AAZ 音乐爬虫,行为参考用户提供的 OmniBox 脚本,但交付物遵循仓库现有的单文件 Spider 结构、短 ID 约定和 `unittest` 测试方式。 - -首版仅覆盖仓库最常用的五个能力: - -- 首页 -- 分类 -- 搜索 -- 详情 -- 播放 - -## 范围 - -本次实现包含: - -- 新增独立脚本 `py/AAZ音乐.py` -- 新增独立测试 `py/tests/test_AAZ音乐.py` -- 首页固定分类与推荐歌曲解析 -- 分类页对歌曲、歌手、歌单、专辑、MV 的列表解析 -- 搜索页的混合结果解析 -- 单曲详情解析 -- 歌手、歌单、专辑、MV 详情页解析 -- 单曲试听直链解析 -- 针对短 ID、空结果、详情播放串和播放失败回退增加离线单测 - -本次实现不包含: - -- 下载链接抓取和返回 -- 歌词链接抓取和返回 -- 修改 `py/base/` 公共层 -- 引入新的第三方依赖 -- 真实联网集成测试 -- 复杂反爬绕过、代理、缓存和重试策略 -- 对 MV 独立播放链路做额外兼容 - -## 方案选择 - -采用“按仓库 Python Spider 形态重写站点逻辑”的方案,而不是直接移植 OmniBox 脚本的数据模型。 - -原因如下: - -- 当前仓库消费的是 Python Spider 接口,而不是 OmniBox 风格的异步 handler -- 仓库现有测试和调用都围绕 `homeContent/categoryContent/searchContent/detailContent/playerContent` -- 参考脚本中的下载、歌词和扩展字段不在本次范围内,直接照搬会增加无效复杂度 -- 使用短 ID 和离线 fixture 更符合当前仓库的可维护性与可测试性 - -## 模块边界 - -新增模块 `py/AAZ音乐.py` 只负责 AAZ 站点逻辑,不修改 `py/base/`。 - -模块对外实现以下接口: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `detailContent` -- `searchContent` -- `playerContent` - -模块内部使用 helper 收敛 URL、ID 编解码和页面解析逻辑: - -- `_build_url` - - 补全站点绝对地址 -- `_fetch_html` - - 统一 GET 页面并返回文本 -- `_post_play_api` - - 统一 POST `/js/play.php` 并解析 JSON -- `_load_html` - - 将 HTML 转成可 XPath 的文档对象 -- `_clean_text` - - 规范化文本并移除多余空白 -- `_extract_song_id` - - 从 `/m/<id>.html` 链接提取单曲 ID -- `_encode_vod_id` - - 将站点链接编码为仓库短 `vod_id` -- `_decode_vod_id` - - 将短 `vod_id` 还原为站点详情路径 -- `_parse_song_cards` - - 解析歌曲列表 -- `_parse_folder_cards` - - 解析歌手、歌单、专辑、MV 封面卡片 -- `_parse_folder_tracks` - - 从目录型详情页提取歌曲播放列表 -- `_parse_song_detail` - - 提取单曲详情元信息 -- `_build_empty_result` - - 返回统一的空分页结构 - -## 站点配置 - -固定站点配置如下: - -- 站点名:`AAZ音乐` -- 根地址:`https://www.aaz.cx` -- 默认请求头包含桌面浏览器 `User-Agent` -- 默认请求头带 `Referer: https://www.aaz.cx/` - -固定首页分类如下: - -- `new -> 新歌榜` -- `top -> TOP榜单` -- `singer -> 歌手` -- `playtype -> 歌单` -- `album -> 专辑` -- `mv -> 高清MV` - -分类路径映射如下: - -- `new -> /list/new.html` -- `top -> /list/top.html` -- `singer -> /singerlist/index/index/index/index.html` -- `playtype -> /playtype/index.html` -- `album -> /albumlist/index.html` -- `mv -> /mvlist/index.html` - -## ID 设计 - -统一使用仓库短 ID,不在列表或搜索结果中暴露完整 URL。 - -`vod_id` 编码规则: - -- `song:<song_id>` -- `singer:<slug>` -- `playlist:<slug>` -- `album:<slug>` -- `mv:<slug>` - -目录型资源与链接前缀映射如下: - -- `/s/xxx` -> `singer:xxx` -- `/p/xxx` -> `playlist:xxx` -- `/a/xxx` -> `album:xxx` -- `/v/xxx` -> `mv:xxx` - -单曲资源与链接映射如下: - -- `/m/<id>.html` -> `song:<id>` - -`play_id` 首版只保留: - -- `song:<song_id>` - -内部路径还原规则: - -- `song:<id>` -> `/m/<id>.html` -- `singer:<id>` -> `/s/<id>` -- `playlist:<id>` -> `/p/<id>` -- `album:<id>` -> `/a/<id>` -- `mv:<id>` -> `/v/<id>` - -## 首页设计 - -`homeContent` 返回: - -- `class` -- `list` - -首页策略如下: - -- 请求 `/list/new.html` -- 解析页面中的歌曲列表项 -- 将歌曲链接统一编码为 `song:<id>` -- 以 `vod_id` 去重 -- 返回固定分类和首页歌曲列表 - -首页列表项字段统一为: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -`homeVideoContent` 直接复用 `homeContent(False)` 的 `list`。 - -首版不返回 `filters`,因为当前站点分类依赖固定入口,不需要额外筛选面板。 - -## 分类设计 - -`categoryContent` 支持以下分类: - -- `new` -- `top` -- `singer` -- `playtype` -- `album` -- `mv` - -返回结构遵循仓库当前约定: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -各分类策略如下: - -### `new` 与 `top` - -- 请求对应榜单页面 -- 解析歌曲列表 -- 结果项编码为 `song:<id>` -- `vod_name` 使用站点卡片标题 -- 若卡片存在 MV 标记,可写入 `vod_remarks` - -### `singer` - -- 请求歌手列表页 -- 解析 `/s/` 链接的封面卡片 -- 编码为 `singer:<slug>` - -### `playtype` - -- 请求歌单列表页 -- 解析 `/p/` 链接的封面卡片 -- 编码为 `playlist:<slug>` - -### `album` - -- 请求专辑列表页 -- 解析 `/a/` 链接的封面卡片 -- 编码为 `album:<slug>` - -### `mv` - -- 请求 MV 列表页 -- 解析 `/v/` 链接的封面卡片 -- 编码为 `mv:<slug>` - -首版不实现翻页抓取;所有分类都按单页结果返回,`page` 仍回显请求值,`limit` 和 `total` 取当前页解析条数。 - -## 搜索设计 - -`searchContent(keyword, quick, pg)` 请求 `/so/{keyword}.html`。 - -搜索策略如下: - -- 空关键字直接返回空分页结构 -- 请求搜索结果页 -- 统一扫描结果区域中的站点链接 -- 根据链接前缀映射为 `song/singer/playlist/album/mv` 五类短 ID -- 对结果按 `vod_id` 去重 - -搜索结果字段统一为: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` - -搜索结果允许混合类型存在,不对类型做二次拆分。 - -## 详情设计 - -`detailContent` 根据 `vod_id` 分为“单曲详情”和“目录型详情”两条链路。 - -### 单曲详情 - -当 `vod_id` 为 `song:<id>` 时: - -- 请求 `/m/<id>.html` -- 提取歌名、歌手、专辑、封面、时长和简介 -- `vod_name` 使用解析到的歌名 -- `vod_pic` 优先使用详情页封面 -- `vod_remarks` 组合歌手、专辑和时长 -- `vod_play_from` 固定为 `AAZ音乐` -- `vod_play_url` 生成为 `播放$song:<id>` - -单曲详情不暴露下载地址、慢速下载地址和歌词地址。 - -### 目录型详情 - -当 `vod_id` 为 `singer/playlist/album/mv` 之一时: - -- 根据短 ID 还原对应详情页 URL -- 提取页面标题、封面和简介 -- 从页面歌曲列表中解析 `/m/<id>.html` -- 生成 `歌曲名$song:<id>` 的播放串 -- `vod_play_from` 固定为 `AAZ音乐` - -目录型详情只保证歌曲列表和歌曲试听,不承诺 MV 独立播放能力。即使详情类型为 `mv:<slug>`,只要页面能列出歌曲,就按目录型播放列表处理。 - -若详情页没有有效歌曲列表,则返回只有基础元信息、播放串为空的单对象详情。 - -## 播放设计 - -`playerContent(flag, id, vipFlags)` 只处理 `song:<id>`。 - -播放流程如下: - -1. 解析 `id` 中的 `song_id` -2. POST `https://www.aaz.cx/js/play.php` -3. 请求体使用 `id=<song_id>&type=music` -4. 请求头补充表单提交和 `X-Requested-With` -5. 从 JSON 中提取 `url` -6. 若成功,返回 `parse=0` 的直链播放结果 -7. 若失败,返回 `parse=0` 且 `url=""` - -返回结构至少包含: - -- `parse` -- `url` -- `header` - -`header` 固定返回: - -- `User-Agent` -- `Referer: https://www.aaz.cx/` - -首版不接入下载接口,也不把播放失败回退到下载地址。 - -## 错误处理 - -统一遵循仓库现有的宽松容错策略: - -- 页面请求失败时返回空字符串或空结果,不抛出到调用层 -- 解析缺失字段时回退为空字符串 -- 搜索空关键字返回空分页结构 -- 未知 `vod_id` 或非法 `play_id` 返回空详情或空播放地址 - -空分页结构统一为: - -- `{"page": 1, "limit": 0, "total": 0, "list": []}` - -## 测试设计 - -新增 `py/tests/test_AAZ音乐.py`,使用 `unittest` 与 `unittest.mock`。 - -测试覆盖以下行为: - -- `homeContent` - - 返回 6 个固定分类 - - 首页歌曲卡片正确映射为 `song:<id>` -- `homeVideoContent` - - 直接复用首页 `list` -- `categoryContent` - - `new/top` 正确解析歌曲 - - `singer/playtype/album/mv` 正确解析目录型短 ID - - 未知分类返回空分页结构 -- `searchContent` - - 混合结果正确映射为五类短 ID - - 空关键字返回空分页结构 -- `detailContent` - - `song:<id>` 生成单条播放串 - - `singer/playlist/album/mv` 详情页能生成歌曲播放列表 - - 无效 `vod_id` 返回空 `list` -- `playerContent` - - `/js/play.php` 返回 JSON 时正确映射直链 - - 非法 `play_id` 或空 `url` 时返回空播放地址 - -fixture 采用内嵌 HTML/JSON 文本,不依赖真实网络。 - -## 验证计划 - -实现阶段先执行最小受影响测试,再执行模块完整测试。 - -预期验证命令为: - -```bash -cd py && python -m unittest tests.test_AAZ音乐 -v -``` - -本次工作完成后不新增更大范围的套件验证要求,因为实现不会修改公共层。 diff --git a/py/docs/superpowers/specs/2026-04-25-secspider-design.md b/py/docs/superpowers/specs/2026-04-25-secspider-design.md deleted file mode 100644 index bad8ad1..0000000 --- a/py/docs/superpowers/specs/2026-04-25-secspider-design.md +++ /dev/null @@ -1,513 +0,0 @@ -# SecSpider 加密插件设计 - -## 目标 - -为当前 Spider 插件体系设计一套可落地的“源码封包”机制,用于隐藏 Spider 明文源码,避免普通用户直接打开插件文件即可看到实现细节。 - -本次设计面向的宿主前提如下: - -- 宿主支持修改加载流程 -- 插件既可能来自本地文件,也可能来自远程下载 -- 宿主本身是开源可见、运行在用户设备上的 Python 客户端 -- 现有 Spider 插件接口保持不变,仍以 `Spider` 类作为宿主入口 - -本次设计的核心目标是: - -- 让发布物不再是直接可读的 Python 明文源码 -- 支持宿主对插件做来源校验和完整性校验 -- 在不改变 Spider 对外接口的前提下完成加载 -- 保持明文插件与加密插件双栈兼容,便于渐进迁移 - -本次设计不追求: - -- 防住能修改宿主、抓内存、下断点的逆向者 -- 做在线授权平台 -- 做设备绑定、过期控制或远程吊销 -- 让用户设备上的插件“不可提取” - -## 背景与边界 - -当前宿主 `SpiderPluginLoader` 使用 `importlib.util.spec_from_file_location(...)` 直接执行本地 Python 文件,并约定模块中导出 `Spider` 类。该机制适合明文源码插件,但不适合密文封包。 - -由于本次目标是隐藏源码而不是彻底防逆向,设计必须先承认以下边界: - -- 只要插件最终要在本地运行,明文源码或等价字节码一定会在某个时刻进入解释器 -- 解密端开源且跑在用户设备上,因此任何宿主内置密钥都可能被逆向提取 -- 这意味着方案的安全收益是“提高获取源码的门槛”,而不是“保证源码永不泄露” - -因此,本次设计定位为: - -- 源码封包机制 -- 完整性与签名校验机制 -- 宿主侧双栈插件加载机制 - -而不是 DRM 或不可导出的执行环境。 - -## 方案选择 - -候选方案: - -1. 纯混淆方案 -2. 离线封包 + 本地验签 + 本地解密方案 -3. 在线授权下发解密材料方案 - -选用方案 2。 - -原因: - -- 用户当前目标只是避免普通用户直接看源码,不需要引入在线依赖 -- 宿主已允许修改,适合增加自定义 loader -- 本地验签可以防止第三方伪造或篡改插件 -- 双栈方案能平滑兼容现有明文插件生态 -- 相比在线授权,工程复杂度和运维成本更可控 - -不选方案 1 的原因: - -- 只能防止双击直读,无法提供可靠的篡改检测 -- 无法建立统一的协议版本、密钥轮换和签名链路 - -不选方案 3 的原因: - -- 超出当前目标 -- 需要额外服务端、授权链路和可用性保障 - -## 总体架构 - -系统分成三个角色: - -- `builder` - - 读取明文 Spider 源码 - - 生成内容密钥 - - 加密源码 - - 生成签名 - - 输出单文件加密包 -- `loader` - - 读取插件文件 - - 识别明文或密文格式 - - 对密文包执行验签、解密、内存加载 -- `runtime spider` - - 解密后在内存中编译执行 - - 对宿主继续暴露 `Spider` 类 - -总体链路如下: - -1. 构建阶段读取明文 Spider 源码 -2. 生成随机内容密钥 `content_key` -3. 用 `content_key` 对源码做对称加密 -4. 使用宿主密钥派生链路包裹 `content_key` -5. 对包头和密文内容做签名 -6. 产出单文件文本封包 -7. 宿主加载时先识别格式 -8. 若为明文插件,则继续按现有 `importlib` 逻辑执行 -9. 若为密文插件,则先验签,再解包内容密钥,再解密源码 -10. 解密后的源码通过 `compile/exec` 在内存中执行 -11. 宿主统一从模块中读取 `Spider` 类并实例化 - -## 封包格式设计 - -建议定义文本协议 `secspider/1`,保持现有注释头风格,便于远程分发和人工排查。 - -推荐格式: - -```text -// ignore -//@name:[直] omofun -//@version:1 -//@remark: -//@format:secspider/1 -//@alg:aes-256-gcm -//@wrap:hkdf-aes-keywrap -//@sign:ed25519 -//@kid:k2026_04 -//@nonce:base64:... -//@ek:base64:... -//@hash:sha256:... -//@sig:base64:... -// ignore -payload.base64:... -``` - -字段分组如下: - -- 业务元数据 - - `name` - - `version` - - `remark` -- 协议元数据 - - `format` - - `alg` - - `wrap` - - `sign` - - `kid` -- 加密与校验字段 - - `nonce` - - `ek` - - `hash` - - `sig` -- 数据字段 - - `payload.base64` - -字段约束如下: - -- `name`、`version`、`remark` 是仅保留的业务元数据字段 -- `format` 固定为 `secspider/1` -- `alg` 首版固定为 `aes-256-gcm` -- `wrap` 首版固定为 `hkdf-aes-keywrap` -- `sign` 首版固定为 `ed25519` -- `kid` 表示当前包使用的密钥版本 -- `nonce` 为内容加密随机数 -- `ek` 为包裹后的内容密钥,不是主密钥本身 -- `hash` 为解密前明文源码的 `sha256` -- `sig` 覆盖除 `sig` 自身外的所有头字段和 `payload` - -`payload.base64` 存放最终密文载荷,推荐流程为: - -- 明文源码转 `utf-8` 字节 -- 可选压缩 -- 使用内容密钥加密 -- 对结果做 `base64` 编码 - -## 算法选择 - -首版固定采用如下组合: - -- 内容加密:`AES-256-GCM` -- 内容密钥派生:`HKDF-SHA256` -- 发布签名:`Ed25519` - -选择原因如下: - -- `AES-256-GCM` 为成熟 AEAD 算法,适合同时提供保密性和密文完整性 -- `HKDF-SHA256` 足够简单,适合从宿主主密钥按 `kid/name/version` 派生包密钥 -- `Ed25519` 适合快速、稳定地完成离线签名和验签 - -本次设计明确不采用: - -- 自定义异或或字符串切片混淆 -- 自研加解密协议 -- 只做加密不做签名 - -## 密钥分层设计 - -宿主开源且运行在用户设备上,因此密钥体系的设计目标不是“绝对保密”,而是避免所有插件共用一把固定明文密钥。 - -推荐分三层密钥: - -### 1. 发布签名密钥对 - -- 构建环境持有 `Ed25519 private key` -- 宿主持有对应 `Ed25519 public key` - -用途: - -- 证明插件确实由发布方签发 -- 拦截被篡改或伪造的插件包 - -约束: - -- 私钥绝不进入客户端 - -### 2. 宿主主密钥 - -- 宿主内置 `master_secret` -- 不直接用于解密 `payload` - -用途: - -- 派生当前包使用的 `wrap_key` - -说明: - -- 这层密钥会被逆向到,因此安全作用有限 -- 但仍优于“所有插件共用固定 AES key”的做法 - -### 3. 内容密钥 - -- 每个插件构建时随机生成一个 32 字节 `content_key` - -用途: - -- 只用于当前插件包的源码加密 - -优势: - -- 每个插件包独立加密 -- 不同包之间不共享实际内容密钥 - -### 密钥派生规则 - -建议: - -```text -wrap_key = HKDF-SHA256( - ikm = master_secret, - salt = kid, - info = "secspider:" + name + ":" + version -) -``` - -用途: - -- `wrap_key` 用于包裹或解包 `content_key` - -收益: - -- 同一 `master_secret` 下,不同 `name/version` 的包不会直接共钥 -- 支持按 `kid` 做宿主侧密钥轮换 - -## 密钥轮换策略 - -建议宿主维护一个 `keyring`,按 `kid` 管理签名公钥和主密钥材料。 - -设计要求: - -- 插件头部必须携带 `kid` -- loader 先按 `kid` 查找密钥材料 -- 找不到 `kid` 直接拒绝执行 -- 新版插件可切换到新 `kid` -- 旧 `kid` 可在兼容期内继续保留 - -首版只需要支持静态内置 `keyring`,不要求联网拉取。 - -## 宿主加载设计 - -### 现状 - -当前宿主 `SpiderPluginLoader` 的核心流程为: - -1. 安装兼容 `base.spider` -2. 通过本地路径或远程下载得到插件文件 -3. 通过 `importlib.util.spec_from_file_location(...)` 执行源码文件 -4. 从模块中读取 `Spider` 类并实例化 - -这条链路仅适合明文 Python 文件。 - -### 目标形态 - -宿主改造为双栈加载: - -- 明文插件:保持现有 `importlib` 路径 -- 加密插件:走自定义 `secspider` runtime 路径 - -两条路径最终都返回 `types.ModuleType`,上层实例化逻辑保持不变。 - -### 推荐改造点 - -在 `SpiderPluginLoader` 中新增三个私有方法: - -- `_detect_package_format(source_path) -> str` -- `_load_plain_module(module_name, source_path) -> types.ModuleType` -- `_load_secspider_module(module_name, source_path, config) -> types.ModuleType` - -主流程调整为: - -1. `_install_compat_modules()` -2. `_resolve_source_path()` -3. `_detect_package_format(source_path)` -4. 如果不是 `secspider/1`,则走 `_load_plain_module(...)` -5. 如果是 `secspider/1`,则走 `_load_secspider_module(...)` -6. 统一读取 `Spider` 类、实例化并执行 `init(config.config_text)` - -### 为什么不继续沿用 `spec_from_file_location` - -原因如下: - -- 密文插件正文不再是合法 Python 源码 -- 若继续沿用 `importlib` 文件执行,势必要把明文源码先落盘 -- 这会直接削弱“避免用户直接读取源码”的目标 - -因此,对加密插件的正确路径是: - -- 把缓存文件当“文本包” -- 在内存中验签和解密 -- 用 `compile/exec` 创建模块对象 - -## 宿主侧模块结构 - -建议新增如下模块: - -- `atv_player/plugins/spider_crypto/package.py` - - 解析 `secspider/1` - - 校验头字段 - - 输出规范化签名输入 -- `atv_player/plugins/spider_crypto/keyring.py` - - 维护 `kid -> public_key/master_secret` -- `atv_player/plugins/spider_crypto/runtime.py` - - 验签 - - 派生 `wrap_key` - - 解包 `content_key` - - 解密 `payload` - - `compile/exec` 生成内存模块 -- `atv_player/plugins/spider_crypto/errors.py` - - 定义格式、签名、密钥、解密、运行期错误 - -`SpiderPluginLoader` 只负责调度,不直接承载加解密细节。 - -## 运行时序设计 - -密文插件的标准加载时序如下: - -1. 读取插件文件文本 -2. 解析注释头和 `payload.base64` -3. 检查 `format == secspider/1` -4. 按 `kid` 获取签名公钥 -5. 对头字段和 payload 执行 `Ed25519` 验签 -6. 验签通过后,按 `kid/name/version` 派生 `wrap_key` -7. 解开 `ek` 得到 `content_key` -8. 使用 `content_key + nonce` 解密 `payload` -9. 对解密后的源码做 `sha256` 校验,必须与 `hash` 一致 -10. 将源码编译为 `code object` -11. 在新的 `types.ModuleType` namespace 中执行 -12. 返回 module 对象给 `SpiderPluginLoader` - -执行时应满足: - -- 明文源码不写回磁盘 -- 模块命名保持宿主当前风格 -- 解密失败和运行失败分开报错 - -## 推荐的运行时接口 - -建议形成如下边界: - -```python -class SecSpiderPackage: - @classmethod - def parse(cls, text: str) -> "SecSpiderPackage": ... - def signing_bytes(self) -> bytes: ... - -class SpiderKeyring: - def get_public_key(self, kid: str): ... - def get_master_secret(self, kid: str) -> bytes: ... - -class SecSpiderRuntime: - def __init__(self, keyring: SpiderKeyring) -> None: ... - def load_module(self, pkg: SecSpiderPackage, module_name: str) -> types.ModuleType: ... -``` - -这样可以把: - -- 包格式解析 -- 密钥管理 -- 运行时加载 - -三个责任分开,避免 `SpiderPluginLoader` 继续膨胀。 - -## 远程下载与缓存策略 - -当前宿主远程插件会下载后缓存到本地文件。此行为可以保留,但缓存内容应始终是“原始加密包文本”,而不是解密后的源码。 - -建议: - -- `_resolve_source_path()` 继续负责下载并落地缓存 -- 缓存文件只用于下次重新加载原始包 -- 每次加载仍重新验签和解密 -- 如需提速,只做内存级缓存,不做明文持久化缓存 - -缓存文件后缀可以考虑从固定 `.py` 调整为更中性的扩展名,例如: - -- `.spkg` -- `.txt` - -若短期不想改后缀,也应在代码语义上把其视为“插件文本缓存”,而不是默认假设为 Python 源码文件。 - -## 构建器设计 - -构建器职责如下: - -1. 读取明文 Spider 源码 -2. 计算源码 `sha256` -3. 生成随机 `content_key` -4. 生成随机 `nonce` -5. 可选压缩源码 -6. 使用 `AES-256-GCM` 加密源码 -7. 基于 `master_secret + kid + name + version` 派生 `wrap_key` -8. 用 `wrap_key` 包裹 `content_key` -9. 生成规范化包头 -10. 对头部和 payload 做 `Ed25519` 签名 -11. 输出最终文本封包 - -构建器只在受控环境中使用,不进入宿主。 - -## 错误分类设计 - -宿主侧应把底层异常统一映射为明确的用户可见错误: - -- `插件格式不支持` -- `插件签名校验失败` -- `插件密钥不可用` -- `插件解密失败` -- `插件源码校验失败` -- `插件缺少 Spider 类` -- `插件运行失败: ...` -- `缺少依赖: ...` - -这样做的目的是: - -- 便于日志区分是下载问题、协议问题、签名问题还是运行问题 -- 便于 UI 或调用方给出稳定提示 - -## 兼容与迁移策略 - -推荐分三步迁移: - -### 第一步:宿主支持双栈 - -- 明文插件继续可加载 -- 新增 `secspider/1` 加密插件加载能力 - -### 第二步:单插件试点 - -- 选择一个 Spider 先完成从明文到加密包的发布验证 -- 检查下载、缓存、验签、解密和错误处理链路 - -### 第三步:逐步扩大覆盖面 - -- 将更多 Spider 改为由构建器生成加密包 -- 明文源码只保留在私有开发或构建环境 - -本次设计不要求一次性淘汰全部明文插件。 - -## 测试策略 - -至少覆盖以下测试: - -1. 包解析测试 - - 缺少字段 - - 重复字段 - - 非法 `format` - - 缺失 `payload` -2. 验签测试 - - 改动头字段任一字符后验签失败 - - 改动 payload 任一字符后验签失败 -3. 解密测试 - - 错误 `kid` - - 错误 `ek` - - 错误 `nonce` - - 错误 `hash` -4. 模块加载测试 - - 成功加载后模块含 `Spider` 类 - - `init(config_text)` 能按现有方式调用 -5. 兼容测试 - - 明文插件加载逻辑不回退 -6. 缓存测试 - - 远程下载失败时,若已有有效缓存,仍能回退到缓存包 - -所有测试应使用本地 fixture,不依赖真实网络和真实私钥环境。 - -## 风险与约束 - -- 宿主主密钥可被逆向提取,因此无法防住高级对手 -- 攻击者可以自行修改宿主,去掉验签和解密保护 -- 若未来需要做授权控制,必须引入新的密钥分发或服务端参与机制 -- 若协议字段顺序、空白或换行规则不固定,签名实现很容易出错,因此必须定义严格的 canonicalization 规则 - -## 验收标准 - -- 明文插件加载行为保持兼容 -- `secspider/1` 插件可从本地和远程加载 -- 被篡改的插件会在验签阶段拒绝执行 -- 密钥错误或密文损坏会在解密阶段明确失败 -- 加载成功后,宿主仍像处理普通插件一样处理 `Spider` 实例 -- 解密后的源码不落盘 -- 宿主错误提示可区分格式、签名、密钥、解密和运行期问题 diff --git a/py/docs/superpowers/specs/2026-04-26-feikuai-tv-spider-design.md b/py/docs/superpowers/specs/2026-04-26-feikuai-tv-spider-design.md deleted file mode 100644 index bd3f7fc..0000000 --- a/py/docs/superpowers/specs/2026-04-26-feikuai-tv-spider-design.md +++ /dev/null @@ -1,226 +0,0 @@ -# 飞快TV Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的飞快TV爬虫,行为参考用户提供的 JS 版本,但实现形式遵循仓库现有的单文件 Spider 与 `unittest` 测试约定。 - -本次实现范围: - -- 首页分类 -- 分类浏览 -- 搜索 -- 详情解析 -- 站内在线播放分组 -- 网盘分组 -- 播放页 `player_aaaa` 直链解析 - -本次不实现: - -- 首页推荐抓取 -- 复杂筛选项 -- 额外加密算法扩展 -- 浏览器回退或动态渲染 - -## 方案选择 - -采用“固定站点 + DOM 直解析 + 播放页脚本解链 + 网盘直出”的方案,而不是为该站额外设计通用抽象或复杂 `play_id` 编码。 - -原因如下: - -- 用户给出的参考实现本身就是固定站点 `https://feikuai.tv` -- 当前仓库对同类站点优先使用站内短路径作为 `vod_id` 与播放 ID -- 该站的在线播放和网盘在详情页上是两类不同数据,分开组装最清晰 -- 播放页只需要覆盖 `player_aaaa` 的 `encrypt=1/2` 分支,过度抽象没有收益 - -## 模块边界 - -新增 `py/飞快TV.py`,不修改 `py/base/`。 - -模块对外实现: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `searchContent` -- `detailContent` -- `playerContent` - -模块内部 helper 负责: - -- 统一请求头 -- URL 归一化与短路径提取 -- 列表卡片解析 -- 详情元信息解析 -- 在线播放分组解析 -- 网盘分组解析 -- 播放页脚本解析 -- Base64 解码与结果归一化 - -## 站点策略 - -固定站点参数: - -- host:`https://feikuai.tv` -- 固定桌面端 `User-Agent` -- 默认请求头包含 `Referer` 与 `Origin` - -请求策略: - -- 分类、搜索、详情、播放页都使用 GET -- 不引入可配置 `site` -- 请求失败时返回空结果或解析回退,不抛额外异常 - -## 分类与首页 - -`homeContent` 返回固定分类: - -- `1` 电影 -- `2` 剧集 -- `3` 综艺 -- `4` 动漫 - -`homeVideoContent` 固定返回 `{"list": []}`。 - -分类 URL 固定为: - -- `/vodshow/{type_id}--------{page}---.html` - -分类页解析规则: - -- 遍历 `a.module-poster-item` -- 提取详情短路径、标题、封面、备注 -- `vod_id` 保留站内短路径,例如 `/voddetail/12345.html` -- `vod_pic` 统一补全为绝对 URL - -返回结构遵循仓库当前约定: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -## 搜索 - -搜索 URL 固定为: - -- `/label/search_ajax.html?wd=<urlencoded keyword>&by=time&order=desc&page=<page>` - -搜索页解析规则: - -- 遍历 `div.module-card-item.module-item` -- 提取详情短路径、标题、封面、备注 -- 去重后返回 -- 关键字为空时直接返回空列表 - -搜索返回: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -## 详情解析 - -详情页 URL 为: - -- `https://feikuai.tv` + `vod_id` - -详情页需要同时解析基础信息、在线播放分组和网盘分组。 - -基础元信息: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_content` -- `vod_remarks` - -在线播放分组规则: - -- 线路名来自 `div.module-tab-items-box > .module-tab-item` -- 排除带 `onclick` 的节点 -- 播放列表来自 `div.module-list.tab-list` -- 排除 `.module-downlist` -- 每条剧集提取显示名与播放短路径,例如 `/vodplay/123-1-1.html` -- `vod_play_from` 中站内分组名保留页面线路名 -- 站内各线路之间用 `$$$` 拼接,线路内各剧集用 `#` 拼接 - -网盘分组规则: - -- 读取 `div.module-list > .tab-content` -- 每个网盘条目优先取 `h4` 文本的 `@` 前缀作为剧集名 -- 分享链接取条目内 `p` 文本 -- 根据分享链接域名识别线路名: - - `pan.quark.cn` -> `quark` - - `drive.uc.cn` -> `uc` - - `alipan.com`、`aliyundrive.com` -> `aliyun` - - `pan.baidu.com` -> `baidu` - - 其他回退 `pan` -- 按识别后的线路名归并,追加到 `vod_play_from` / `vod_play_url` -- 网盘链接直接写入 `vod_play_url`,不经过 `playerContent` - -如果站内播放和网盘都缺失,则返回空播放字段。 - -## 播放解析 - -`playerContent` 只处理站内在线播放短路径。 - -播放页 URL 为: - -- `https://feikuai.tv` + `id` - -解析规则: - -- 从页面脚本中提取 `player_aaaa=...` -- 解析 JSON 后读取 `url` 与 `encrypt` -- `encrypt == "1"` 时对 `url` 执行 `unescape` -- `encrypt == "2"` 时先 Base64 解码,再执行 `unescape` -- 解出媒体地址后返回 `parse=0`、`jx=0` - -回退规则: - -- 如果脚本缺失、JSON 解析失败或未得到可用媒体地址,则返回 `parse=1`、`jx=1` -- 回退 URL 使用完整播放页地址,由上层决定是否继续解析 - -## 数据约束 - -ID 设计保持仓库现有风格: - -- `vod_id` 使用详情短路径 -- 站内播放项 ID 使用播放短路径 -- 不把完整详情 URL 或播放 URL 直接写入站内 ID 字段 - -字段约束: - -- 列表与搜索结果不返回 `pagecount` -- `vod_pic` 必须尽量输出绝对地址 -- 网盘链接作为最终分享链接直出 -- 站内播放链接只在 `playerContent` 中转换为媒体直链 - -## 测试策略 - -采用 TDD,实现前先写 `py/tests/test_飞快TV.py`。 - -测试覆盖最小闭环: - -- `homeContent` 返回固定分类 -- `homeVideoContent` 返回空列表 -- `categoryContent` 解析分类卡片并输出短 `vod_id` -- `searchContent` 解析搜索结果并处理空关键字 -- `detailContent` 同时组装站内分组与网盘分组 -- `playerContent` 覆盖 `encrypt=1` 明文分支 -- `playerContent` 覆盖 `encrypt=2` Base64 分支 -- `playerContent` 在脚本缺失或无可用地址时回退解析页 - -测试方法: - -- 使用 `unittest` -- 使用 `unittest.mock.patch` 隔离网络请求 -- 测试 HTML 与脚本夹具直接内嵌在测试文件中 -- 先跑单个新增测试模块,再决定是否补跑相关模块 diff --git a/py/docs/superpowers/specs/2026-04-26-maitian-spider-design.md b/py/docs/superpowers/specs/2026-04-26-maitian-spider-design.md deleted file mode 100644 index 872a999..0000000 --- a/py/docs/superpowers/specs/2026-04-26-maitian-spider-design.md +++ /dev/null @@ -1,208 +0,0 @@ -# 麦田影院 Python 爬虫设计 - -## 目标 - -在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的麦田影院爬虫,实现用户给出的 JS 参考行为,并遵循仓库现有的单文件 Spider 与 `unittest` 测试约定。 - -本次实现范围: - -- 固定分类首页 -- 分类分页浏览 -- 详情页元信息解析 -- 详情页线路分组解析 -- 播放页直链解析 -- 无搜索占位返回 - -本次不实现: - -- 首页推荐抓取 -- 站点筛选项 -- 模糊搜索或站外搜索 -- 通用 MacCMS 基类抽象 -- 超出参考 JS 的额外解密分支 - -## 方案选择 - -采用“单站点直解析 + 固定分类配置 + `player_data` / `art.php` 两段式解链”的实现。 - -原因如下: - -- 用户已经给出完整的站点行为参考,直接映射到 Python 最稳妥 -- 当前仓库的同类 Spider 以单文件实现为主,维护成本最低 -- 该站详情页结构与现有 `耐视点播`、`糯米` 接近,可以复用相同的解析思路 -- 搜索明确不做,没必要为了未来复用引入新的公共抽象 - -## 模块边界 - -新增 `py/麦田影院.py`,新增 `py/tests/test_麦田影院.py`,不修改 `py/base/`。 - -Spider 对外接口: - -- `init` -- `getName` -- `homeContent` -- `homeVideoContent` -- `categoryContent` -- `searchContent` -- `detailContent` -- `playerContent` - -Spider 内部 helper 负责: - -- 统一请求头与绝对 URL 构造 -- 文本清洗和短路径归一化 -- 列表卡片解析 -- 详情元信息解析 -- 线路名与剧集分组解析 -- `player_data` 提取与 JSON 解析 -- `art.php` 两段式签名请求 - -## 站点配置 - -固定站点参数: - -- `host`: `https://www.mtyy5.com` -- 固定桌面端 `User-Agent` -- 默认请求头包含 `Referer`、`Origin`、`User-Agent` - -固定分类: - -- `1` 电影 -- `2` 电视剧 -- `4` 动漫 -- `3` 综艺 -- `26` 短剧 -- `25` 少儿 - -`homeContent` 返回固定分类,不抓取首页推荐。 - -`homeVideoContent` 固定返回 `{"list": []}`。 - -## 分类与卡片解析 - -分类 URL 固定为: - -- `/vodshow/{type_id}--------{page}---.html` - -列表解析规则: - -- 遍历 `div.public-list-box` -- 取 `a.public-list-exp` 的 `href` 作为详情短路径 -- 取 `a.public-list-exp` 的 `title` 作为片名 -- 取 `img` 的 `data-src`,缺失时回退 `src` -- 取 `.public-list-prb` 文本作为备注 -- `vod_id` 保留短路径,例如 `/voddetail/123.html` -- `vod_pic` 输出绝对 URL - -返回结构: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -## 搜索策略 - -站点标题明确标注“无搜索”,因此 `searchContent` 不发起任何请求。 - -行为约束: - -- 任意关键字都返回空结果 -- 返回结构与仓库现有约定一致:`page`、`limit`、`total`、`list` - -## 详情解析 - -详情页 URL: - -- `host + vod_id` - -基础信息: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_content` -- `vod_remarks` - -字段来源: - -- 标题优先取页面 `h1`,缺失时回退详情 `title` -- 封面优先取详情图片区 `img` -- 简介优先取剧情介绍区文本,取不到时置空 -- `vod_remarks` 取详情页状态类文本,缺失允许为空 - -播放分组规则: - -- 先读取 `a.swiper-slide` 作为线路名来源 -- 线路名去除数字字符后再清洗空白 -- 再按页面顺序读取 `div.anthology-list-box` -- 每个 `anthology-list-box` 对应一个线路分组 -- 分组内读取 `ul.anthology-list-play > li` -- 每个剧集项取文本作为名称 -- 每个剧集项取第一个子元素的 `href` 作为播放短路径 -- `vod_play_from` 与 `vod_play_url` 采用仓库统一的 `$$$` / `#` 拼接规则 - -如果线路名数量少于分组数量,缺失部分回退为 `线路{index}`。 - -## 播放解析 - -`playerContent` 只处理站内播放短路径。 - -播放页 URL: - -- `host + play_id` - -解析流程: - -1. 请求播放页 -2. 从脚本文本中提取 `player_data=...` -3. 解析 JSON,读取 `url` -4. 如果 `url` 已经是 `http` / `https` 直链,直接返回 -5. 否则先对 `url` 执行 URL 解码,再请求: - - `/static/player/art.php?get_signed_url=1&url=<decoded>` -6. 解析上一步 JSON 中的 `signed_url` -7. 再请求: - - `/static/player/art.php<signed_url>` -8. 从返回 JSON 中读取 `jmurl` 作为最终媒体地址 - -返回结构: - -- 成功时返回 `parse=0`、`jx=0`、`playUrl=""`、`url=<media_url>`、`header=<请求头>` -- 失败时回退返回 `parse=1`、`jx=1`、`playUrl=""`、`url=<完整播放页地址>`、`header=<请求头>` - -请求头约束: - -- 最终播放结果保留桌面端 `User-Agent` -- `Referer` 指向播放页 URL - -## 数据约束 - -ID 设计遵循仓库现有习惯: - -- `vod_id` 使用详情短路径 -- 播放项 ID 使用播放短路径 -- 不把站内详情 URL 或播放 URL 预先展开写入 ID - -结果约束: - -- `vod_pic` 尽量输出绝对地址 -- 列表、分类、搜索不返回 `pagecount` -- 搜索不触网 -- 解析失败尽量返回空结果或解析页回退,而不是抛异常 - -## 测试策略 - -采用 TDD,先新增 `py/tests/test_麦田影院.py`,再实现 Spider。 - -测试覆盖最小闭环: - -- `homeContent` 返回固定分类 -- `homeVideoContent` 返回空列表 -- `categoryContent` 构造正确 URL 并解析卡片 -- `searchContent` 对任意关键字返回空结果且不触网 -- `detailContent` 按 `swiper-slide + anthology-list-box` 组装播放分组 -- `playerContent` 覆盖直链分支 -- `playerContent` 覆盖 `art.php` 两段式解签分支 -- `playerContent` 在脚本缺失或 JSON 无效时回退解析页 diff --git a/py/docs/superpowers/specs/2026-04-29-shuangxing-spider-design.md b/py/docs/superpowers/specs/2026-04-29-shuangxing-spider-design.md deleted file mode 100644 index 55b584e..0000000 --- a/py/docs/superpowers/specs/2026-04-29-shuangxing-spider-design.md +++ /dev/null @@ -1,446 +0,0 @@ -# 双星 Python 爬虫设计 - -**日期:** 2026-04-29 - -## 目标 - -在当前 Python Spider 仓库中新增独立单站蜘蛛 `双星.py`。 - -目标站点主域名固定为: - -- `https://1.star2.cn` - -行为边界以用户提供的 Python 参考实现为准,并对齐当前仓库蜘蛛接口: - -- 支持固定分类输出 -- 支持分类分页列表 -- 支持关键字搜索 -- 支持详情页标题和网盘链接整理 -- `playerContent` 只透传已识别的网盘分享链接 -- 不做站内播放页解析 - -## 范围 - -本次实现包含: - -- 新增蜘蛛文件 `py/双星.py` -- 新增测试文件 `py/tests/test_双星.py` -- 补充对应 spec 和 plan 文档 - -本次实现不包含: - -- 抽取新的公共盘站基类 -- 修改 `base/` 公共层 -- 增加站内直链解析 -- 接入外部聚合蜘蛛 -- 实现多备用域名 failover - -## 现状 - -仓库中已经存在多份结构接近的网盘聚合站蜘蛛,尤其是: - -- `py/欧歌.py` -- `py/闪电.py` -- `py/二小.py` - -这些实现已经验证了当前仓库对同类站点的稳定接入方式: - -- `homeContent/homeVideoContent/categoryContent/searchContent/detailContent/playerContent` 作为统一入口 -- `vod_id` 保持为站内短路径 -- `detailContent` 只负责把详情页里的网盘分享链接整理成播放线路 -- `playerContent` 不做二次路由,直接对支持的网盘链接透传 - -双星和上述盘站不同的一点是: - -- 首次访问首页后需要缓存响应 cookie,后续列表、搜索和详情请求都要带回该 cookie - -因此,本次工作的重点是: - -- 在现有盘站模式下补上 cookie 初始化链路 -- 按双星页面结构实现列表、搜索和详情解析 -- 保持 `playerContent` 返回形状与仓库现有蜘蛛兼容 - -## 方案选择 - -采用“独立单站实现 + 仓库接口适配”的方案。 - -### 方案 A:推荐 - -- 新增 `py/双星.py` -- 参考用户给出的 Python 逻辑实现 cookie 初始化和 DOM 解析 -- 对外接口对齐仓库现有 `Spider` 约定 -- 使用 `unittest` 做离线单测 - -优点: - -- 与当前仓库现有调用链兼容 -- 风险边界小 -- 测试可直接复用已有盘站蜘蛛的断言模式 - -### 方案 B:不采用 - -- 严格照搬用户参考中的 `route/id` 分发模式 - -不采用原因: - -- 与当前仓库 `playerContent` 返回结构不一致 -- 会在单站蜘蛛里引入额外的路由语义 -- 对当前调用方没有直接收益 - -## Spider 对外行为 - -### 文件 - -- `py/双星.py` -- `py/tests/test_双星.py` - -### `init` - -初始化时访问首页: - -- URL:`https://1.star2.cn` -- 请求头至少包含 `User-Agent` 和 `Referer` -- `allow_redirects=False` - -从首页响应 cookies 中提取 `name=value`,按 `; ` 拼接后缓存到实例字段中。 - -设计约束: - -- `init` 失败不做复杂重试 -- 后续请求统一通过 helper 带上缓存 cookie -- 如果 cookie 为空,后续请求仍然允许继续发送基础请求头 - -### `homeContent` - -返回固定 7 个分类: - -- `ju -> 国剧` -- `zy -> 综艺` -- `mv -> 电影` -- `rh -> 日韩` -- `ym -> 英美` -- `wj -> 外剧` -- `dm -> 动漫` - -返回结构: - -```python -{"class": [...]} -``` - -不返回筛选项。 - -### `homeVideoContent` - -返回: - -```python -{"list": []} -``` - -### `categoryContent` - -分类 URL: - -- `/{cate_id}_{page}/` - -例如: - -- `/ju_1/` - -解析容器: - -- `body > div > div > main > div > ul > li` - -每个列表项提取: - -- `vod_id`:`div.a > a[href]` -- `vod_name`:`div.a > a` 文本 -- `vod_pic`:固定空字符串 -- `vod_remarks`:固定空字符串 - -结果结构包含: - -- `page` -- `limit` -- `total` -- `list` - -不返回 `pagecount`。 - -`limit` 采用站点参考中的每页基准值 `15`,`total` 采用仓库当前惯例的保守估算: - -- `total = (page - 1) * 15 + len(list)` - -这样可以避免伪造不可验证的总页数。 - -### `searchContent` - -搜索 URL: - -- `/search/?keyword={quote(keyword)}&page={page}` - -空关键词直接返回: - -```python -{"page": page, "total": 0, "list": []} -``` - -解析容器与字段同分类页: - -- `body > div > div > main > div > ul > li` -- `vod_id`:`div.a > a[href]` -- `vod_name`:`div.a > a` 文本 -- `vod_pic`:空字符串 -- `vod_remarks`:空字符串 - -返回结构包含: - -- `page` -- `total` -- `list` - -### `detailContent` - -输入 `vod_id` 为站内相对路径,例如: - -- `/post/123` - -内部请求时用 `urljoin` 拼成完整详情 URL。 - -详情页提取: - -- 标题:`body > div > div.s20erx.erx-m-bot.erx-content > main > article > h1` -- 分享链接:`#maximg > div.dlipp-cont-wp > div > div.dlipp-cont-bd > a[href]` - -输出字段: - -- `vod_id` -- `vod_name` -- `vod_pic` -- `vod_remarks` -- `vod_content` -- `vod_director` -- `vod_actor` -- `vod_play_from` -- `vod_play_url` - -其中: - -- `vod_pic` 固定空字符串 -- `vod_remarks` 固定空字符串 -- `vod_content` 固定空字符串 -- `vod_director` 固定空字符串 -- `vod_actor` 固定空字符串 - -### `playerContent` - -对已识别网盘分享链接返回: - -```python -{"parse": 0, "playUrl": "", "url": id} -``` - -对未识别链接返回: - -```python -{"parse": 0, "playUrl": "", "url": ""} -``` - -不返回 `route/id` 风格结构。 - -## URL 与请求设计 - -### 主域名 - -- `https://1.star2.cn` - -### 基础请求头 - -- `User-Agent`:沿用用户给定的浏览器 UA -- `Referer`:`https://1.star2.cn` - -### 请求 helper - -实现两个内部 helper: - -- `_headers()`:生成带 cookie 的请求头 -- `_get_html(url)`:统一请求 HTML 文本 - -`_headers()` 规则: - -- 始终带 `User-Agent` -- 始终带 `Referer` -- 仅在缓存 cookie 非空时追加 `cookie` - -`_get_html(url)` 规则: - -- 统一通过 `self.fetch` 请求 -- 失败时返回空字符串或由上层解析成空结果 -- 不在 helper 内猜测页面编码之外的站点行为 - -## 页面解析策略 - -### HTML 解析方式 - -优先使用仓库现有 `self.html()` + XPath,而不是引入 `BeautifulSoup` 新依赖。 - -原因: - -- 与当前仓库其他蜘蛛一致 -- 单测更容易直接构造 HTML 片段 -- 避免为一个站点引入不同解析风格 - -### 列表与搜索卡片 - -站点参考选择器较深,但字段实际很少,只提取以下最稳定的数据: - -- `href` -- 文字标题 - -如节点缺失,直接跳过当前卡片,不做兜底猜测。 - -### 详情页分享链接 - -详情页只收集 `a[href]` 中真实可用的分享链接: - -- 空链接跳过 -- 重复链接去重 -- 未识别网盘类型的链接跳过 - -## 网盘线路规则 - -支持识别以下 9 类网盘: - -- `quark`:夸克 -- `ali`:阿里 -- `115`:115 -- `tianyi`:天翼 -- `uc`:UC -- `baidu`:百度 -- `xunlei`:迅雷 -- `123pan`:123 -- `yd`:移动云盘 - -域名识别规则: - -- `quark`:`quark` -- `115`:`115.com` -- `tianyi`:`cloud.189.cn` -- `uc`:`drive.uc.cn` 或 `uc.cn` -- `baidu`:`pan.baidu.com` -- `xunlei`:`xunlei` -- `123pan`:`123pan` -- `yd`:`caiyun` 或 `139.com` -- `ali`:`aliyundrive` 或 `alipan` - -### 排序与命名 - -线路输出顺序固定为: - -1. `quark` -2. `ali` -3. `115` -4. `tianyi` -5. `uc` -6. `baidu` -7. `xunlei` -8. `123pan` -9. `yd` - -每个线路名使用稳定 key,而不是展示中文: - -- `vod_play_from = "quark$$$ali$$$baidu"...` - -每个线路内的剧集项使用展示名加 `$` 拼接: - -- `夸克资源$https://pan.quark.cn/s/demo` -- `百度资源$https://pan.baidu.com/s/demo` - -多个同类链接使用 `#` 拼接。 - -### 组装规则 - -详情页提取到的分享链接经过以下流程: - -1. 去空 -2. 去重 -3. 识别网盘类型 -4. 按预定义顺序分组 -5. 生成 `vod_play_from` -6. 生成 `vod_play_url` - -设计上不为同类链接额外制造 `夸克1/夸克2` 这样的条目名。 - -原因: - -- 当前仓库同类盘站一般使用统一展示名 -- 用户最终消费的是分享链接,不依赖人工编号 -- 可减少无意义字符串差异,便于测试断言 - -## 异常与空结果策略 - -### 分类页 / 搜索页 - -当请求失败、HTML 为空或解析不到卡片时,返回空列表,但结构保持稳定。 - -### 详情页 - -当请求失败或解析失败时,返回: - -- `{"list": []}` - -不返回半残缺占位对象。 - -原因: - -- 当前仓库多数新蜘蛛对详情失败采取空列表回退 -- 比返回字段为空但看似成功的对象更容易让调用方识别失败 - -### `playerContent` - -不尝试修正、跳转或二次解析链接: - -- 支持的网盘链接直接透传 -- 非支持链接直接返回空 `url` - -## 测试设计 - -测试文件: - -- `py/tests/test_双星.py` - -使用 `unittest` 与 `unittest.mock`,全部通过 mock 隔离网络访问。 - -核心测试点: - -1. `homeContent` 返回固定 7 个分类 -2. `homeVideoContent` 返回空列表 -3. `init` 能从首页响应 cookies 组装出 `a=1; b=2` 形式的缓存 cookie -4. `_headers()` 在 cookie 存在和不存在时都返回正确请求头 -5. `categoryContent` 组装正确 URL 并解析列表卡片 -6. `searchContent` 组装正确搜索 URL 并解析结果 -7. `searchContent` 对空关键词直接返回空列表 -8. `detailContent` 能提取标题和多类网盘链接 -9. `detailContent` 会去重、过滤未知链接并按固定顺序组装线路 -10. `detailContent` 在详情页请求失败时返回空列表 -11. `playerContent` 透传支持的网盘链接 -12. `playerContent` 拒绝非网盘链接 - -## 实现约束 - -- 不引入新的第三方依赖 -- 不修改 `base/` 公共行为 -- 不在 `playerContent` 中引入路由分发语义 -- 不伪造 `pagecount` -- 不把完整 URL 存成列表页 `vod_id` - -## 验收标准 - -完成后应满足: - -- `homeContent(False)` 返回预期分类 -- `categoryContent/searchContent` 能按参考 URL 组织请求并解析基础卡片 -- `detailContent` 正确输出 `vod_play_from` 和 `vod_play_url` -- `playerContent` 对支持网盘链接返回可直接消费的透传结构 -- 新增单测全部通过 diff --git a/py/tests/test_AAZ音乐.py b/py/tests/test_AAZ音乐.py deleted file mode 100644 index 5c5965a..0000000 --- a/py/tests/test_AAZ音乐.py +++ /dev/null @@ -1,219 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("aaz_music_spider", str(ROOT / "AAZ音乐.py")).load_module() -Spider = MODULE.Spider - - -HOME_HTML = """ -<html><body> - <ul> - <li> - <div class="name"><a href="/m/1001.html" title="晴天">晴天</a></div> - <div class="mv"><a href="/v/mv1001">MV</a></div> - </li> - <li> - <div class="name"><a href="/m/1002.html" title="七里香">七里香</a></div> - </li> - </ul> -</body></html> -""" - -SINGER_HTML = """ -<html><body> - <li> - <div class="pic"><img src="/img/singer.jpg"></div> - <div class="name"><a href="/s/jay" title="周杰伦">周杰伦</a></div> - </li> -</body></html> -""" - -PLAYLIST_HTML = """ -<html><body> - <li> - <div class="pic"><img src="/img/playlist.jpg"></div> - <div class="name"><a href="/p/top100" title="华语热歌">华语热歌</a></div> - </li> -</body></html> -""" - -ALBUM_HTML = """ -<html><body> - <li> - <div class="pic"><img src="/img/album.jpg"></div> - <div class="name"><a href="/a/fantasy" title="范特西">范特西</a></div> - </li> -</body></html> -""" - -MV_HTML = """ -<html><body> - <li> - <div class="pic"><img src="/img/mv.jpg"></div> - <div class="name"><a href="/v/qingtianmv" title="晴天MV">晴天MV</a></div> - </li> -</body></html> -""" - -SEARCH_HTML = """ -<html><body> - <ul> - <li><div class="name"><a href="/m/2001.html" title="夜曲">夜曲</a></div></li> - <li><div class="name"><a href="/s/jay" title="周杰伦">周杰伦</a></div></li> - <li><div class="name"><a href="/p/jaybest" title="周董精选">周董精选</a></div></li> - <li><div class="name"><a href="/a/fantasy" title="范特西">范特西</a></div></li> - <li><div class="name"><a href="/v/nocturnemv" title="夜曲MV">夜曲MV</a></div></li> - </ul> -</body></html> -""" - -SONG_DETAIL_HTML = """ -<html> - <head><meta name="description" content="夜曲歌曲简介"></head> - <body> - <div class="djname"><h1>夜曲<a href="javascript:location.reload()">刷新</a></h1></div> - <div class="name"><a href="/s/jay">周杰伦</a></div> - 所属专辑:<a href="/a/fantasy">范特西</a> - <img class="rotate" id="mcover" src="/img/song_detail.jpg"> - <div>歌曲时长:03:45</div> - </body> -</html> -""" - -FOLDER_DETAIL_HTML = """ -<html><body> - <div class="title"><h1>周董精选</h1></div> - <div class="pic"><img src="/img/folder_detail.jpg"></div> - <div class="info">经典歌曲合集</div> - <ul> - <li><div class="name"><a href="/m/3001.html" title="安静">安静</a></div></li> - <li><div class="name"><a href="/m/3002.html" title="晴天">晴天</a></div></li> - </ul> -</body></html> -""" - -PLAY_JSON = '{"url":"https://cdn.example.com/aaz/song2001.mp3","pic":"https://cdn.example.com/cover.jpg"}' -EMPTY_PLAY_JSON = '{"url":""}' - - -class TestAAZMusicSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - @patch.object(Spider, "fetch") - def test_home_content_returns_fixed_classes_and_song_cards(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=HOME_HTML) - result = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in result["class"]], - ["new", "top", "singer", "playtype", "album", "mv"], - ) - self.assertEqual([item["vod_id"] for item in result["list"]], ["song:1001", "song:1002"]) - self.assertEqual(result["list"][0]["vod_name"], "晴天") - self.assertEqual(result["list"][0]["vod_remarks"], "高清MV") - - @patch.object(Spider, "fetch") - def test_home_video_content_reuses_home_list(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=HOME_HTML) - result = self.spider.homeVideoContent() - self.assertEqual([item["vod_id"] for item in result["list"]], ["song:1001", "song:1002"]) - - @patch.object(Spider, "fetch") - def test_category_content_maps_song_and_folder_types(self, mock_fetch): - mock_fetch.side_effect = [ - SimpleNamespace(status_code=200, text=HOME_HTML), - SimpleNamespace(status_code=200, text=HOME_HTML), - SimpleNamespace(status_code=200, text=SINGER_HTML), - SimpleNamespace(status_code=200, text=PLAYLIST_HTML), - SimpleNamespace(status_code=200, text=ALBUM_HTML), - SimpleNamespace(status_code=200, text=MV_HTML), - ] - self.assertEqual(self.spider.categoryContent("new", "1", False, {})["list"][0]["vod_id"], "song:1001") - self.assertEqual(self.spider.categoryContent("top", "1", False, {})["list"][1]["vod_id"], "song:1002") - self.assertEqual(self.spider.categoryContent("singer", "1", False, {})["list"][0]["vod_id"], "singer:jay") - self.assertEqual(self.spider.categoryContent("playtype", "1", False, {})["list"][0]["vod_id"], "playlist:top100") - self.assertEqual(self.spider.categoryContent("album", "1", False, {})["list"][0]["vod_id"], "album:fantasy") - self.assertEqual(self.spider.categoryContent("mv", "1", False, {})["list"][0]["vod_id"], "mv:qingtianmv") - - def test_category_content_returns_empty_for_unknown_type(self): - self.assertEqual( - self.spider.categoryContent("bad", "1", False, {}), - {"page": 1, "limit": 0, "total": 0, "list": []}, - ) - - @patch.object(Spider, "fetch") - def test_search_content_maps_mixed_result_types(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=SEARCH_HTML) - result = self.spider.searchContent("周杰伦", False, "1") - self.assertEqual( - [item["vod_id"] for item in result["list"]], - ["song:2001", "singer:jay", "playlist:jaybest", "album:fantasy", "mv:nocturnemv"], - ) - - def test_search_content_returns_empty_for_blank_keyword(self): - self.assertEqual( - self.spider.searchContent("", False, "1"), - {"page": 1, "limit": 0, "total": 0, "list": []}, - ) - - @patch.object(Spider, "fetch") - def test_detail_content_builds_song_metadata_and_single_play_url(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=SONG_DETAIL_HTML) - vod = self.spider.detailContent(["song:2001"])["list"][0] - self.assertEqual(vod["vod_name"], "夜曲") - self.assertEqual(vod["vod_pic"], "https://www.aaz.cx/img/song_detail.jpg") - self.assertEqual(vod["vod_remarks"], "周杰伦 | 范特西 | 03:45") - self.assertEqual(vod["vod_play_from"], "AAZ音乐") - self.assertEqual(vod["vod_play_url"], "播放$song:2001") - - @patch.object(Spider, "fetch") - def test_detail_content_builds_folder_track_list_for_singer_playlist_album_and_mv(self, mock_fetch): - mock_fetch.side_effect = [ - SimpleNamespace(status_code=200, text=FOLDER_DETAIL_HTML), - SimpleNamespace(status_code=200, text=FOLDER_DETAIL_HTML), - SimpleNamespace(status_code=200, text=FOLDER_DETAIL_HTML), - SimpleNamespace(status_code=200, text=FOLDER_DETAIL_HTML), - ] - singer_vod = self.spider.detailContent(["singer:jay"])["list"][0] - playlist_vod = self.spider.detailContent(["playlist:jaybest"])["list"][0] - album_vod = self.spider.detailContent(["album:fantasy"])["list"][0] - mv_vod = self.spider.detailContent(["mv:nocturnemv"])["list"][0] - expected = "安静$song:3001#晴天$song:3002" - self.assertEqual(singer_vod["vod_play_url"], expected) - self.assertEqual(playlist_vod["vod_play_url"], expected) - self.assertEqual(album_vod["vod_play_url"], expected) - self.assertEqual(mv_vod["vod_play_url"], expected) - - def test_detail_content_returns_empty_list_for_invalid_vod_id(self): - self.assertEqual(self.spider.detailContent(["bad"])["list"], []) - - @patch.object(Spider, "post") - def test_player_content_resolves_song_id_to_direct_url(self, mock_post): - mock_post.return_value = SimpleNamespace(status_code=200, text=PLAY_JSON) - result = self.spider.playerContent("AAZ音乐", "song:2001", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://cdn.example.com/aaz/song2001.mp3") - self.assertEqual(result["header"]["Referer"], "https://www.aaz.cx/") - self.assertEqual(mock_post.call_args.args[0], "https://www.aaz.cx/js/play.php") - self.assertEqual(mock_post.call_args.kwargs["data"], "id=2001&type=music") - self.assertEqual( - mock_post.call_args.kwargs["headers"]["Content-Type"], - "application/x-www-form-urlencoded; charset=UTF-8", - ) - - @patch.object(Spider, "post") - def test_player_content_returns_empty_url_for_invalid_or_empty_payload(self, mock_post): - mock_post.return_value = SimpleNamespace(status_code=200, text=EMPTY_PLAY_JSON) - self.assertEqual(self.spider.playerContent("AAZ音乐", "song:2001", {})["url"], "") - self.assertEqual(self.spider.playerContent("AAZ音乐", "bad", {})["url"], "") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_PPnix.py b/py/tests/test_PPnix.py deleted file mode 100644 index 2120c5c..0000000 --- a/py/tests/test_PPnix.py +++ /dev/null @@ -1,171 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("ppnix_spider", str(ROOT / "PPnix.py")).load_module() -Spider = MODULE.Spider - - -class TestPPnixSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories_and_filter_keys(self): - content = self.spider.homeContent(False) - self.assertEqual([item["type_id"] for item in content["class"]], ["1", "2"]) - self.assertEqual([item["key"] for item in content["filters"]["1"]], ["class", "by"]) - self.assertEqual([item["key"] for item in content["filters"]["2"]], ["class", "by"]) - - def test_build_category_url_maps_first_page_and_sort_values(self): - self.assertEqual( - self.spider._build_category_url("1", "1", {}), - "https://www.ppnix.com/cn/movie/----newstime.html", - ) - self.assertEqual( - self.spider._build_category_url("2", "3", {"class": "爱情", "by": "hits"}), - "https://www.ppnix.com/cn/tv/爱情---2-onclick.html", - ) - - def test_build_search_url_uses_ppnix_pattern(self): - self.assertEqual( - self.spider._build_search_url("繁花", "1"), - "https://www.ppnix.com/cn/search/%E7%B9%81%E8%8A%B1--.html", - ) - self.assertEqual( - self.spider._build_search_url("繁花", "2"), - "https://www.ppnix.com/cn/search/%E7%B9%81%E8%8A%B1--.html-page-2", - ) - - def test_parse_cards_extracts_short_vod_id_title_cover_and_remarks(self): - html = """ - <ul> - <li> - <a class="thumbnail" href="/cn/movie/123.html"> - <img class="thumb" src="/poster.jpg" alt="示例影片" /> - </a> - <footer><span class="rate">HD</span></footer> - </li> - </ul> - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "movie/123.html", - "vod_name": "示例影片", - "vod_pic": "https://www.ppnix.com/poster.jpg", - "vod_remarks": "HD", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_merges_movie_and_tv_cards(self, mock_request_html): - mock_request_html.return_value = """ - <div class="lists-content"> - <ul> - <li><a class="thumbnail" href="/cn/movie/101.html"><img class="thumb" src="/m.jpg" alt="电影一" /></a><footer><span class="rate">HD</span></footer></li> - </ul> - </div> - <div class="lists-content"> - <ul> - <li><a class="thumbnail" href="/cn/tv/201.html"><img class="thumb" src="/t.jpg" alt="剧集一" /></a><footer><span class="rate">更新中</span></footer></li> - </ul> - </div> - """ - result = self.spider.homeVideoContent() - self.assertEqual([item["vod_id"] for item in result["list"]], ["movie/101.html", "tv/201.html"]) - - @patch.object(Spider, "_request_html") - def test_category_content_uses_expected_listing_url(self, mock_request_html): - mock_request_html.return_value = """ - <div class="lists-content"><ul> - <li><a class="thumbnail" href="/cn/movie/301.html"><img class="thumb" src="/c.jpg" alt="分类影片" /></a><footer><span class="rate">HD</span></footer></li> - </ul></div> - """ - result = self.spider.categoryContent("1", "2", False, {"class": "动作", "by": "score"}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.ppnix.com/cn/movie/动作---1-rating.html", - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "movie/301.html") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_parses_only_movie_and_tv_ids(self, mock_request_html): - mock_request_html.return_value = """ - <div class="lists-content"><ul> - <li><a class="thumbnail" href="/cn/movie/401.html"><img class="thumb" src="/s1.jpg" alt="搜索电影" /></a></li> - <li><a class="thumbnail" href="/cn/topic/ignore.html"><img class="thumb" src="/s2.jpg" alt="忽略条目" /></a></li> - </ul></div> - """ - result = self.spider.searchContent("搜索词", False, "1") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.ppnix.com/cn/search/%E6%90%9C%E7%B4%A2%E8%AF%8D--.html", - ) - self.assertEqual([item["vod_id"] for item in result["list"]], ["movie/401.html"]) - self.assertNotIn("pagecount", result) - - def test_extract_m3u8_items_reads_infoid_and_episode_names(self): - html = """ - <script> - var infoid = 7788; - var m3u8 = ["第1集", "第2集"]; - </script> - """ - self.assertEqual( - self.spider._extract_m3u8_items(html), - {"info_id": "7788", "items": ["第1集", "第2集"]}, - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_ppnix_play_group(self, mock_request_html): - mock_request_html.return_value = """ - <h1 class="product-title">示例剧 (2025)</h1> - <header class="product-header"><img class="thumb" src="/poster.jpg" /></header> - <div class="product-excerpt">导演:<span>导演甲</span></div> - <div class="product-excerpt">主演:<span>演员甲 / 演员乙</span></div> - <div class="product-excerpt">简介:一段剧情简介</div> - <script> - var infoid = 8899; - var m3u8 = ["第1集", "第2集"]; - </script> - """ - result = self.spider.detailContent(["tv/8899.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "tv/8899.html") - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["vod_pic"], "https://www.ppnix.com/poster.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"], "PPnix") - self.assertEqual( - vod["vod_play_url"], - "第1集$8899|%E7%AC%AC1%E9%9B%86#第2集$8899|%E7%AC%AC2%E9%9B%86", - ) - - def test_player_content_returns_direct_m3u8_url(self): - result = self.spider.playerContent("PPnix", "8899|%E7%AC%AC1%E9%9B%86", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://www.ppnix.com/info/m3u8/8899/%E7%AC%AC1%E9%9B%86.m3u8") - self.assertEqual(result["header"]["Origin"], "https://www.ppnix.com") - - def test_player_content_falls_back_when_play_id_is_invalid(self): - result = self.spider.playerContent("PPnix", "broken", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "broken") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_dbku.py b/py/tests/test_dbku.py deleted file mode 100644 index 38430dc..0000000 --- a/py/tests/test_dbku.py +++ /dev/null @@ -1,237 +0,0 @@ -import unittest -import base64 -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("dbku_spider", str(ROOT / "独播库.py")).load_module() -Spider = MODULE.Spider - - -class TestDBKUSpider(unittest.TestCase): - def setUp(self): - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - class_ids = [item["type_id"] for item in content["class"]] - self.assertEqual(class_ids, ["index", "movie", "variety", "anime", "hk", "luju"]) - - def test_parse_list_cards_extracts_detail_url_title_cover_and_description(self): - html = """ - <div class="myui-vodlist__box"> - <a class="thumb" href="/voddetail/123.html" title="示例影片" data-original="https://img.example/dbku.jpg"></a> - <span class="pic-text">更新至10集</span> - </div> - """ - cards = self.spider._parse_list_cards(html) - self.assertEqual( - cards, - [ - { - "vod_id": "123", - "vod_name": "示例影片", - "vod_pic": "https://img.example/dbku.jpg", - "vod_remarks": "更新至10集", - } - ], - ) - - @patch.object(Spider, "fetch") - def test_request_html_uses_dbku_headers(self, mock_fetch): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse("<html><body>ok</body></html>") - html = self.spider._request_html("/vodtype/1--------1---.html", expect_xpath="//body") - self.assertIn("ok", html) - called_headers = mock_fetch.call_args.kwargs["headers"] - self.assertEqual(called_headers["Referer"], "https://www.dbku.tv") - self.assertEqual(called_headers["Origin"], "https://www.dbku.tv") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_page_result(self, mock_request_html): - mock_request_html.return_value = """ - <div class="myui-vodlist__box"> - <a href="/voddetail/456.html" title="分类影片" data-original="/cover.jpg"></a> - <span class="pic-text">HD</span> - </div> - """ - result = self.spider.categoryContent("movie", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertEqual(result["list"][0]["vod_pic"], "https://www.dbku.tv/cover.jpg") - - def test_parse_search_cards_prefers_search_list_container(self): - html = """ - <div id="searchList"> - <div class="myui-vodlist__box"> - <a href="/voddetail/789.html" title="搜索命中" data-original="/search.jpg"></a> - </div> - </div> - <div class="myui-vodlist__box"> - <a href="/voddetail/999.html" title="回退结果" data-original="/fallback.jpg"></a> - </div> - """ - results = self.spider._parse_search_cards(html) - self.assertEqual(len(results), 1) - self.assertEqual(results[0]["vod_name"], "搜索命中") - - @patch.object(Spider, "_request_html") - def test_search_content_reuses_search_parser(self, mock_request_html): - mock_request_html.return_value = """ - <div id="searchList"> - <div class="myui-vodlist__box"> - <a href="/voddetail/321.html" title="搜索影片" data-original="/search.jpg"></a> - </div> - </div> - """ - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(result["list"][0]["vod_id"], "321") - self.assertEqual(result["list"][0]["vod_name"], "搜索影片") - - def test_parse_detail_page_extracts_meta_and_episodes(self): - html = """ - <div class="myui-content__thumb"> - <img data-original="/poster.jpg" /> - </div> - <div class="myui-content__detail"> - <h1 class="title">独播剧</h1> - <p>年份:2025</p> - <p>地区:大陆</p> - <p>导演:张三</p> - <p>主演:李四</p> - </div> - <span class="data">一段剧情简介</span> - <a href="/vodplay/100-1-1.html">第1集</a> - <a href="/vodplay/100-1-2.html">第2集</a> - """ - result = self.spider._parse_detail_page(html, "100") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "100") - self.assertEqual(vod["vod_name"], "独播剧") - self.assertEqual(vod["vod_year"], "2025") - self.assertEqual(vod["vod_play_from"], "独播库") - self.assertIn("第1集$100-1-1", vod["vod_play_url"]) - - def test_parse_detail_page_extracts_structured_fields_from_detail_block(self): - html = """ - <div class="myui-content__thumb"> - <img data-original="/poster-ai.jpg" /> - </div> - <div class="myui-content__detail"> - <h1 class="title">AI教我谈恋爱</h1> - <div id="rating" class="score" data-mid="1" data-id="143508" data-score="3"> - <span class="branch">6</span> - </div> - <p class="data"> - <span class="text-muted">分类:</span><a href="/vodshow/21-----------.html">短剧</a> - <span class="split-line"></span> - <span class="text-muted hidden-xs">地区:</span><a href="/vodshow/21-大陆----------.html">大陆</a> - <span class="split-line"></span> - <span class="text-muted hidden-xs">年份:</span><a href="/vodshow/21-----------2026.html">2026</a> - </p> - <p class="data hidden-sm"><span class="text-muted">更新:</span><span class="text-red">2026-04-18 09:59:00</span></p> - <p class="data"> - <span class="text-muted">主演:</span> - <a href="/vodsearch/-%E7%AB%A0%E7%85%9C%E5%A5%87------------.html" target="_blank">章煜奇</a>  - <a href="/vodsearch/-%E8%A2%81%E4%BC%8A------------.html" target="_blank">袁伊</a>  - <a href="/vodsearch/-%E9%83%AD%E5%AD%90%E6%B8%9D------------.html" target="_blank">郭子渝</a>  - <a href="/vodsearch/-%E5%88%98%E5%B8%8C%E5%A9%A7------------.html" target="_blank">刘希婧</a>  - </p> - <p class="data"> - <span class="text-muted">导演:</span> - <a href="/vodsearch/-----%E5%BC%A0%E4%B8%96%E5%8D%9A--------.html" target="_blank">张世博</a>  - <a href="/vodsearch/-----%E6%BA%90%E8%AF%97%E5%98%89--------.html" target="_blank">源诗嘉</a>  - </p> - <p class="data hidden-xs"><span class="text-muted">简介:</span>《AI教我谈恋爱》线上看,共26集,《AI教我谈恋爱》简介:漫画家袁七柚意外绑定...<a href="#desc">详情</a></p> - </div> - <a href="/vodplay/143508-1-1.html">第1集</a> - """ - result = self.spider._parse_detail_page(html, "143508") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "143508") - self.assertEqual(vod["path"], "https://www.dbku.tv/voddetail/143508.html") - self.assertEqual(vod["vod_pic"], "https://www.dbku.tv/poster-ai.jpg") - self.assertEqual(vod["type_name"], "短剧") - self.assertEqual(vod["vod_area"], "大陆") - self.assertEqual(vod["vod_year"], "2026") - self.assertEqual(vod["vod_time"], "2026-04-18 09:59:00") - self.assertEqual(vod["vod_actor"], "章煜奇,袁伊,郭子渝,刘希婧") - self.assertEqual(vod["vod_director"], "张世博,源诗嘉") - self.assertEqual(vod["vod_content"], "《AI教我谈恋爱》线上看,共26集,《AI教我谈恋爱》简介:漫画家袁七柚意外绑定...") - self.assertEqual(vod["vod_play_url"], "第1集$143508-1-1") - self.assertEqual(vod["vod_tag"], "") - self.assertEqual(vod["vod_remarks"], "6") - self.assertEqual(vod["vod_lang"], "") - self.assertNotIn("dbid", vod) - self.assertNotIn("type", vod) - - @patch.object(Spider, "_request_html") - def test_detail_content_reads_from_vod_id_url(self, mock_request_html): - mock_request_html.return_value = """ - <h1 class="title">详情影片</h1> - <a href="/vodplay/200-1-1.html">第1集</a> - """ - result = self.spider.detailContent(["200"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.dbku.tv/voddetail/200.html") - self.assertEqual(result["list"][0]["vod_id"], "200") - self.assertEqual(result["list"][0]["vod_name"], "详情影片") - - def test_parse_player_data_reads_json_block(self): - html = '<script>var player_data = {"url":"https://video.example/a.m3u8","encrypt":"0"};</script>' - data = self.spider._parse_player_data(html) - self.assertEqual(data["url"], "https://video.example/a.m3u8") - - def test_decode_play_url_by_encrypt_supports_modes_0_1_2_3(self): - self.assertEqual( - self.spider._decode_play_url_by_encrypt("https://video.example/raw.m3u8", 0), - "https://video.example/raw.m3u8", - ) - self.assertEqual( - self.spider._decode_play_url_by_encrypt("https%3A//video.example/escape.m3u8", 1), - "https://video.example/escape.m3u8", - ) - mode2 = base64.b64encode("https://video.example/base64.m3u8".encode("utf-8")).decode("utf-8") - self.assertEqual( - self.spider._decode_play_url_by_encrypt(mode2, 2), - "https://video.example/base64.m3u8", - ) - mode3_raw = "ABCDEFGHhttps://video.example/trimmed.m3u8HGFEDCBA" - mode3 = base64.b64encode(mode3_raw.encode("utf-8")).decode("utf-8") - self.assertEqual( - self.spider._decode_play_url_by_encrypt("12345678" + mode3, 3), - "https://video.example/trimmed.m3u8", - ) - - @patch.object(Spider, "_request_html") - def test_player_content_returns_decoded_direct_url(self, mock_request_html): - mock_request_html.return_value = """ - <script> - var player_data = {"url":"https://video.example/final.m3u8","encrypt":"0"}; - </script> - """ - result = self.spider.playerContent("独播库", "100-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/final.m3u8") - self.assertEqual(result["header"]["Referer"], "https://www.dbku.tv/vodplay/100-1-1.html") - - @patch.object(Spider, "_request_html") - def test_player_content_follows_internal_jump(self, mock_request_html): - mock_request_html.side_effect = [ - '<script>var player_data = {"url":"/vodplay/100-1-2.html","encrypt":"0"};</script>', - '<script>var player_data = {"url":"https://video.example/jump-final.m3u8","encrypt":"0"};</script>', - ] - result = self.spider.playerContent("独播库", "100-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/jump-final.m3u8") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_libvio.py b/py/tests/test_libvio.py deleted file mode 100644 index e350e85..0000000 --- a/py/tests/test_libvio.py +++ /dev/null @@ -1,208 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("libvio_spider", str(ROOT / "libvio.py")).load_module() -Spider = MODULE.Spider - - -class TestLibVioSpider(unittest.TestCase): - def setUp(self): - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - class_ids = [item["type_id"] for item in content["class"]] - self.assertEqual(class_ids, ["index", "movie", "series", "anime", "jpandkr", "euandus"]) - - def test_parse_list_cards_extracts_compact_vod_id(self): - html = """ - <div class="stui-vodlist__box"> - <a class="stui-vodlist__thumb" href="/detail/456.html" title="示例影片" data-original="/cover.jpg"></a> - <span class="pic-text text-right">更新至10集</span> - </div> - """ - cards = self.spider._parse_list_cards(html) - self.assertEqual( - cards, - [ - { - "vod_id": "456", - "vod_name": "示例影片", - "vod_pic": "https://www.libvio.la/cover.jpg", - "vod_remarks": "更新至10集", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_reuses_list_card_parser(self, mock_request_html): - mock_request_html.return_value = """ - <div class="stui-vodlist__box"> - <a class="stui-vodlist__thumb" href="/detail/111.html" title="最近更新" data-original="/recent.jpg"></a> - <span class="pic-text text-right">HD</span> - </div> - """ - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_id"], "111") - self.assertEqual(result["list"][0]["vod_name"], "最近更新") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_page_result(self, mock_request_html): - mock_request_html.return_value = """ - <div class="stui-vodlist__box"> - <a class="stui-vodlist__thumb" href="/detail/222.html" title="分类影片" data-original="/cate.jpg"></a> - <span class="pic-text text-right">完结</span> - </div> - """ - result = self.spider.categoryContent("movie", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "222") - - @patch.object(Spider, "_request_html") - def test_search_content_reuses_card_parser(self, mock_request_html): - mock_request_html.return_value = """ - <div class="stui-vodlist__box"> - <a class="stui-vodlist__thumb" href="/detail/333.html" title="搜索影片" data-original="/search.jpg"></a> - <span class="pic-text text-right">抢先版</span> - </div> - """ - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(result["list"][0]["vod_id"], "333") - self.assertEqual(result["list"][0]["vod_name"], "搜索影片") - - def test_parse_detail_page_extracts_fields_and_preserves_pan_sources(self): - html = """ - <div class="stui-content__thumb"> - <img data-original="/poster.jpg" /> - </div> - <div class="stui-content__detail"> - <h1 class="title">示例剧</h1> - <p><span class="text-muted">类型:</span><a>剧情</a></p> - <p><span class="text-muted">地区:</span><a>大陆</a></p> - <p><span class="text-muted">年份:</span><a>2026</a></p> - <p><span class="text-muted">导演:</span><a>张三</a></p> - <p><span class="text-muted">主演:</span><a>李四</a><a>王五</a></p> - <p><span class="text-muted">简介:</span>一段剧情简介</p> - </div> - <h3 class="title">在线播放</h3> - <ul class="stui-content__playlist clearfix"> - <li><a href="/play/999-1-1.html">第1集</a></li> - <li><a href="/play/999-1-2.html">第2集</a></li> - </ul> - <h3 class="title">夸克资源</h3> - <ul class="stui-content__playlist clearfix"> - <li><a href="/play/pan-1.html">网盘</a></li> - </ul> - <div class="playlist-panel netdisk-panel"> - <div class="panel-head netdisk-head"> - <div class="netdisk-head-inner"> - <h3>视频下载(UC)</h3> - </div> - </div> - <div class="netdisk-list"> - <a class="netdisk-item" href="https://drive.uc.cn/s/e1532998c2bf4?public=1" target="_blank"> - <span class="netdisk-name">合集</span> - <span class="netdisk-url">https://drive.uc.cn/s/e1532998c2bf4?public=1</span> - </a> - </div> - </div> - """ - result = self.spider._parse_detail_page(html, "999") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "999") - self.assertEqual(vod["path"], "https://www.libvio.la/detail/999.html") - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["type_name"], "剧情") - self.assertEqual(vod["vod_area"], "大陆") - self.assertEqual(vod["vod_year"], "2026") - self.assertEqual(vod["vod_director"], "张三") - self.assertEqual(vod["vod_actor"], "李四,王五") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_play_from"], "LibVIO$$$UC") - self.assertEqual( - vod["vod_play_url"], - "第1集$999-1-1#第2集$999-1-2$$$合集$https://drive.uc.cn/s/e1532998c2bf4?public=1", - ) - - def test_extract_netdisk_sources_deduplicates_same_link(self): - html = """ - <div class="playlist-panel netdisk-panel"> - <div class="panel-head netdisk-head"> - <div class="netdisk-head-inner"> - <h3>视频下载(UC)</h3> - </div> - </div> - <div class="netdisk-list"> - <a class="netdisk-item" href="https://drive.uc.cn/s/e1532998c2bf4?public=1" target="_blank"> - <span class="netdisk-name">合集</span> - <span class="netdisk-url">https://drive.uc.cn/s/e1532998c2bf4?public=1</span> - </a> - <a class="netdisk-item" href="https://drive.uc.cn/s/e1532998c2bf4?public=1" target="_blank"> - <span class="netdisk-name">一键复制</span> - <span class="netdisk-url">https://drive.uc.cn/s/e1532998c2bf4?public=1</span> - </a> - </div> - </div> - """ - self.assertEqual( - self.spider._extract_netdisk_sources(html), - [{"from": "UC", "urls": "合集$https://drive.uc.cn/s/e1532998c2bf4?public=1"}], - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_detail_request_url_from_vod_id(self, mock_request_html): - mock_request_html.return_value = """ - <h1 class="title">详情影片</h1> - <ul class="stui-content__playlist"> - <li><a href="/play/123-1-1.html">第1集</a></li> - </ul> - """ - result = self.spider.detailContent(["123"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.libvio.la/detail/123.html") - self.assertEqual(result["list"][0]["vod_id"], "123") - - def test_extract_player_config_reads_json_assignment(self): - html = '<script>var player_aaaa={"url":"abc","from":"line","id":"1","nid":"2"};</script>' - data = self.spider._parse_player_config(html) - self.assertEqual(data["from"], "line") - - def test_extract_play_api_base_reads_player_js(self): - body = 'var player={}; src="/player/api.php?url=";' - self.assertEqual(self.spider._extract_play_api_base(body), "https://www.libvio.la/player/api.php?url=") - - @patch.object(Spider, "_request_html") - def test_player_content_resolves_direct_api_url(self, mock_request_html): - mock_request_html.side_effect = [ - '<script>var player_x={"url":"https://up.example/id","from":"line","id":"11","nid":"22","link_next":"next"};</script>', - 'src="/player/api.php?url="', - 'var urls="https://video.example/final.m3u8";', - ] - result = self.spider.playerContent("LibVIO", "999-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/final.m3u8") - self.assertEqual(result["header"]["Referer"], "https://www.libvio.la/") - - @patch.object(Spider, "_request_html") - def test_player_content_returns_empty_for_pan_source(self, mock_request_html): - mock_request_html.return_value = '<script>var player_x={"url":"abc","from":"kuake"};</script>' - self.assertEqual(self.spider.playerContent("LibVIO", "999-1-1", {}), {"parse": 0, "playUrl": "", "url": ""}) - - def test_player_content_returns_direct_netdisk_link(self): - result = self.spider.playerContent("UC", "https://drive.uc.cn/s/e1532998c2bf4?public=1", {}) - self.assertEqual( - result, - { - "parse": 0, - "playUrl": "", - "url": "https://drive.uc.cn/s/e1532998c2bf4?public=1", - }, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_secspider.py b/py/tests/test_secspider.py deleted file mode 100644 index 9c84b3a..0000000 --- a/py/tests/test_secspider.py +++ /dev/null @@ -1,289 +0,0 @@ -import base64 -import contextlib -import io -import tempfile -import unittest -from pathlib import Path - -from Crypto.Cipher import AES -from Crypto.Hash import SHA256 -from Crypto.Protocol.KDF import HKDF -from Crypto.PublicKey import ECC -from Crypto.Signature import eddsa - -from base.secspider import build_secspider_package -from secspider_tool import main as secspider_main - - -class TestSecSpiderBuilder(unittest.TestCase): - def test_build_package_emits_required_headers_and_payload(self): - private_key = ECC.generate(curve="Ed25519") - package_text = build_secspider_package( - source_text="class Spider:\n pass\n", - name="[直] omofun", - version="1", - remark="", - kid="kid-1", - signing_private_key=private_key, - master_secret=b"0123456789abcdef0123456789abcdef", - ) - - self.assertIn("//@name:[直] omofun", package_text) - self.assertIn("//@version:1", package_text) - self.assertIn("//@remark:", package_text) - self.assertIn("//@format:secspider/1", package_text) - self.assertIn("//@alg:aes-256-gcm", package_text) - self.assertIn("//@wrap:hkdf-aes-keywrap", package_text) - self.assertIn("//@sign:ed25519", package_text) - self.assertIn("//@kid:kid-1", package_text) - self.assertIn("//@nonce:base64:", package_text) - self.assertIn("//@ek:base64:", package_text) - self.assertIn("//@hash:sha256:", package_text) - self.assertIn("//@sig:base64:", package_text) - self.assertIn("payload.base64:", package_text) - - def test_build_package_signature_verifies_over_headers_and_payload(self): - private_key = ECC.generate(curve="Ed25519") - package_text = build_secspider_package( - source_text="class Spider:\n pass\n", - name="fixture", - version="3", - remark="demo", - kid="kid-sign", - signing_private_key=private_key, - master_secret=b"0123456789abcdef0123456789abcdef", - ) - - headers = {} - payload_b64 = "" - for line in package_text.splitlines(): - if line.startswith("//@"): - key, _, value = line[3:].partition(":") - headers[key] = value - elif line.startswith("payload.base64:"): - payload_b64 = line.removeprefix("payload.base64:") - - signing_bytes = "\n".join( - [ - f"//@name:{headers['name']}", - f"//@version:{headers['version']}", - f"//@remark:{headers['remark']}", - f"//@format:{headers['format']}", - f"//@alg:{headers['alg']}", - f"//@wrap:{headers['wrap']}", - f"//@sign:{headers['sign']}", - f"//@kid:{headers['kid']}", - f"//@nonce:{headers['nonce']}", - f"//@ek:{headers['ek']}", - f"//@hash:{headers['hash']}", - f"payload.base64:{payload_b64}", - ] - ).encode("utf-8") - signature = base64.b64decode(headers["sig"].removeprefix("base64:")) - - verifier = eddsa.new(private_key.public_key(), "rfc8032") - verifier.verify(signing_bytes, signature) - - def test_build_package_encrypts_source_and_wraps_content_key(self): - private_key = ECC.generate(curve="Ed25519") - master_secret = b"0123456789abcdef0123456789abcdef" - source_text = "class Spider:\n value = 'plain'\n" - package_text = build_secspider_package( - source_text=source_text, - name="fixture", - version="5", - remark="", - kid="kid-wrap", - signing_private_key=private_key, - master_secret=master_secret, - ) - - self.assertNotIn(source_text, package_text) - headers = {} - payload_b64 = "" - for line in package_text.splitlines(): - if line.startswith("//@"): - key, _, value = line[3:].partition(":") - headers[key] = value - elif line.startswith("payload.base64:"): - payload_b64 = line.removeprefix("payload.base64:") - - wrap_key = HKDF( - master=master_secret, - key_len=32, - salt=headers["kid"].encode("utf-8"), - hashmod=SHA256, - num_keys=1, - context=f"secspider:{headers['name']}:{headers['version']}:wrap-key".encode("utf-8"), - ) - wrap_nonce = HKDF( - master=master_secret, - key_len=12, - salt=headers["kid"].encode("utf-8"), - hashmod=SHA256, - num_keys=1, - context=f"secspider:{headers['name']}:{headers['version']}:wrap-nonce".encode("utf-8"), - ) - wrap_blob = base64.b64decode(headers["ek"].removeprefix("base64:")) - wrap_cipher = AES.new(wrap_key, AES.MODE_GCM, nonce=wrap_nonce) - content_key = wrap_cipher.decrypt_and_verify(wrap_blob[:-16], wrap_blob[-16:]) - - payload_blob = base64.b64decode(payload_b64) - payload_nonce = base64.b64decode(headers["nonce"].removeprefix("base64:")) - payload_cipher = AES.new(content_key, AES.MODE_GCM, nonce=payload_nonce) - decrypted = payload_cipher.decrypt_and_verify(payload_blob[:-16], payload_blob[-16:]).decode("utf-8") - - self.assertEqual(decrypted, source_text) - - -class TestSecSpiderCli(unittest.TestCase): - def test_genkeys_writes_private_and_public_key_files(self): - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - private_path = temp_path / "signing-private.pem" - public_path = temp_path / "signing-public.pem" - stdout = io.StringIO() - - with contextlib.redirect_stdout(stdout): - exit_code = secspider_main( - [ - "genkeys", - "--private-key", - str(private_path), - "--public-key", - str(public_path), - ] - ) - - self.assertEqual(exit_code, 0) - self.assertTrue(private_path.is_file()) - self.assertTrue(public_path.is_file()) - self.assertIn("wrote", stdout.getvalue()) - - def test_pack_builds_secspider_file_from_source_and_key_files(self): - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - private_path = temp_path / "signing-private.pem" - public_path = temp_path / "signing-public.pem" - secret_path = temp_path / "master-secret.txt" - source_path = temp_path / "fixture.py" - output_path = temp_path / "fixture.sec.py" - - secspider_main( - [ - "genkeys", - "--private-key", - str(private_path), - "--public-key", - str(public_path), - ] - ) - secret_path.write_text("0123456789abcdef0123456789abcdef", encoding="utf-8") - source_path.write_text("class Spider:\n pass\n", encoding="utf-8") - - exit_code = secspider_main( - [ - "pack", - "--input", - str(source_path), - "--output", - str(output_path), - "--name", - "fixture", - "--version", - "9", - "--remark", - "", - "--kid", - "kid-cli", - "--private-key", - str(private_path), - "--master-secret-file", - str(secret_path), - ] - ) - - self.assertEqual(exit_code, 0) - package_text = output_path.read_text(encoding="utf-8") - self.assertIn("//@name:fixture", package_text) - self.assertIn("//@version:9", package_text) - self.assertIn("//@kid:kid-cli", package_text) - self.assertIn("payload.base64:", package_text) - - def test_pack_uses_default_name_version_and_secret_file_paths(self): - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - source_path = temp_path / "红果短剧.py" - output_path = temp_path / "红果短剧.sec.py" - private_path = temp_path / "signing-private.pem" - public_path = temp_path / "signing-public.pem" - secret_path = temp_path / "master-secret.txt" - - secspider_main( - [ - "genkeys", - "--private-key", - str(private_path), - "--public-key", - str(public_path), - ] - ) - secret_path.write_text("0123456789abcdef0123456789abcdef", encoding="utf-8") - source_path.write_text("class Spider:\n pass\n", encoding="utf-8") - - with contextlib.chdir(temp_path): - exit_code = secspider_main( - [ - "pack", - "--input", - str(source_path), - "--output", - str(output_path), - ] - ) - - self.assertEqual(exit_code, 0) - package_text = output_path.read_text(encoding="utf-8") - self.assertIn("//@name:红果短剧", package_text) - self.assertIn("//@version:1", package_text) - self.assertIn("//@remark:", package_text) - self.assertIn("//@kid:k2026_04", package_text) - - def test_pack_uses_default_output_name_txt_suffix(self): - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - source_path = temp_path / "默认命名.py" - private_path = temp_path / "signing-private.pem" - public_path = temp_path / "signing-public.pem" - secret_path = temp_path / "master-secret.txt" - - secspider_main( - [ - "genkeys", - "--private-key", - str(private_path), - "--public-key", - str(public_path), - ] - ) - secret_path.write_text("0123456789abcdef0123456789abcdef", encoding="utf-8") - source_path.write_text("class Spider:\n pass\n", encoding="utf-8") - - with contextlib.chdir(temp_path): - exit_code = secspider_main( - [ - "pack", - "--input", - str(source_path), - ] - ) - - self.assertEqual(exit_code, 0) - output_path = temp_path / "默认命名.txt" - self.assertTrue(output_path.is_file()) - package_text = output_path.read_text(encoding="utf-8") - self.assertIn("//@name:默认命名", package_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_youknow.py b/py/tests/test_youknow.py deleted file mode 100644 index bdfa416..0000000 --- a/py/tests/test_youknow.py +++ /dev/null @@ -1,256 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("youknow_spider", str(ROOT / "youknow.py")).load_module() -Spider = MODULE.Spider - - -class TestYouKnowSpider(unittest.TestCase): - def setUp(self): - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - class_ids = [item["type_id"] for item in content["class"]] - self.assertEqual(class_ids, ["index", "drama", "movie", "variety", "anime", "short", "doc"]) - - def test_parse_list_cards_extracts_compact_vod_id(self): - html = """ - <a class="module-poster-item" href="/d/1234/" title="示例影片" data-original="/cover.jpg"> - <div class="module-item-note">更新至10集</div> - </a> - """ - cards = self.spider._parse_list_cards(html) - self.assertEqual( - cards, - [ - { - "vod_id": "1234", - "vod_name": "示例影片", - "vod_pic": "https://www.youknow.tv/cover.jpg", - "vod_remarks": "更新至10集", - } - ], - ) - - def test_parse_list_cards_prefers_nested_lazyload_original_over_placeholder_src(self): - html = """ - <a class="module-poster-item" href="/d/5678/" title="一念梦离"> - <div class="module-item-pic"> - <img - class="lazy lazyload" - data-original="/upload/vod/20260409-1/f1d0233129bc59adb6d2e8fa6396e681.jpg" - alt="一念梦离" - src="/upload/mxprocms/20230815-1/8a218aed43eb11efa5a045d21a46d601.webp" - /> - </div> - <div class="module-item-note">更新中</div> - </a> - """ - cards = self.spider._parse_list_cards(html) - self.assertEqual(cards[0]["vod_pic"], "https://www.youknow.tv/upload/vod/20260409-1/f1d0233129bc59adb6d2e8fa6396e681.jpg") - - @patch.object(Spider, "_request_html") - def test_home_video_content_uses_today_updates_page(self, mock_request_html): - mock_request_html.return_value = """ - <a class="module-poster-item" href="/d/111/" title="今日更新" data-original="/recent.jpg"> - <div class="module-item-note">HD</div> - </a> - """ - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_id"], "111") - self.assertEqual(result["list"][0]["vod_name"], "今日更新") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_page_result(self, mock_request_html): - mock_request_html.return_value = """ - <a class="module-poster-item" href="/d/222/" title="分类影片" data-original="/cate.jpg"> - <div class="module-item-note">完结</div> - </a> - """ - result = self.spider.categoryContent("movie", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertEqual(mock_request_html.call_args.args[0], "/show/2--------2---/") - self.assertEqual(result["list"][0]["vod_id"], "222") - - @patch.object(Spider, "_request_html") - def test_category_content_uses_page1_path_without_page_number(self, mock_request_html): - mock_request_html.return_value = """ - <a class="module-poster-item" href="/d/555/" title="分类第一页" data-original="/page1.jpg"> - <div class="module-item-note">HD</div> - </a> - """ - result = self.spider.categoryContent("movie", "1", False, {}) - self.assertEqual(mock_request_html.call_args.args[0], "/show/2-----------/") - self.assertEqual(result["list"][0]["vod_id"], "555") - - @patch.object(Spider, "_request_html") - def test_search_content_reuses_card_parser(self, mock_request_html): - mock_request_html.return_value = """ - <a class="module-poster-item" href="/d/333/" title="搜索影片" data-original="/search.jpg"> - <div class="module-item-note">抢先版</div> - </a> - """ - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(result["list"][0]["vod_id"], "333") - self.assertEqual(result["list"][0]["vod_name"], "搜索影片") - - def test_parse_detail_page_extracts_fields_and_aligns_episode_sources(self): - html = """ - <div class="module-info-poster"> - <img data-original="/poster.jpg" /> - </div> - <div class="module-info-heading"> - <h1>示例剧</h1> - </div> - <div class="module-info-content"> - <div><span>类型:</span><a>剧情</a></div> - <div><span>地区:</span><a>大陆</a></div> - <div><span>年份:</span><a>2026</a></div> - <div><span>导演:</span><a>张三</a></div> - <div><span>主演:</span><a>李四</a><a>王五</a></div> - <div><span>语言:</span><a>国语</a></div> - <div><span>备注:</span><span>更新至2集</span></div> - <div><span>简介:</span><p>一段剧情简介</p></div> - </div> - <div data-dropdown-value="线路A"></div> - <div data-dropdown-value="线路B"></div> - <div class="module-play-list"> - <a href="/p/888-1-1/">第1集</a> - <a href="/p/888-1-2/">第2集</a> - </div> - <div class="module-play-list"> - <a href="/p/888-2-1/">第1集</a> - <a href="/p/888-2-2/">第2集</a> - </div> - """ - result = self.spider._parse_detail_page(html, "888") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "888") - self.assertEqual(vod["path"], "https://www.youknow.tv/d/888/") - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["type_name"], "剧情") - self.assertEqual(vod["vod_area"], "大陆") - self.assertEqual(vod["vod_year"], "2026") - self.assertEqual(vod["vod_lang"], "国语") - self.assertEqual(vod["vod_remarks"], "更新至2集") - self.assertEqual(vod["vod_director"], "张三") - self.assertEqual(vod["vod_actor"], "李四,王五") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_play_from"], "YouKnowTV") - first_episode = vod["vod_play_url"].split("#")[0] - self.assertTrue(first_episode.startswith("第1集$")) - payload = self.spider._decode_episode_payload(first_episode.split("$", 1)[1]) - self.assertEqual(payload["vod_id"], "888") - self.assertEqual(payload["episode_index"], 1) - self.assertEqual(len(payload["candidates"]), 2) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_detail_request_url_from_vod_id(self, mock_request_html): - mock_request_html.return_value = '<h1>详情影片</h1><div class="module-play-list"><a href="/p/123-1-1/">第1集</a></div>' - result = self.spider.detailContent(["123"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.youknow.tv/d/123/") - self.assertEqual(result["list"][0]["vod_id"], "123") - - def test_extract_player_config_reads_player_aaaa(self): - html = '<script>var player_aaaa={"url":"https%3A%2F%2Fcdn.example%2Fa.m3u8","encrypt":"1"};</script>' - data = self.spider._parse_player_config(html) - self.assertEqual(data["encrypt"], "1") - - def test_extract_player_config_supports_nested_object_literal(self): - html = """ - <script> - var player_aaaa={ - "flag":"play", - "encrypt":2, - "vod_data":{"vod_name":"示例影片","vod_actor":"甲,乙"}, - "url":"JTY4JTc0JTc0JTcwJTczJTNBJTJGJTJGJTc2JTY5JTc0JTY0JTJFJTYzJTY0JTZFJTJFJTY1JTc4JTYxJTZEJTcwJTZDJTY1JTJGJTYxJTJGJTY5JTZFJTY0JTY1JTc4JTJFJTZEJTMzJTc1JTM4" - }; - </script> - """ - data = self.spider._parse_player_config(html) - self.assertEqual(data["encrypt"], 2) - self.assertEqual(data["vod_data"]["vod_name"], "示例影片") - - def test_decode_player_url_supports_encrypt_1_and_2(self): - self.assertEqual( - self.spider._decode_player_url("https%3A%2F%2Fvideo.example%2Fa.m3u8", "1"), - "https://video.example/a.m3u8", - ) - encoded = "aHR0cHMlM0ElMkYlMkZ2aWRlby5leGFtcGxlJTJGYi5tM3U4" - self.assertEqual( - self.spider._decode_player_url(encoded, "2"), - "https://video.example/b.m3u8", - ) - - @patch.object(Spider, "_request_html") - def test_player_content_collects_candidates_from_page_and_iframe(self, mock_request_html): - payload = self.spider._encode_episode_payload( - { - "vod_id": "888", - "episode_index": 1, - "title": "第1集", - "candidates": [ - {"source": "线路A", "source_id": "1", "episode_url": "https://www.youknow.tv/p/888-1-1/"} - ], - } - ) - mock_request_html.side_effect = [ - '<script>var player_aaaa={"url":"https%3A%2F%2Fvideo.example%2Fpage.m3u8","encrypt":"1"};</script><iframe class="embed-responsive-item" src="/embed/player.html?url=https%3A%2F%2Fvideo.example%2Fiframe.m3u8"></iframe>', - '<html><body>https://video.example/iframe-final.m3u8</body></html>', - ] - result = self.spider.playerContent("YouKnowTV", payload, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/page.m3u8") - self.assertEqual(result["header"]["Referer"], "https://www.youknow.tv/") - - @patch.object(Spider, "_request_html") - def test_player_content_handles_nested_real_world_player_config(self, mock_request_html): - payload = self.spider._encode_episode_payload( - { - "vod_id": "202774", - "episode_index": 1, - "title": "HD中字", - "candidates": [ - {"source": "线路1", "source_id": "1", "episode_url": "https://www.youknow.tv/p/202774-1-1/"} - ], - } - ) - mock_request_html.return_value = """ - <script> - var player_aaaa={"flag":"play","encrypt":2,"vod_data":{"vod_name":"机动战士高达闪光的哈萨维"},"url":"JTY4JTc0JTc0JTcwJTczJTNBJTJGJTJGJTc2JTY5JTcwJTJFJTY0JTc5JTc0JTc0JTJEJTZFJTY1JTc0JTc3JTZGJTcyJTZCJTJFJTYzJTZGJTZEJTJGJTMyJTMwJTMyJTM2JTMwJTMzJTMyJTM1JTJGJTMyJTMxJTMzJTMzJTM0JTVGJTMxJTM4JTM4JTM1JTM2JTYyJTM0JTM3JTJGJTY5JTZFJTY0JTY1JTc4JTJFJTZEJTMzJTc1JTM4"}; - </script> - <iframe src="/template/mxpro/html/vod/adsterra_iframe.html"></iframe> - """ - result = self.spider.playerContent("YouKnowTV", payload, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://vip.dytt-network.com/20260325/21334_18856b47/index.m3u8") - - @patch.object(Spider, "_request_html") - def test_player_content_tries_next_candidate_when_first_fails(self, mock_request_html): - payload = self.spider._encode_episode_payload( - { - "vod_id": "888", - "episode_index": 1, - "title": "第1集", - "candidates": [ - {"source": "线路A", "source_id": "1", "episode_url": "https://www.youknow.tv/p/888-1-1/"}, - {"source": "线路B", "source_id": "2", "episode_url": "https://www.youknow.tv/p/888-2-1/"}, - ], - } - ) - mock_request_html.side_effect = [ - "<html><body>empty</body></html>", - "<html><body>https://video.example/backup.m3u8</body></html>", - ] - result = self.spider.playerContent("YouKnowTV", payload, {}) - self.assertEqual(result["url"], "https://video.example/backup.m3u8") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_不太灵.py b/py/tests/test_不太灵.py deleted file mode 100644 index 78b2a07..0000000 --- a/py/tests/test_不太灵.py +++ /dev/null @@ -1,185 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("butailing_spider", str(ROOT / "不太灵.py")).load_module() -Spider = MODULE.Spider - - -class FakeResponse: - def __init__(self, text, status_code=200): - self.text = text - self.status_code = status_code - self.encoding = "utf-8" - - -class TestBuTaiLingSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_fixed_categories_and_filters(self): - with patch.object( - Spider, - "_request_api", - return_value={"t1": [{"title": "动作"}], "t2": [{"title": "中国"}], "t3": [], "t4": [], "t5": []}, - ): - content = self.spider.homeContent(False) - - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["1", "2", "3", "4", "5"], - ) - self.assertIn("1", content["filters"]) - self.assertIn("2", content["filters"]) - self.assertEqual(content["filters"]["1"][0]["key"], "sc") - - @patch.object(Spider, "_request_api") - def test_home_video_content_requests_recent_hot_list(self, mock_request_api): - mock_request_api.return_value = [ - {"doub_id": 11, "title": "示例电影", "image": "https://img.test/poster.jpg", "ejs": "HD"} - ] - content = self.spider.homeVideoContent() - self.assertEqual(mock_request_api.call_args.args[0], "getVideoList") - self.assertEqual(mock_request_api.call_args.args[1]["sc"], "3") - self.assertEqual(content["list"][0]["vod_id"], "11") - self.assertEqual(content["list"][0]["vod_remarks"], "HD") - - def test_build_api_url_appends_credentials(self): - url = self.spider._build_api_url("getVideoList", {"page": 2, "limit": 24}) - self.assertIn("getVideoList", url) - self.assertIn("app_id=83768d9ad4", url) - self.assertIn("identity=23734adac0301bccdcb107c4aa21f96c", url) - self.assertIn("page=2", url) - - @patch("requests.get") - def test_request_api_parses_json_and_callback_payload(self, mock_get): - mock_get.return_value = FakeResponse('callback({"success":true,"code":200,"data":{"data":[{"doub_id": 1}]}})') - result = self.spider._request_api("getVideoList", {"page": 1}) - self.assertEqual(result, [{"doub_id": 1}]) - - def test_parse_ext_object_supports_plain_urlencoded_and_base64_json(self): - plain = self.spider._parse_ext_object('{"sc":"动作"}') - encoded = self.spider._parse_ext_object("%7B%22sd%22%3A%22中国%22%7D") - wrapped = self.spider._parse_ext_object("eyJzZSI6IjIwMjYifQ==") - self.assertEqual(plain["sc"], "动作") - self.assertEqual(encoded["sd"], "中国") - self.assertEqual(wrapped["se"], "2026") - - @patch.object(Spider, "_request_api") - def test_category_content_movie_uses_filter_params(self, mock_request_api): - mock_request_api.return_value = [ - {"doub_id": 21, "title": "电影A", "image": "a.jpg", "ejs": "4K"}, - {"doub_id": 21, "title": "电影A", "image": "a.jpg", "ejs": "4K"}, - ] - result = self.spider.categoryContent("1", "2", False, '{"sc":"动作","sd":"中国","iswp":"1","status":"更新中"}') - self.assertEqual(mock_request_api.call_args.args[0], "getVideoMovieList") - self.assertEqual( - mock_request_api.call_args.args[1], - {"sa": 1, "page": 2, "sc": "动作", "sd": "中国", "se": "", "sf": "", "sh": "", "sg": "1", "iswp": 1}, - ) - self.assertEqual(result["page"], 2) - self.assertEqual(len(result["list"]), 1) - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_api") - def test_category_content_hot_uses_local_dedupe_and_pagination(self, mock_request_api): - mock_request_api.return_value = [ - {"doub_id": 1, "title": "A"}, - {"doub_id": 1, "title": "A"}, - {"doub_id": 2, "title": "B"}, - ] - result = self.spider.categoryContent("3", "1", False, {}) - self.assertEqual(mock_request_api.call_args.args[0], "getVideoList") - self.assertEqual(result["total"], 2) - self.assertEqual(len(result["list"]), 2) - - @patch.object(Spider, "_request_api") - def test_search_content_filters_by_name_and_dedupes(self, mock_request_api): - mock_request_api.return_value = [ - {"doub_id": 1, "title": "繁花", "image": "1.jpg"}, - {"doub_id": 2, "title": "繁花幕后", "image": "2.jpg"}, - {"doub_id": 2, "title": "繁花幕后", "image": "2.jpg"}, - {"doub_id": 3, "title": "别的内容", "image": "3.jpg"}, - ] - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(mock_request_api.call_args.args[0], "getVideoList") - self.assertEqual(result["total"], 2) - self.assertEqual([item["vod_id"] for item in result["list"]], ["1", "2"]) - - def test_detect_drive_type_by_url(self): - self.assertEqual(self.spider._detect_drive_type_by_url("https://pan.baidu.com/s/abc"), "baidu") - self.assertEqual(self.spider._detect_drive_type_by_url("https://pan.quark.cn/s/abc"), "quark") - self.assertEqual(self.spider._detect_drive_type_by_url("https://pan.xunlei.com/s/abc"), "xunlei") - self.assertEqual(self.spider._detect_drive_type_by_url("https://www.alipan.com/s/abc"), "aliyun") - self.assertEqual(self.spider._detect_drive_type_by_url("https://www.123pan.com/s/abc"), "a123") - self.assertEqual(self.spider._detect_drive_type_by_url("https://example.com/file"), "other") - - def test_extract_pan_sources_creates_independent_lines_and_ignores_magnet_groups(self): - detail = { - "title": "示例详情", - "movies_online_seed": { - "夸克": [ - {"seed_name": "夸克资源A", "link": "https://pan.quark.cn/s/q1"}, - {"seed_name": "夸克资源A", "link": "https://pan.quark.cn/s/q1"}, - ], - "百度网盘": [{"seed_name": "百度资源", "link": "https://pan.baidu.com/s/b1"}], - "未知": [{"seed_name": "未知资源", "link": "https://example.com/file"}], - }, - "ecca": {"WEB-4K": [{"zlink": "magnet:?xt=1"}]}, - } - play_from, play_url = self.spider._extract_pan_sources(detail) - self.assertEqual(play_from, "baidu#1$$$quark#1$$$other#1") - self.assertEqual( - play_url, - "baidu#1$https://pan.baidu.com/s/b1$$$quark#1$https://pan.quark.cn/s/q1$$$other#1$https://example.com/file", - ) - - @patch.object(Spider, "_request_api") - def test_detail_content_maps_metadata_and_pan_lines(self, mock_request_api): - mock_request_api.return_value = { - "doub_id": 88, - "title": "详情标题", - "image": "https://img.test/detail.jpg", - "ejs": "全 12 集", - "years": "2026", - "abstract": "这是一段剧情简介", - "performer": "演员甲/演员乙", - "director": "导演甲", - "production_area": "中国", - "movies_online_seed": { - "阿里": [{"seed_name": "阿里资源", "link": "https://www.alipan.com/s/ali1"}] - }, - } - result = self.spider.detailContent(["88"]) - vod = result["list"][0] - self.assertEqual(mock_request_api.call_args.args[0], "getVideoDetail") - self.assertEqual(mock_request_api.call_args.args[1], {"id": "88"}) - self.assertEqual(vod["vod_id"], "88") - self.assertEqual(vod["vod_name"], "详情标题") - self.assertEqual(vod["vod_play_from"], "aliyun#1") - self.assertEqual(vod["vod_play_url"], "aliyun#1$https://www.alipan.com/s/ali1") - - def test_player_content_passthroughs_share_links_with_tvbox_shape(self): - self.assertEqual( - self.spider.playerContent("quark#1", "https://pan.quark.cn/s/q1", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.quark.cn/s/q1"}, - ) - self.assertEqual( - self.spider.playerContent("quark#1", "push://https://pan.quark.cn/s/q1", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.quark.cn/s/q1"}, - ) - - def test_player_content_returns_empty_url_for_non_pan_links(self): - self.assertEqual( - self.spider.playerContent("other#1", "https://example.com/file", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_世纪音乐.py b/py/tests/test_世纪音乐.py deleted file mode 100644 index ee5a091..0000000 --- a/py/tests/test_世纪音乐.py +++ /dev/null @@ -1,236 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("sjmusic_spider", str(ROOT / "世纪音乐.py")).load_module() -Spider = MODULE.Spider - - -HOME_HTML = """ -<html> - <body> - <ul id="datalist"> - <li> - <div class="name"><a class="url" href="/mp3/123.html">夜曲</a></div> - <div class="singer">周杰伦</div> - <div class="pic"><img src="/img/song.jpg"></div> - </li> - </ul> - <ul class="video_list"> - <li> - <div class="name"><a href="/mp4/456.html">稻香MV</a></div> - <div class="pic"><img src="/img/mv.jpg"></div> - </li> - </ul> - </body> -</html> -""" - -PLAYLIST_HTML = """ -<html><body> - <ul class="video_list"> - <li> - <div class="name"><a href="/playlist/gedan001.html">华语经典</a></div> - <div class="pic"><img src="/img/list.jpg"></div> - </li> - </ul> -</body></html> -""" - -SINGER_HTML = """ -<html><body> - <ul class="singer_list"> - <li> - <div class="pic"><a href="/singer/zhoujielun.html"><img src="/img/singer.jpg"></a></div> - <div class="name"><a>周杰伦</a></div> - </li> - </ul> -</body></html> -""" - -MV_HTML = """ -<html><body> - <ul class="video_list"> - <li> - <div class="name"><a href="/mp4/999.html">晴天MV</a></div> - <div class="pic"><img src="/img/mv2.jpg"></div> - </li> - </ul> -</body></html> -""" - -SEARCH_HTML = """ -<html><body> - <ul class="play_list"> - <li><div class="name"><a href="/mp3/777.html">七里香</a></div><img src="/img/1.jpg"></li> - <li><div class="name"><a href="/mp4/778.html">七里香MV</a></div><img src="/img/2.jpg"></li> - <li><div class="name"><a href="/playlist/top777.html">周董精选</a></div><img src="/img/3.jpg"></li> - <li><div class="name"><a href="/singer/jay.html">周杰伦</a></div><img src="/img/4.jpg"></li> - </ul> -</body></html> -""" - -RANK_HTML = """ -<html><body> - <ul class="play_list"> - <li><div class="name"><a href="/mp3/321.html">搁浅</a></div></li> - <li><div class="name"><a href="/mp3/322.html">简单爱</a></div></li> - </ul> -</body></html> -""" - -SONG_HTML = """ -<html><head><title>夜曲_世纪音乐 -

夜曲

- -
- -""" - -MV_DETAIL_HTML = """ -晴天MV_世纪音乐 -

晴天MV

- -
- -""" - -PLAYLIST_DETAIL_HTML = """ - -

周董歌单

-
- - -""" - -SINGER_DETAIL_HTML = """ - -

周杰伦

-

华语男歌手

-
- - -""" - - -class TestSJMusicSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - @patch.object(Spider, "fetch") - def test_home_content_returns_classes_filters_and_home_items(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=HOME_HTML) - result = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in result["class"]], - ["home", "rank_list", "playlist", "singer", "mv"], - ) - self.assertIn("singer", result["filters"]) - self.assertEqual(result["list"][0]["vod_id"], "song:123") - self.assertEqual(result["list"][0]["vod_name"], "周杰伦 - 夜曲") - self.assertEqual(result["list"][1]["vod_id"], "mv:456") - - @patch.object(Spider, "fetch") - def test_home_video_content_reuses_home_list(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=HOME_HTML) - result = self.spider.homeVideoContent() - self.assertEqual([item["vod_id"] for item in result["list"]], ["song:123", "mv:456"]) - - @patch.object(Spider, "fetch") - def test_category_content_supports_rank_playlist_singer_and_mv(self, mock_fetch): - mock_fetch.side_effect = [ - SimpleNamespace(status_code=200, text=PLAYLIST_HTML), - SimpleNamespace(status_code=200, text=SINGER_HTML), - SimpleNamespace(status_code=200, text=MV_HTML), - ] - rank_result = self.spider.categoryContent("rank_list", "1", False, {}) - playlist_result = self.spider.categoryContent("playlist", "1", False, {"lang": "index"}) - singer_result = self.spider.categoryContent( - "singer", - "1", - False, - {"sex": "girl", "area": "huayu", "char": "index"}, - ) - mv_result = self.spider.categoryContent( - "mv", - "1", - False, - {"area": "index", "type": "index", "sort": "new"}, - ) - self.assertTrue(rank_result["list"][0]["vod_id"].startswith("rank:")) - self.assertEqual(playlist_result["list"][0]["vod_id"], "playlist:gedan001") - self.assertEqual(singer_result["list"][0]["vod_id"], "singer:zhoujielun") - self.assertEqual(mv_result["list"][0]["vod_id"], "mv:999") - - @patch.object(Spider, "fetch") - def test_search_content_maps_link_types_and_blank_keyword(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=SEARCH_HTML) - result = self.spider.searchContent("周杰伦", False, "1") - self.assertEqual( - [item["vod_id"] for item in result["list"]], - ["song:777", "mv:778", "playlist:top777", "singer:jay"], - ) - self.assertEqual( - self.spider.searchContent("", False, "1"), - {"page": 1, "limit": 0, "total": 0, "list": []}, - ) - - def test_category_content_returns_empty_for_unknown_tid(self): - self.assertEqual( - self.spider.categoryContent("unknown", "1", False, {}), - {"page": 1, "limit": 0, "total": 0, "list": []}, - ) - - @patch.object(Spider, "fetch") - def test_detail_content_builds_rank_song_mv_playlist_and_singer(self, mock_fetch): - mock_fetch.side_effect = [ - SimpleNamespace(status_code=200, text=RANK_HTML), - SimpleNamespace(status_code=200, text=SONG_HTML), - SimpleNamespace(status_code=200, text=MV_DETAIL_HTML), - SimpleNamespace(status_code=200, text=PLAYLIST_DETAIL_HTML), - SimpleNamespace(status_code=200, text=SINGER_DETAIL_HTML), - ] - rank_vod = self.spider.detailContent(["rank:rise"])["list"][0] - song_vod = self.spider.detailContent(["song:123"])["list"][0] - mv_vod = self.spider.detailContent(["mv:456"])["list"][0] - playlist_vod = self.spider.detailContent(["playlist:top100"])["list"][0] - singer_vod = self.spider.detailContent(["singer:jay"])["list"][0] - self.assertEqual(rank_vod["vod_play_url"], "搁浅$music:321#简单爱$music:322") - self.assertEqual(song_vod["vod_play_url"], "周杰伦 - 夜曲$music:123") - self.assertEqual(mv_vod["vod_play_url"], "晴天MV$vplay:456:1080") - self.assertEqual(playlist_vod["vod_play_url"], "安静$music:401#晴天$music:402") - self.assertEqual(singer_vod["vod_play_url"], "青花瓷$music:501#稻香$music:502") - - def test_player_content_maps_music_and_mv_ids(self): - song = self.spider.playerContent("世纪音乐", "music:123", {}) - mv = self.spider.playerContent("世纪音乐", "vplay:456:1080", {}) - self.assertEqual(song["parse"], 0) - self.assertEqual(song["url"], "https://www.4c44.com/data/down.php?ac=music&id=123") - self.assertEqual(mv["url"], "https://www.4c44.com/data/down.php?ac=vplay&id=456&q=1080") - - def test_player_content_returns_empty_url_for_invalid_id(self): - self.assertEqual(self.spider.playerContent("世纪音乐", "bad", {})["url"], "") - - @patch.object(Spider, "fetch") - def test_filters_include_singer_mv_and_playlist_groups(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=HOME_HTML) - filters = self.spider.homeContent(False)["filters"] - self.assertEqual([item["key"] for item in filters["singer"]], ["sex", "area", "char"]) - self.assertEqual([item["key"] for item in filters["mv"]], ["area", "type", "sort"]) - self.assertEqual([item["key"] for item in filters["playlist"]], ["lang", "style"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_两个BT.py b/py/tests/test_两个BT.py deleted file mode 100644 index fb4fd39..0000000 --- a/py/tests/test_两个BT.py +++ /dev/null @@ -1,342 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import MagicMock, patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("lianggebt_spider", str(ROOT / "两个BT.py")).load_module() -Spider = MODULE.Spider - - -class TestLiangGeBTSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - [ - "zgjun", - "meiju", - "jpsrtv", - "movie_bt_tags/xiju", - "movie_bt_tags/aiqing", - "movie_bt_tags/adt", - "movie_bt_tags/at", - "movie_bt_tags/donghua", - "movie_bt_tags/qihuan", - "movie_bt_tags/xuanni", - "movie_bt_tags/kehuan", - "movie_bt_tags/juqing", - "movie_bt_tags/kongbu", - "gf", - ], - ) - self.assertNotIn("filters", content) - - def test_extract_cards_supports_short_ids_and_relative_images(self): - html = """ - - """ - cards = self.spider._extract_cards(html) - self.assertEqual( - cards, - [ - { - "vod_id": "123", - "vod_name": "示例影片", - "vod_pic": "https://www.bttwoo.com/cover.jpg", - "vod_remarks": "更新至10集", - } - ], - ) - - def test_extract_cards_supports_current_play_cards(self): - html = """ - - """ - cards = self.spider._extract_cards(html) - self.assertEqual( - cards, - [ - { - "vod_id": "/play/ch42nt5hw", - "vod_name": "夜魔侠:重生 第二季", - "vod_pic": "https://img.example/poster.jpg", - "vod_remarks": "更新至6集", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_requests_home_page(self, mock_request_html): - mock_request_html.return_value = """ -
  • - - - -

    首页影片

    -
  • - """ - result = self.spider.homeVideoContent() - self.assertEqual(mock_request_html.call_args.args[0], "https://www.bttwoo.com") - self.assertEqual(result["list"][0]["vod_id"], "200") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_expected_urls(self, mock_request_html): - mock_request_html.return_value = """ - - """ - page_one = self.spider.categoryContent("meiju", "1", False, {}) - page_three = self.spider.categoryContent("movie_bt_tags/xiju", "3", False, {}) - self.assertEqual( - mock_request_html.call_args_list[0].args[0], - "https://www.bttwoo.com/filter?classify=2&tvclasses=21", - ) - self.assertEqual( - mock_request_html.call_args_list[1].args[0], - "https://www.bttwoo.com/filter?classify=1&types=5&page=3", - ) - self.assertEqual(page_one["page"], 1) - self.assertEqual(page_one["list"][0]["vod_name"], "分类影片") - self.assertNotIn("pagecount", page_one) - self.assertEqual(page_three["page"], 3) - - @patch.object(Spider, "_request_html") - def test_category_content_maps_high_score_movies(self, mock_request_html): - mock_request_html.return_value = "" - self.spider.categoryContent("gf", "2", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.bttwoo.com/filter?classify=1&sort_by=score&order=desc&page=2", - ) - - @patch.object(Spider, "_request_html") - def test_search_content_builds_query_and_filters_irrelevant_results(self, mock_request_html): - mock_request_html.return_value = """ - - - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.bttwoo.com/search?q=%E7%B9%81%E8%8A%B1&page=2", - ) - self.assertEqual(result["page"], 2) - self.assertEqual( - result["list"], - [ - { - "vod_id": "/play/ch-search-1", - "vod_name": "繁花", - "vod_pic": "https://www.bttwoo.com/match.jpg", - "vod_remarks": "", - } - ], - ) - - def test_encode_and_decode_play_id_round_trip(self): - payload = self.spider._decode_play_id(self.spider._encode_play_id("play-1", "900", "第1集")) - self.assertEqual(payload, {"pid": "play-1", "sid": "900", "name": "第1集"}) - - @patch.object(Spider, "_request_html") - def test_detail_content_extracts_meta_and_playlist(self, mock_request_html): - mock_request_html.return_value = """ - - 两个BT详情页 - -

    示例详情

    -
    -
    这里是剧情简介
    -
  • 主演:演员甲 / 演员乙
  • -
  • 导演:导演甲
  • - 第1集 - 第2集 - - - """ - result = self.spider.detailContent(["900"]) - vod = result["list"][0] - first_name, first_id = vod["vod_play_url"].split("#")[0].split("$", 1) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.bttwoo.com/movie/900.html") - self.assertEqual(vod["vod_id"], "900") - self.assertEqual(vod["vod_name"], "示例详情") - self.assertEqual(vod["vod_pic"], "https://www.bttwoo.com/detail.jpg") - self.assertEqual(vod["vod_content"], "这里是剧情简介") - self.assertEqual(vod["vod_actor"], "演员甲 / 演员乙") - self.assertEqual(vod["vod_director"], "导演甲") - self.assertEqual(vod["vod_play_from"], "两个BT") - self.assertEqual(first_name, "第1集") - self.assertEqual(self.spider._decode_play_id(first_id)["pid"], "play-1") - self.assertEqual(self.spider._decode_play_id(first_id)["sid"], "900") - - @patch.object(Spider, "_request_html") - def test_detail_content_supports_current_play_pages(self, mock_request_html): - mock_request_html.return_value = """ - - - 夜魔侠:重生 第二季 - 第1集 -两个BT影视 - - - - -
    - -

    夜魔侠:重生 第二季

    -
    -
    -
    导演
    贾斯汀·本森 / 艾伦·穆尔黑德
    -
    主演
    查理·考克斯 / 文森特·多诺费奥
    -
    -

    夜幕降临,魔影共舞。

    -
    - 1 - 2 -
    - - - """ - result = self.spider.detailContent(["/play/ch42nt5hw"]) - vod = result["list"][0] - first_name, first_id = vod["vod_play_url"].split("#")[0].split("$", 1) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.bttwoo.com/play/ch42nt5hw") - self.assertEqual(vod["vod_id"], "/play/ch42nt5hw") - self.assertEqual(vod["vod_name"], "夜魔侠:重生 第二季") - self.assertEqual(vod["vod_pic"], "https://img.example/poster.jpg") - self.assertEqual(vod["vod_content"], "夜幕降临,魔影共舞。") - self.assertEqual(vod["vod_actor"], "查理·考克斯 / 文森特·多诺费奥") - self.assertEqual(vod["vod_director"], "贾斯汀·本森 / 艾伦·穆尔黑德") - self.assertEqual(first_name, "1") - self.assertEqual(self.spider._decode_play_id(first_id)["pid"], "/play/ch42nt5hw") - - def test_player_content_passthroughs_direct_media_url(self): - result = self.spider.playerContent("两个BT", "https://media.example/direct.m3u8", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://media.example/direct.m3u8") - self.assertEqual(result["header"]["Referer"], "https://www.bttwoo.com/") - - @patch.object(Spider, "_request_html") - def test_player_content_supports_current_play_page_ids(self, mock_request_html): - mock_request_html.return_value = "empty" - play_id = self.spider._encode_play_id("/play/ch42nt5hw", "/play/ch42nt5hw", "1") - result = self.spider.playerContent("两个BT", play_id, {}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.bttwoo.com/play/ch42nt5hw") - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://www.bttwoo.com/play/ch42nt5hw") - - @patch.object(Spider, "fetch") - @patch.object(Spider, "_build_wasm_play_api_url") - @patch.object(Spider, "_cache_wasm_assets") - @patch.object(Spider, "_request_html") - def test_player_content_resolves_current_play_page_via_play_api( - self, mock_request_html, mock_cache_wasm_assets, mock_build_wasm_play_api_url, mock_fetch - ): - mock_request_html.return_value = """ - - - -
    -
    - 1 -
    - - - """ - mock_build_wasm_play_api_url.return_value = "https://www.bttwoo.com/video/play?p=33373" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = ( - '{"code":200,"data":{"quality_urls":[{"url":"https://media.example/final.m3u8"}],"current_quality":0}}' - ) - mock_fetch.return_value = mock_response - play_id = self.spider._encode_play_id("/play/ch440i68t", "/play/ch440i68t", "1") - result = self.spider.playerContent("两个BT", play_id, {}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.bttwoo.com/play/ch440i68t") - self.assertEqual( - mock_build_wasm_play_api_url.call_args.args, - ("33373", "ch440i68t", "1080", "X1VaWEZeVwEFCw4FCgc7IUlfXUhe"), - ) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://media.example/final.m3u8") - self.assertEqual(result["header"]["Referer"], "https://www.bttwoo.com/play/ch440i68t") - - @patch.object(Spider, "_request_html") - def test_player_content_extracts_media_from_play_page(self, mock_request_html): - mock_request_html.return_value = """ - - """ - play_id = self.spider._encode_play_id("play-100", "900", "第1集") - result = self.spider.playerContent("两个BT", play_id, {}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.bttwoo.com/v_play/play-100.html") - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://media.example/final.mp4") - self.assertEqual(result["header"]["Referer"], "https://www.bttwoo.com/v_play/play-100.html") - - @patch.object(Spider, "_request_html") - def test_player_content_extracts_media_from_iframe(self, mock_request_html): - mock_request_html.side_effect = [ - '', - '', - ] - play_id = self.spider._encode_play_id("play-200", "900", "第2集") - result = self.spider.playerContent("两个BT", play_id, {}) - self.assertEqual(mock_request_html.call_args_list[0].args[0], "https://www.bttwoo.com/v_play/play-200.html") - self.assertEqual(mock_request_html.call_args_list[1].args[0], "https://www.bttwoo.com/embed/demo.html") - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://media.example/iframe.m3u8") - - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_to_parse_when_no_media_found(self, mock_request_html): - mock_request_html.return_value = "empty" - play_id = self.spider._encode_play_id("play-300", "900", "第3集") - result = self.spider.playerContent("两个BT", play_id, {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://www.bttwoo.com/v_play/play-300.html") - self.assertEqual(result["header"]["Referer"], "https://www.bttwoo.com/v_play/play-300.html") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_乌云影视.py b/py/tests/test_乌云影视.py deleted file mode 100644 index ba4925c..0000000 --- a/py/tests/test_乌云影视.py +++ /dev/null @@ -1,337 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("wooyun_spider", str(ROOT / "乌云影视.py")).load_module() -Spider = MODULE.Spider - - -class TestWooyunSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_builds_classes_and_filters_from_menu(self): - menu = [ - {"code": "movie", "name": "电影"}, - {"code": "tv_series", "name": "电视剧"}, - {"code": "animation", "name": "动画"}, - {"code": "variety", "name": "综艺"}, - {"code": "short_drama", "name": "短剧"}, - { - "nameEn": "year", - "children": [ - {"code": "THIS_YEAR", "name": "今年"}, - {"code": "LAST_YEAR", "name": "去年"}, - {"code": "2024", "name": "2024"}, - ], - }, - { - "nameEn": "region", - "children": [{"code": "CN", "name": "大陆"}], - }, - { - "nameEn": "genre", - "children": [{"code": "COMEDY", "name": "喜剧"}], - }, - { - "nameEn": "language", - "children": [{"code": "ZH", "name": "国语"}], - }, - ] - - self.spider._request_json = lambda path, method="GET", data=None, headers=None: menu - content = self.spider.homeContent(False) - - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["movie", "tv_series", "animation", "variety", "short_drama", "THIS_YEAR", "LAST_YEAR"], - ) - self.assertEqual(content["filters"]["movie"][0]["key"], "year") - self.assertEqual(content["filters"]["movie"][1]["key"], "region") - self.assertEqual(content["filters"]["movie"][2]["key"], "genre") - self.assertEqual(content["filters"]["movie"][3]["key"], "lang") - self.assertEqual(content["filters"]["movie"][4]["key"], "sort") - - def test_request_json_uses_post_for_json_payload(self): - calls = {} - - class FakeResponse: - def __init__(self): - self.status_code = 200 - self.text = "" - - def json(self): - return {"data": {"records": []}} - - def fake_post( - url, - json=None, - headers=None, - timeout=5, - verify=True, - stream=False, - allow_redirects=True, - params=None, - data=None, - cookies=None, - ): - calls["url"] = url - calls["json"] = json - calls["headers"] = headers - return FakeResponse() - - self.spider.post = fake_post - result = self.spider._request_json( - "/movie/media/search", - method="POST", - data={"pageIndex": "1"}, - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(calls["url"], "https://wooyun.tv/movie/media/search") - self.assertEqual(calls["json"], {"pageIndex": "1"}) - self.assertEqual(calls["headers"]["Origin"], "https://wooyun.tv") - self.assertEqual(result, {"records": []}) - - def test_build_category_body_handles_year_shortcuts_and_filters(self): - body = self.spider._build_category_body( - "THIS_YEAR", - "3", - {"region": "CN", "year": "2025", "lang": "ZH", "sort": "hot"}, - ) - self.assertEqual(body["menuCodeList"], ["THIS_YEAR", "CN", "2025", "ZH"]) - self.assertEqual(body["pageIndex"], "3") - self.assertEqual(body["sortCode"], "hot") - self.assertEqual(body["topCode"], "movie") - - def test_extract_home_list_deduplicates_media_resources(self): - home_blocks = { - "records": [ - { - "mediaResources": [ - {"id": 1, "title": "影片A", "posterUrl": "/a.jpg"}, - {"id": 1, "title": "影片A", "posterUrl": "/a.jpg"}, - ] - }, - { - "mediaResources": [ - {"id": 2, "title": "影片B", "posterUrlS3": "https://img.example/b.jpg"}, - ] - }, - ] - } - items = self.spider._extract_home_list(home_blocks) - self.assertEqual([item["id"] for item in items], ["1", "2"]) - - def test_map_vod_picks_expected_fields(self): - item = { - "id": 7, - "title": "示例片", - "posterUrl": "/poster.jpg", - "mediaType": {"code": "movie", "name": "电影"}, - "episodeStatus": "更新至4集", - "releaseYear": "2026", - "rating": "8.8", - "actors": ["甲", "乙"], - "directors": ["丙"], - } - vod = self.spider._map_vod(item) - self.assertEqual(vod["vod_id"], "7") - self.assertEqual(vod["vod_pic"], "/poster.jpg") - self.assertEqual(vod["vod_actor"], "甲/乙") - self.assertEqual(vod["vod_director"], "丙") - - @patch.object(Spider, "_request_json") - def test_home_video_content_reads_home_blocks(self, mock_request_json): - mock_request_json.return_value = { - "records": [ - {"mediaResources": [{"id": 1, "title": "首页片", "posterUrl": "/a.jpg"}]} - ] - } - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_id"], "1") - self.assertEqual(result["list"][0]["vod_name"], "首页片") - - @patch.object(Spider, "_request_json") - def test_category_content_posts_search_body_and_returns_page_data(self, mock_request_json): - mock_request_json.return_value = { - "records": [{"id": 9, "title": "分类片", "posterUrl": "/cate.jpg"}], - "total": 30, - "pages": 2, - } - result = self.spider.categoryContent("movie", "2", False, {"sort": "latest"}) - kwargs = mock_request_json.call_args.kwargs - self.assertEqual(kwargs["method"], "POST") - self.assertEqual(kwargs["data"]["pageIndex"], "2") - self.assertEqual(kwargs["data"]["sortCode"], "latest") - self.assertEqual(result["page"], 2) - self.assertNotIn("pagecount", result) - self.assertEqual(result["limit"], 24) - self.assertEqual(result["total"], 30) - - @patch.object(Spider, "_request_json") - def test_search_content_returns_empty_result_for_blank_keyword(self, mock_request_json): - result = self.spider.searchContent("", False, "1") - self.assertEqual(result, {"page": 1, "total": 0, "list": []}) - mock_request_json.assert_not_called() - - @patch.object(Spider, "_request_json") - def test_search_content_posts_keyword_body(self, mock_request_json): - mock_request_json.return_value = { - "records": [{"id": 15, "title": "搜索片", "posterUrl": "/search.jpg"}], - "total": 1, - "pages": 1, - } - result = self.spider.searchContent("繁花", False, "1") - body = mock_request_json.call_args.kwargs["data"] - self.assertEqual(body["searchKey"], "繁花") - self.assertEqual(body["topCode"], "") - self.assertEqual(result["list"][0]["vod_id"], "15") - - def test_build_play_sources_encodes_multiple_seasons(self): - seasons = [ - { - "seasonNo": 1, - "videoList": [ - {"id": 101, "epNo": 1, "remark": "", "playUrl": "/play-1.m3u8"}, - {"id": 102, "epNo": 2, "remark": "加更", "playUrl": "/play-2.m3u8"}, - ], - }, - { - "seasonNo": 2, - "videoList": [ - {"id": 201, "epNo": 1, "remark": "", "playUrl": "/play-3.m3u8"}, - ], - }, - ] - payload = self.spider._build_play_sources(seasons, "99") - self.assertEqual(payload["vod_play_from"], "第1季$$$第2季") - self.assertIn("第1集$", payload["vod_play_url"]) - self.assertIn("第2集 加更$", payload["vod_play_url"]) - - def test_decode_play_id_round_trips_base64url_payload(self): - encoded = self.spider._encode_play_payload( - { - "mediaId": "88", - "seasonNo": 1, - "epNo": 3, - "videoId": 12, - "playUrl": "/video.m3u8", - "name": "第3集", - } - ) - decoded = self.spider._decode_play_id(encoded) - self.assertEqual(decoded["mediaId"], "88") - self.assertEqual(decoded["seasonNo"], 1) - self.assertEqual(decoded["epNo"], 3) - self.assertEqual(decoded["playUrl"], "/video.m3u8") - - @patch.object(Spider, "_request_json") - def test_detail_content_merges_detail_apis_and_videos(self, mock_request_json): - mock_request_json.side_effect = [ - {"id": 300, "title": "基础标题", "posterUrl": "/base.jpg"}, - { - "id": 300, - "title": "完整标题", - "posterUrlS3": "https://img.example/detail.jpg", - "mediaType": {"code": "tv_series", "name": "电视剧"}, - "episodeStatus": "更新至2集", - "releaseYear": "2026", - "region": "大陆", - "actors": ["张三", "李四"], - "directors": ["王五"], - "overview": "一段简介", - "rating": "9.1", - }, - [ - { - "seasonNo": 1, - "videoList": [ - {"id": 901, "epNo": 1, "remark": "", "playUrl": "/ep1.m3u8"}, - {"id": 902, "epNo": 2, "remark": "超前", "playUrl": "/ep2.m3u8"}, - ], - } - ], - ] - result = self.spider.detailContent(["300"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "300") - self.assertEqual(vod["vod_name"], "完整标题") - self.assertEqual(vod["vod_pic"], "https://img.example/detail.jpg") - self.assertEqual(vod["type_name"], "电视剧") - self.assertEqual(vod["vod_area"], "大陆") - self.assertEqual(vod["vod_actor"], "张三/李四") - self.assertEqual(vod["vod_director"], "王五") - self.assertEqual(vod["vod_content"], "一段简介") - self.assertEqual(vod["vod_douban_score"], "9.1") - self.assertEqual(vod["vod_play_from"], "乌云影视") - self.assertIn("第2集 超前$", vod["vod_play_url"]) - - def test_find_play_url_matches_video_id_then_ep_no(self): - seasons = [ - { - "seasonNo": 1, - "videoList": [ - {"id": 10, "epNo": 1, "playUrl": "/ep1.m3u8"}, - {"id": 11, "epNo": 2, "playUrl": "/ep2.m3u8"}, - ], - } - ] - self.assertEqual(self.spider._find_play_url(seasons, {"seasonNo": 1, "videoId": 11}), "/ep2.m3u8") - self.assertEqual(self.spider._find_play_url(seasons, {"seasonNo": 1, "epNo": 1}), "/ep1.m3u8") - - def test_player_content_returns_direct_url_when_payload_has_play_url(self): - play_id = self.spider._encode_play_payload( - { - "mediaId": "100", - "seasonNo": 1, - "epNo": 1, - "videoId": 10, - "playUrl": "/direct.m3u8", - "name": "第1集", - } - ) - result = self.spider.playerContent("乌云影视", play_id, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://wooyun.tv/direct.m3u8") - self.assertEqual(result["header"]["Referer"], "https://wooyun.tv/") - - @patch.object(Spider, "_request_json") - def test_player_content_refetches_video_list_when_payload_missing_play_url(self, mock_request_json): - mock_request_json.return_value = [ - { - "seasonNo": 1, - "videoList": [ - {"id": 77, "epNo": 3, "playUrl": "/refetched.m3u8"}, - ], - } - ] - play_id = self.spider._encode_play_payload( - { - "mediaId": "100", - "seasonNo": 1, - "epNo": 3, - "videoId": 77, - "playUrl": "", - "name": "第3集", - } - ) - result = self.spider.playerContent("乌云影视", play_id, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://wooyun.tv/refetched.m3u8") - - def test_player_content_falls_back_to_external_parse_when_no_url_found(self): - result = self.spider.playerContent("乌云影视", "raw-play-id", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "raw-play-id") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_乐兔.py b/py/tests/test_乐兔.py deleted file mode 100644 index 5ea5050..0000000 --- a/py/tests/test_乐兔.py +++ /dev/null @@ -1,188 +0,0 @@ -import unittest -import base64 -from importlib.machinery import SourceFileLoader -from pathlib import Path -from urllib.parse import quote -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("letu_spider", str(ROOT / "乐兔.py")).load_module() -Spider = MODULE.Spider - - -class TestLeTuSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["1", "2", "3", "4", "5"], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_encode_and_decode_detail_and_play_ids(self): - self.assertEqual(self.spider._encode_vod_id("/vods/305741.html"), "vods/305741") - self.assertEqual(self.spider._decode_vod_id("vods/305741"), "https://www.letu.me/vods/305741.html") - self.assertEqual(self.spider._encode_play_id("/vod/305741-1-1.html"), "vod/305741-1-1") - self.assertEqual(self.spider._decode_play_id("vod/305741-1-1"), "https://www.letu.me/vod/305741-1-1.html") - - def test_parse_cards_extracts_compact_vod_ids(self): - html = """ -
    -
    - - -
    更新至1集
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "vods/305741", - "vod_name": "示例影片", - "vod_pic": "https://www.letu.me/cover.jpg", - "vod_remarks": "更新至1集", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_uses_type_page_url(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    - - -
    HD
    -
    -
    - """ - result = self.spider.categoryContent("2", "5", False, {}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.letu.me/type/2-5.html") - self.assertEqual(result["page"], 5) - self.assertEqual(result["limit"], 1) - self.assertNotIn("pagecount", result) - self.assertEqual(result["list"][0]["vod_id"], "vods/90001") - - @patch.object(Spider, "_request_html") - def test_category_content_uses_plain_type_path_for_first_page(self, mock_request_html): - mock_request_html.return_value = "" - self.spider.categoryContent("1", "1", False, {}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.letu.me/type/1.html") - - @patch.object(Spider, "_request_html") - def test_search_content_uses_search_url_and_parses_cards(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    - 搜索影片 - -
    全集
    -
    -
    - """ - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.letu.me/vodsearch/-------------.html?wd=%E7%B9%81%E8%8A%B1", - ) - self.assertEqual(result["list"][0]["vod_id"], "vods/70001") - self.assertNotIn("pagecount", result) - - def test_parse_detail_page_extracts_metadata_and_playlists(self): - html = """ -

    详情标题

    - - -
    导演甲
    -
    中国
    -

    第一段

    一段剧情简介

    - - -
    - 正片 -
    - """ - result = self.spider._parse_detail_page(html, "vods/305741") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "vods/305741") - self.assertEqual(vod["vod_name"], "详情标题") - self.assertEqual(vod["vod_pic"], "https://www.letu.me/poster.jpg") - self.assertEqual(vod["type_name"], "电影") - self.assertEqual(vod["vod_actor"], "演员甲") - self.assertEqual(vod["vod_director"], "导演甲") - self.assertEqual(vod["vod_area"], "中国") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_play_from"], "线路A$$$线路B") - self.assertEqual( - vod["vod_play_url"], - "第1集$vod/305741-1-1#第2集$vod/305741-1-2$$$正片$vod/305741-2-1", - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_decodes_compact_vod_id(self, mock_request_html): - mock_request_html.return_value = "

    详情标题

    " - self.spider.detailContent(["vods/305741"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.letu.me/vods/305741.html") - - @patch.object(Spider, "_request_html") - def test_player_content_returns_direct_json_url(self, mock_request_html): - mock_request_html.return_value = '{"code":200,"url":"https://video.example/direct.m3u8"}' - result = self.spider.playerContent("线路A", "vod/305741-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://video.example/direct.m3u8") - - @patch.object(Spider, "_request_html") - def test_player_content_decodes_rose_base64_url(self, mock_request_html): - encoded = quote(base64.b64encode(b"https://video.example/rose.m3u8").decode("utf-8")) - mock_request_html.return_value = '{"code":200,"url":"rose_' + encoded + '"}' - result = self.spider.playerContent("线路A", "vod/305741-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/rose.m3u8") - - def test_extract_player_data_reads_player_json(self): - html = '' - self.assertEqual(self.spider._extract_player_data(html)["encrypt"], "1") - - @patch.object(Spider, "_request_html") - def test_player_content_supports_encrypt_1_and_encrypt_2(self, mock_request_html): - encoded = quote(base64.b64encode(b"https://video.example/enc2.m3u8").decode("utf-8")) - mock_request_html.side_effect = [ - '', - '', - ] - encrypt_1 = self.spider.playerContent("线路A", "vod/305741-1-1", {}) - encrypt_2 = self.spider.playerContent("线路A", "vod/305741-1-2", {}) - self.assertEqual(encrypt_1["url"], "https://video.example/enc1.m3u8") - self.assertEqual(encrypt_2["url"], "https://video.example/enc2.m3u8") - - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_to_system_parse(self, mock_request_html): - mock_request_html.return_value = "" - result = self.spider.playerContent("线路A", "vod/305741-1-1", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://www.letu.me/vod/305741-1-1.html") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_二小.py b/py/tests/test_二小.py deleted file mode 100644 index 85e30d2..0000000 --- a/py/tests/test_二小.py +++ /dev/null @@ -1,251 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("erxiao_spider", str(ROOT / "二小.py")).load_module() -Spider = MODULE.Spider - - -class TestErXiaoSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_all_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("1", "二小电影"), - ("2", "二小剧集"), - ("3", "二小动漫"), - ("17", "二小综艺"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_url_and_detect_pan_type(self): - self.assertEqual( - self.spider._build_url("/index.php/vod/detail/id/1.html"), - f"{self.spider.host}/index.php/vod/detail/id/1.html", - ) - self.assertEqual(self.spider._detect_pan_type("https://pan.baidu.com/s/demo"), ("baidu", "百度资源")) - self.assertEqual(self.spider._detect_pan_type("https://pan.quark.cn/s/demo"), ("quark", "夸克资源")) - self.assertEqual(self.spider._detect_pan_type("https://example.com/video"), ("", "")) - - def test_parse_cards_extracts_short_path_ids(self): - html = """ -
    -
    -
    - - 示例影片 -
    -
    HD
    -
    2025
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/index.php/vod/detail/id/123.html", - "vod_name": "示例影片", - "vod_pic": f"{self.spider.host}/poster.jpg", - "vod_remarks": "HD", - "vod_year": "2025", - } - ], - ) - - def test_parse_cards_ignores_module_items_wrapper_and_keeps_each_card_separate(self): - html = """ -
    -
    -
    -
    - - 影片一 -
    -
    HD
    -
    -
    -
    - - 影片二 -
    -
    更新至2集
    -
    -
    -
    - """ - cards = self.spider._parse_cards(html) - self.assertEqual( - cards, - [ - { - "vod_id": "/index.php/vod/detail/id/111.html", - "vod_name": "影片一", - "vod_pic": f"{self.spider.host}/poster-1.jpg", - "vod_remarks": "HD", - "vod_year": "", - }, - { - "vod_id": "/index.php/vod/detail/id/222.html", - "vod_name": "影片二", - "vod_pic": f"{self.spider.host}/poster-2.jpg", - "vod_remarks": "更新至2集", - "vod_year": "", - }, - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_reference_url_and_returns_page_payload(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    -
    - - 分类影片 -
    -
    更新至10集
    -
    2024
    -
    -
    - """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - f"{self.spider.host}/index.php/vod/show/id/2/page/3.html", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_builds_reference_search_url_and_parses_results(self, mock_request_html): - mock_request_html.return_value = """ -
    - 抢先版 -
    - 搜索影片 -
    -
    - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - f"{self.spider.host}/index.php/vod/search/page/2/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual( - result["list"][0], - { - "vod_id": "/index.php/vod/detail/id/789.html", - "vod_name": "搜索影片", - "vod_pic": f"{self.spider.host}/search.jpg", - "vod_remarks": "抢先版", - }, - ) - - def test_search_content_returns_empty_list_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - def test_build_pan_lines_deduplicates_and_sorts_supported_links(self): - detail = { - "pan_urls": [ - "https://pan.quark.cn/s/q1", - "https://pan.baidu.com/s/b1", - "https://pan.baidu.com/s/b1", - "https://example.com/ignored", - ] - } - self.assertEqual( - self.spider._build_pan_lines(detail), - [ - ("baidu#二小", "百度资源$https://pan.baidu.com/s/b1"), - ("quark#二小", "夸克资源$https://pan.quark.cn/s/q1"), - ], - ) - - def test_parse_detail_page_extracts_meta_content_and_pan_urls(self): - html = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - detail = self.spider._parse_detail_page("/index.php/vod/detail/id/123.html", html) - self.assertEqual(detail["vod_name"], "示例剧") - self.assertEqual(detail["vod_pic"], f"{self.spider.host}/poster.jpg") - self.assertEqual(detail["vod_year"], "2024") - self.assertEqual(detail["vod_director"], "导演甲") - self.assertEqual(detail["vod_actor"], "演员甲,演员乙") - self.assertEqual(detail["vod_content"], "一段剧情简介") - self.assertEqual(detail["pan_urls"], ["https://pan.quark.cn/s/q1", "https://pan.baidu.com/s/b1"]) - - def test_parse_detail_page_uses_only_first_mobile_play_poster(self): - html = """ -
    示例剧
    -
    -
    -
    -
    -

    https://pan.baidu.com/s/b1

    -
    - """ - detail = self.spider._parse_detail_page("/index.php/vod/detail/id/123.html", html) - self.assertEqual(detail["vod_pic"], f"{self.spider.host}/poster-main.jpg") - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_pan_play_fields(self, mock_request_html): - mock_request_html.return_value = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - result = self.spider.detailContent(["/index.php/vod/detail/id/123.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["vod_play_from"], "baidu#二小$$$quark#二小") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/q1", - ) - - def test_player_content_passthroughs_supported_pan_urls(self): - self.assertEqual( - self.spider.playerContent("baidu#二小", "https://pan.baidu.com/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.baidu.com/s/demo"}, - ) - - def test_player_content_rejects_non_pan_url(self): - self.assertEqual( - self.spider.playerContent("site", "/index.php/vod/play/id/1.html", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_人人电影.py b/py/tests/test_人人电影.py deleted file mode 100644 index d042d81..0000000 --- a/py/tests/test_人人电影.py +++ /dev/null @@ -1,277 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("rrdy_spider", str(ROOT / "人人电影.py")).load_module() -Spider = MODULE.Spider - - -class TestRenRenDianYingSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_reference_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("movie/list_2", "电影"), - ("dianshiju/list_6", "电视剧"), - ("dongman/list_13", "动漫"), - ("zongyi/list_10", "老电影"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_url_and_pan_detection_helpers(self): - self.assertEqual(self.spider._build_url("/movie/1.html"), "https://www.rrdynb.com/movie/1.html") - self.assertEqual( - self.spider._build_url("https://pan.baidu.com/s/demo"), - "https://pan.baidu.com/s/demo", - ) - self.assertTrue(self.spider._is_supported_pan_url("https://pan.baidu.com/s/demo")) - self.assertTrue(self.spider._is_supported_pan_url("https://pan.quark.cn/s/demo")) - self.assertFalse(self.spider._is_supported_pan_url("https://pan.xunlei.com/s/demo")) - - def test_clean_search_title_and_normalize_title(self): - self.assertEqual( - self.spider._clean_search_title("剑来 第二季"), - "剑来 第二季", - ) - self.assertEqual(self.spider._normalize_title("《繁花》"), "繁花") - self.assertEqual(self.spider._normalize_title("「诛仙」特别篇"), "诛仙") - self.assertEqual(self.spider._normalize_title("普通标题"), "普通标题") - - def test_parse_cards_extracts_expected_fields(self): - html = """ - - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/movie/123.html", - "vod_name": "示例电影", - "vod_pic": "https://www.rrdynb.com/poster.jpg", - "vod_remarks": "8.8", - } - ], - ) - - def test_parse_cards_uses_first_valid_cover_instead_of_concatenating_multiple_urls(self): - html = """ - - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/movie/999.html", - "vod_name": "封面测试", - "vod_pic": "https://www.rrdynb.com/poster-main.jpg", - "vod_remarks": "HD", - } - ], - ) - - def test_parse_cards_uses_first_intro_link_instead_of_concatenating_multiple_detail_urls(self): - html = """ - - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/movie/2025/0814/51262.html", - "vod_name": "第一部", - "vod_pic": "https://www.rrdynb.com/poster.jpg", - "vod_remarks": "HD", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_reference_url_and_page_payload(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.categoryContent("movie/list_2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.rrdynb.com/movie/list_2_3.html", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_category_content_uses_plain_path_for_first_page(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.categoryContent("movie/list_2", "1", False, {}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.rrdynb.com/movie/list_2.html") - self.assertEqual(result["page"], 1) - self.assertEqual(result["list"][0]["vod_id"], "/movie/111.html") - - @patch.object(Spider, "_request_html") - def test_search_content_builds_reference_url_and_cleans_highlight_title(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.searchContent("剑来", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.rrdynb.com/plus/search.php?q=%E5%89%91%E6%9D%A5&pagesize=10&submit=&PageNo=2", - ) - self.assertEqual( - result["list"][0], - { - "vod_id": "/movie/789.html", - "vod_name": "剑来 第二季", - "vod_pic": "https://www.rrdynb.com/search.jpg", - "vod_remarks": "9.2", - }, - ) - - def test_search_content_returns_empty_list_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - def test_extract_pan_links_filters_xunlei_and_aliyun(self): - html = """ - - """ - self.assertEqual( - self.spider._extract_pan_links(html), - [ - ("百度网盘", "https://pan.baidu.com/s/b1"), - ("夸克资源", "https://pan.quark.cn/s/q1"), - ], - ) - - def test_build_pan_lines_groups_by_detected_pan_type(self): - pan_links = [ - ("夸克资源", "https://pan.quark.cn/s/q1"), - ("百度网盘", "https://pan.baidu.com/s/b1"), - ("百度网盘2", "https://pan.baidu.com/s/b2"), - ] - self.assertEqual( - self.spider._build_pan_lines(pan_links), - [ - ("baidu", "百度网盘$https://pan.baidu.com/s/b1#百度网盘2$https://pan.baidu.com/s/b2"), - ("quark", "夸克资源$https://pan.quark.cn/s/q1"), - ], - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_extracts_meta_and_builds_single_pan_line(self, mock_request_html): - mock_request_html.return_value = """ -

    人人示例片

    -
    -
    -

    一段简介

    - 百度网盘 - 夸克资源 - 迅雷资源 -
    - """ - result = self.spider.detailContent(["/movie/123.html"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.rrdynb.com/movie/123.html") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "/movie/123.html") - self.assertEqual(vod["vod_name"], "人人示例片") - self.assertEqual(vod["vod_pic"], "https://www.rrdynb.com/poster-detail.jpg") - self.assertIn("一段简介", vod["vod_content"]) - self.assertEqual(vod["vod_play_from"], "baidu$$$quark") - self.assertEqual( - vod["vod_play_url"], - "百度网盘$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/q1", - ) - - def test_player_content_passthroughs_supported_pan_urls(self): - self.assertEqual( - self.spider.playerContent("网盘", "https://pan.baidu.com/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.baidu.com/s/demo"}, - ) - - def test_player_content_rejects_non_pan_url(self): - self.assertEqual( - self.spider.playerContent("站内", "/movie/123.html", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_leaves_play_fields_empty_when_no_pan_url_exists(self, mock_request_html): - mock_request_html.return_value = """ -

    无网盘影片

    -
    -

    仅有简介,没有网盘。

    - """ - result = self.spider.detailContent(["/movie/empty.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "无网盘影片") - self.assertEqual(vod["vod_play_from"], "") - self.assertEqual(vod["vod_play_url"], "") diff --git a/py/tests/test_人人视频.py b/py/tests/test_人人视频.py deleted file mode 100644 index 4d81aad..0000000 --- a/py/tests/test_人人视频.py +++ /dev/null @@ -1,273 +0,0 @@ -import base64 -import json -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - -from Crypto.Cipher import AES -from Crypto.Util.Padding import pad - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("rrmj_spider", str(ROOT / "人人视频.py")).load_module() -Spider = MODULE.Spider - - -def encrypt_ecb_payload(payload): - cipher = AES.new(MODULE.AES_KEY.encode("utf-8"), AES.MODE_ECB) - body = json.dumps(payload, ensure_ascii=False).encode("utf-8") - return base64.b64encode(cipher.encrypt(pad(body, AES.block_size))).decode("utf-8") - - -def encrypt_cbc_payload(value, new_sign): - key = new_sign[4:20].encode("utf-8") - cipher = AES.new(key, AES.MODE_CBC, MODULE.AES_IV.encode("utf-8")) - return base64.b64encode(cipher.encrypt(pad(value.encode("utf-8"), AES.block_size))).decode("utf-8") - - -class TestRenRenShiPinSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_module_uses_pycryptodome_and_hmac(self): - source = (ROOT / "人人视频.py").read_text(encoding="utf-8") - self.assertIn("from Crypto.Cipher import AES", source) - self.assertIn("from Crypto.Util.Padding import pad, unpad", source) - self.assertIn("import hmac", source) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - @patch("rrmj_spider.time.time", return_value=1713523200.123) - def test_create_headers_signs_sorted_params(self, mock_time): - headers = self.spider._create_headers( - "/m-station/drama/drama_filter_search", - { - "sort": "hot", - "dramaType": "TV", - "year": "", - }, - method="POST", - ) - self.assertEqual(headers["t"], "1713523200123") - self.assertEqual(headers["aliId"], MODULE.KY_ID) - self.assertEqual( - headers["x-ca-sign"], - "wmstQtQaO9LiPx+xbWpi7LncPdyFnf6eVrVTNgoDDBU=", - ) - - @patch.object(Spider, "fetch") - def test_request_api_decrypts_ecb_response(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace( - status_code=200, - text=encrypt_ecb_payload({"code": "0000", "data": {"ok": True}}), - ) - result = self.spider._request_api("/m-station/drama/get_drama_filter") - self.assertEqual(result, {"code": "0000", "data": {"ok": True}}) - self.assertEqual( - mock_fetch.call_args.args[0], - "https://api.rrmj.plus/m-station/drama/get_drama_filter", - ) - - @patch.object(Spider, "_request_api") - def test_home_content_maps_expected_classes_and_filters(self, mock_request_api): - mock_request_api.return_value = { - "code": "0000", - "data": [ - { - "filterType": "sort", - "dramaFilterItemList": [ - {"displayName": "最热", "value": "hot"}, - {"displayName": "最新", "value": "new"}, - ], - }, - { - "filterType": "dramaType", - "dramaFilterItemList": [ - {"displayName": "全部", "value": ""}, - {"displayName": "电视剧", "value": "TV"}, - {"displayName": "电影", "value": "MOVIE"}, - {"displayName": "综艺", "value": "VARIETY"}, - {"displayName": "纪录片", "value": "DOCUMENTARY"}, - {"displayName": "动漫", "value": "COMIC"}, - ], - }, - { - "filterType": "area", - "dramaFilterItemList": [ - {"displayName": "全部", "value": ""}, - {"displayName": "内地", "value": "中国大陆"}, - ], - }, - { - "filterType": "plotType", - "dramaFilterItemList": [ - {"displayName": "全部", "value": ""}, - {"displayName": "悬疑", "value": "悬疑"}, - ], - }, - { - "filterType": "year", - "dramaFilterItemList": [ - {"displayName": "全部", "value": ""}, - {"displayName": "2026", "value": "2026"}, - ], - }, - ], - } - result = self.spider.homeContent(False) - self.assertEqual( - result["class"], - [ - {"type_id": "TV", "type_name": "剧集"}, - {"type_id": "MOVIE", "type_name": "电影"}, - {"type_id": "VARIETY", "type_name": "综艺"}, - {"type_id": "COMIC", "type_name": "动漫"}, - ], - ) - self.assertEqual([item["key"] for item in result["filters"]["TV"]], ["area", "class", "year", "by"]) - self.assertEqual(result["filters"]["TV"][0]["value"][1], {"n": "内地", "v": "中国大陆"}) - self.assertEqual(result["filters"]["TV"][1]["value"][1], {"n": "悬疑", "v": "悬疑"}) - self.assertEqual(result["filters"]["TV"][3]["value"][1], {"n": "最新", "v": "new"}) - - @patch.object(Spider, "_request_api") - def test_category_content_posts_expected_payload_and_omits_pagecount(self, mock_request_api): - mock_request_api.return_value = { - "code": "0000", - "data": [ - { - "dramaId": 56433, - "title": "神与律师事务所", - "coverUrl": "https://img.example/poster.jpg", - "year": "2026", - "subtitle": "柳演锡x李絮x金景南", - } - ], - } - result = self.spider.categoryContent( - "TV", - "2", - False, - {"area": "韩国", "year": "2026", "class": "悬疑", "by": "new"}, - ) - self.assertEqual( - mock_request_api.call_args.args, - ( - "/m-station/drama/drama_filter_search", - { - "area": "韩国", - "sort": "new", - "year": "2026", - "dramaType": "TV", - "plotType": "悬疑", - "contentLabel": "", - "page": 2, - "rows": 30, - }, - ), - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "56433") - self.assertEqual(result["list"][0]["vod_remarks"], "2026 柳演锡x李絮x金景南") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_api") - def test_detail_content_maps_metadata_and_episode_ids(self, mock_request_api): - mock_request_api.return_value = { - "code": "0000", - "data": { - "dramaInfo": { - "dramaId": 56433, - "title": "神与律师事务所", - "cover": "https://img.example/detail.jpg", - "description": "一段剧情简介", - "year": 2026, - "area": "韩国", - "plotType": "剧情/悬疑", - "subtitle": "柳演锡x李絮x金景南", - "playStatus": "更新至12集", - }, - "episodeList": [ - {"id": 380772, "episodeNo": 1, "title": ""}, - {"id": 380810, "episodeNo": 2, "title": "特别篇"}, - ], - }, - } - result = self.spider.detailContent(["56433"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "56433") - self.assertEqual(vod["vod_name"], "神与律师事务所") - self.assertEqual(vod["vod_pic"], "https://img.example/detail.jpg") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_year"], "2026") - self.assertEqual(vod["vod_area"], "韩国") - self.assertEqual(vod["type_name"], "剧情/悬疑") - self.assertEqual(vod["vod_actor"], "柳演锡x李絮x金景南") - self.assertEqual(vod["vod_play_from"], "人人专线") - self.assertEqual(vod["vod_play_url"], "第1集$56433@380772#特别篇$56433@380810") - - @patch.object(Spider, "fetch") - @patch.object(Spider, "_request_api") - def test_player_content_decrypts_play_url_and_uses_redirect_location(self, mock_request_api, mock_fetch): - new_sign = "ngXN+eiB3og7T+Zf3iojv6zn6GFDjCi+9COuBBGHO6Y=" - encrypted_url = encrypt_cbc_payload("https://video.example/decrypted.mp4", new_sign) - mock_request_api.return_value = { - "code": "0000", - "data": { - "m3u8": {"url": encrypted_url}, - "newSign": new_sign, - }, - } - mock_fetch.return_value = SimpleNamespace(status_code=302, headers={"location": "https://video.example/final.mp4"}) - result = self.spider.playerContent("人人专线", "56433@380772", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://video.example/final.mp4") - self.assertEqual(result["header"]["Referer"], MODULE.HOST) - - @patch.object(Spider, "_request_api") - def test_search_content_prefers_precise_results_and_omits_pagecount(self, mock_request_api): - mock_request_api.return_value = { - "code": "0000", - "data": { - "seasonList": [ - { - "id": "56433", - "title": "神与律师事务所", - "cover": "https://img.example/cover.jpg", - "year": "2026", - "classify": "电视剧", - } - ], - "fuzzySeasonList": [ - { - "id": "56694", - "title": "山田轰律师事务所", - "cover": "https://img.example/fuzzy.jpg", - "year": "2026", - "classify": "电视剧", - } - ], - }, - } - result = self.spider.searchContent("律师事务所", False, "1") - self.assertEqual( - result["list"], - [ - { - "vod_id": "56433", - "vod_name": "神与律师事务所", - "vod_pic": "https://img.example/cover.jpg", - "vod_remarks": "2026 电视剧", - } - ], - ) - self.assertNotIn("pagecount", result) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_低端影视.py b/py/tests/test_低端影视.py deleted file mode 100644 index 59d86bb..0000000 --- a/py/tests/test_低端影视.py +++ /dev/null @@ -1,231 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from urllib.parse import quote -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("ddys_spider", str(ROOT / "低端影视.py")).load_module() -Spider = MODULE.Spider - - -class TestDDYSSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_classes_and_filter_keys(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["series", "movie", "variety", "anime"], - ) - self.assertEqual( - [item["key"] for item in content["filters"]["movie"]], - ["class", "area", "year", "by"], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_category_url_applies_default_and_selected_filters(self): - url = self.spider._build_category_url("movie", "2", {"class": "/genre/action", "year": "/year/2025"}) - self.assertEqual(url, "https://ddys.io/movie/genre/action/year/2025/page/2") - - def test_build_category_url_for_first_page_keeps_page_segment(self): - url = self.spider._build_category_url("series", "1", {}) - self.assertEqual(url, "https://ddys.io/series/page/1") - - def test_parse_movie_cards_extracts_expected_fields(self): - html = """ - - """ - cards = self.spider._parse_movie_cards(html) - self.assertEqual( - cards, - [ - { - "vod_id": "movie/test-title", - "vod_name": "测试电影", - "vod_pic": "https://ddys.io/poster.jpg", - "vod_remarks": "HD", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_uses_built_url_and_returns_page_result(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.categoryContent("series", "2", False, {"area": "/region/japan"}) - self.assertEqual(mock_request_html.call_args.args[0], "https://ddys.io/series/region/japan/page/2") - self.assertEqual(result["page"], 2) - self.assertNotIn("pagecount", result) - self.assertEqual(result["limit"], 24) - self.assertEqual(result["list"][0]["vod_name"], "示例剧") - - def test_request_html_posts_form_data_for_search(self): - calls = {} - - class FakeResponse: - def __init__(self): - self.status_code = 200 - self.text = ( - '' - ) - self.encoding = "utf-8" - - def fake_post( - url, - params=None, - data=None, - json=None, - cookies=None, - headers=None, - timeout=5, - verify=True, - stream=False, - allow_redirects=True, - ): - calls["url"] = url - calls["data"] = data - calls["headers"] = headers - return FakeResponse() - - self.spider.post = fake_post - html = self.spider._request_html( - self.spider.host + "/search", - method="POST", - data=f"q={quote('繁花')}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - self.assertIn("命中结果", html) - self.assertEqual(calls["url"], "https://ddys.io/search") - self.assertEqual(calls["data"], "q=%E7%B9%81%E8%8A%B1") - self.assertEqual(calls["headers"]["Content-Type"], "application/x-www-form-urlencoded") - - @patch.object(Spider, "_request_html") - def test_search_content_posts_keyword_and_parses_cards(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.searchContent("繁花", False, "1") - kwargs = mock_request_html.call_args.kwargs - self.assertEqual(kwargs["method"], "POST") - self.assertEqual(kwargs["data"], "q=%E7%B9%81%E8%8A%B1") - self.assertEqual(result["list"][0]["vod_id"], "anime/result") - self.assertNotIn("pagecount", result) - - def test_parse_detail_page_merges_direct_and_pan_sources(self): - html = """ - -

    低端示例DDYS Example

    - 低端示例 -
    2025 · 日本 · 动画
    -
    导演:导演甲
    -
    主演:主演乙
    -

    第一段简介。

    第二段简介。

    - -
    - -
    -
    - -
    - - """ - vod = self.spider._parse_detail_page(html, "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"]) - - def test_extract_pan_sources_deduplicates_same_link_with_different_labels(self): - html = """ -
    - - -
    - """ - groups = self.spider._extract_pan_sources(html) - self.assertEqual(groups, [{"from": "quark", "urls": "查看$https://pan.quark.cn/s/14a405a9bb0d"}]) - - @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(["movie/demo"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://ddys.io/movie/demo/") - self.assertEqual(result["list"][0]["vod_id"], "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") - - def test_player_content_returns_full_url_for_direct_source(self): - result = self.spider.playerContent("DDYS", "/play/ep1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://ddys.io/play/ep1") - self.assertEqual(result["header"]["Referer"], "https://ddys.io/") - - def test_player_content_returns_pan_link_without_rewriting(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_treats_baidu_and_xunlei_as_pan_sources(self): - baidu = self.spider.playerContent("baidu", "https://pan.baidu.com/s/demo", {}) - xunlei = self.spider.playerContent("xunlei", "https://pan.xunlei.com/s/demo", {}) - self.assertEqual(baidu["url"], "https://pan.baidu.com/s/demo") - self.assertEqual(xunlei["url"], "https://pan.xunlei.com/s/demo") - - def test_home_content_keeps_reference_filter_values_for_movie(self): - movie_filters = self.spider.homeContent(False)["filters"]["movie"] - class_values = movie_filters[0]["value"] - area_values = movie_filters[1]["value"] - year_values = movie_filters[2]["value"] - sort_values = movie_filters[3]["value"] - self.assertIn({"n": "动作", "v": "/genre/action"}, class_values) - self.assertIn({"n": "中国", "v": "/region/china"}, area_values) - self.assertIn({"n": "2025", "v": "/year/2025"}, year_values) - self.assertEqual(sort_values[1], {"n": "评分", "v": "rating/"}) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_修罗.py b/py/tests/test_修罗.py deleted file mode 100644 index e44e641..0000000 --- a/py/tests/test_修罗.py +++ /dev/null @@ -1,315 +0,0 @@ -import gzip -import json -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import Mock, patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("xiuluo_spider", str(ROOT / "修罗.py")).load_module() -Spider = MODULE.Spider - - -def build_proxy_payload(text): - return (b"x" * 3354) + gzip.compress(text.encode("utf-8")) - - -class TestXiuLuoSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_classes_and_filters(self): - result = self.spider.homeContent(False) - self.assertEqual( - result["class"], - [ - {"type_id": "movie", "type_name": "电影"}, - {"type_id": "tv", "type_name": "电视剧"}, - {"type_id": "zongyi", "type_name": "综艺"}, - {"type_id": "duanju", "type_name": "短剧"}, - ], - ) - self.assertEqual(result["filters"]["movie"][0]["key"], "genre") - self.assertEqual(result["filters"]["movie"][0]["value"][0], {"n": "不限", "v": "all"}) - self.assertEqual(result["filters"]["movie"][-1]["key"], "order") - self.assertEqual(result["filters"]["zongyi"][0]["key"], "area") - self.assertEqual(result["list"], []) - - def test_category_content_builds_filter_url_and_parses_cards(self): - html = """ -
    - - -
    - """ - response = Mock(status_code=200, text=html) - with patch.object(self.spider, "fetch", return_value=response) as mock_fetch: - result = self.spider.categoryContent( - "movie", - "2", - False, - {"genre": "dongzuo", "area": "中国大陆", "year": "2025", "order": "1"}, - ) - - self.assertEqual( - mock_fetch.call_args.args[0], - "https://www.xlys02.com/s/dongzuo/2?type=0&area=%E4%B8%AD%E5%9B%BD%E5%A4%A7%E9%99%86&year=2025&order=1", - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "movie/demo-a") - self.assertEqual(result["list"][0]["vod_pic"], "https://www.xlys02.com/img/a.jpg") - self.assertEqual(result["list"][1]["vod_pic"], "https://img.example/b.jpg") - self.assertEqual(result["list"][1]["vod_remarks"], "更新至第3集") - - def test_search_content_filters_target_site_and_extracts_vod_items(self): - mock_json = { - "data": [ - { - "website": "哔滴", - "url": "https://foo.example//vod-detail-id-88413-src-1-num-1.htm", - "text": "第二十条", - "icon": "https://img.example/poster.jpg", - }, - { - "website": "其他站", - "url": "https://foo.example//vod-detail-id-1-src-1-num-1.htm", - "text": "应被过滤", - "icon": "https://img.example/skip.jpg", - }, - ] - } - response = Mock(status_code=200) - response.json.return_value = mock_json - with patch.object(self.spider, "fetch", return_value=response) as mock_fetch: - result = self.spider.searchContent("第二十条", False, "1") - - self.assertEqual( - result["list"], - [ - { - "vod_id": "/vod-detail-id-88413-src-1-num-1.htm", - "vod_name": "第二十条", - "vod_pic": "https://img.example/poster.jpg", - "vod_remarks": "哔滴", - } - ], - ) - called_url = mock_fetch.call_args.args[0] - self.assertIn("https://www.ymck.pro/API/v2.php?q=第二十条&size=50", called_url) - - def test_detail_content_builds_single_vod_shell_from_search_id(self): - html = """ - -

    测试影片

    - -
    一段剧情简介
    -
    - 正片 - 备用 -
    - - """ - response = Mock(status_code=200, text=html) - with patch.object(self.spider, "fetch", return_value=response) as mock_fetch: - result = self.spider.detailContent(["movie/test-alpha"]) - - self.assertEqual(mock_fetch.call_args.args[0], "https://www.xlys02.com/movie/test-alpha.htm") - self.assertEqual( - result["list"], - [ - { - "vod_id": "movie/test-alpha", - "vod_name": "测试影片", - "vod_pic": "https://www.xlys02.com/poster.jpg", - "type_name": "", - "vod_class": "", - "vod_year": "", - "vod_area": "", - "vod_lang": "", - "vod_remarks": "", - "vod_actor": "", - "vod_director": "", - "vod_douban_score": "", - "vod_douban_id": "", - "vod_content": "一段剧情简介", - "vod_play_from": "修罗直连", - "vod_play_url": "正片$play/alpha-1#备用$play/alpha-2", - } - ], - ) - - def test_detail_content_skips_watch_history_heading_when_extracting_title(self): - html = """ - -

    观看历史

    -
    -

    真正片名

    - -
    详情简介
    - -
    - - """ - response = Mock(status_code=200, text=html) - with patch.object(self.spider, "fetch", return_value=response): - result = self.spider.detailContent(["movie/right-title"]) - - self.assertEqual(result["list"][0]["vod_name"], "真正片名") - - def test_detail_content_parses_labeled_metadata_block(self): - html = """ - -

    八千里路云和月

    - -
    大时代里的群像故事
    -
    -

    别名:八千里路雲和月 / 厨子与将军

    -

    导演:张永新

    -

    主演:王阳万茜

    -

    类型:剧情历史战争

    -

    制片国家/地区:[中国大陆]

    -

    语言:汉语普通话

    -

    豆瓣链接:33371937

    -

    摘要:40集全.HD1080P4K.国语中字

    -
    - - - """ - response = Mock(status_code=200, text=html) - with patch.object(self.spider, "fetch", return_value=response): - result = self.spider.detailContent(["movie/8klyhy"]) - - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "八千里路云和月") - self.assertEqual(vod["type_name"], "剧情 / 历史 / 战争") - self.assertEqual(vod["vod_class"], "剧情 / 历史 / 战争") - self.assertEqual(vod["vod_area"], "中国大陆") - self.assertEqual(vod["vod_lang"], "汉语普通话") - self.assertEqual(vod["vod_director"], "张永新") - self.assertEqual(vod["vod_actor"], "王阳 / 万茜") - self.assertEqual(vod["vod_douban_id"], "33371937") - self.assertEqual(vod["vod_remarks"], "40集全.HD1080P4K.国语中字") - - @patch.object(Spider, "_current_millis", return_value=1713952212345, create=True) - def test_player_content_requests_lines_and_returns_first_allowed_direct_url(self, _mock_time): - play_html = '' - play_response = Mock(status_code=200, text=play_html) - lines_response = Mock(status_code=200) - lines_response.json.return_value = { - "code": 0, - "data": { - "url3": ( - "https://media.example.com/direct.mp4," - "https://vip.ffzy/video/index.m3u8," - "https://p3-tt.byteimg.com/byte.mp4" - ) - } - } - with patch.object(self.spider, "fetch", side_effect=[play_response, lines_response]) as mock_fetch: - result = self.spider.playerContent("修罗直连", "play/alpha-1", {}) - - self.assertEqual( - mock_fetch.call_args_list[1].args[0], - "https://www.xlys02.com/lines?t=1713952212345&sg=4322A578A3DB6BD0D1936C4F381C9925E2388F66F2DDE5D783B65E2379BE5B56&pid=9527", - ) - self.assertEqual(mock_fetch.call_args_list[1].kwargs["headers"]["X-Requested-With"], "XMLHttpRequest") - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://media.example.com/direct.mp4") - self.assertEqual(result["header"]["Referer"], "https://www.xlys02.com") - - @patch.object(Spider, "_current_millis", return_value=1713952212345, create=True) - def test_player_content_uses_tos_fallback_when_no_allowed_url3(self, _mock_time): - play_html = '' - play_response = Mock(status_code=200, text=play_html) - lines_response = Mock(status_code=200) - lines_response.json.return_value = { - "code": 0, - "data": { - "url3": "https://vip.ffzy/video/index.m3u8,https://p3-tt.byteimg.com/byte.mp4", - "tos": True, - } - } - tos_response = Mock(status_code=200) - tos_response.json.return_value = {"url": "https://media.example.com/from-tos.mp4"} - with patch.object(self.spider, "fetch", side_effect=[play_response, lines_response]): - with patch.object(self.spider, "post", return_value=tos_response) as mock_post: - result = self.spider.playerContent("修罗直连", "play/alpha-1", {}) - - self.assertEqual(mock_post.call_args.args[0], "https://www.xlys02.com/god/9527?type=1") - self.assertEqual( - mock_post.call_args.kwargs["data"], - "t=1713952212345&sg=4322A578A3DB6BD0D1936C4F381C9925E2388F66F2DDE5D783B65E2379BE5B56&verifyCode=888", - ) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://media.example.com/from-tos.mp4") - - def test_player_content_returns_proxy_url_for_real_m3u8(self): - result = self.spider.playerContent("修罗", "https://cdn.example.com/demo/index.m3u8", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "http://127.0.0.1:9978/proxy?do=py&type=m3u8&url=https%3A%2F%2Fcdn.example.com%2Fdemo%2Findex.m3u8") - self.assertIn("Mozilla/5.0", result["header"]["User-Agent"]) - - def test_player_content_returns_direct_url_for_mp4(self): - probe = Mock() - probe.status_code = 206 - probe.headers = {"Content-Type": "video/mp4"} - with patch.object(self.spider, "_range_probe", return_value=probe): - result = self.spider.playerContent("修罗", "https://cdn.example.com/demo/video.mp4", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://cdn.example.com/demo/video.mp4") - - def test_local_proxy_decodes_rewrites_and_returns_m3u8(self): - body = build_proxy_payload( - "#EXTM3U\n" - "#EXTINF:10,\n" - "seg-1.ts\n" - "#EXTINF:10,\n" - "/seg-2.ts\n" - "#EXT-X-DISCONTINUITY\n" - "https://up.example.com/seg-3.ts\n" - "#EXT-X-ENDLIST\n" - ) - response = Mock(status_code=200, content=body) - response.headers = {} - with patch.object(self.spider, "fetch", return_value=response): - status, content_type, data = self.spider.localProxy( - { - "type": "m3u8", - "url": "https://cdn.example.com/path/master.m3u8", - } - ) - - text = data.decode("utf-8") - self.assertEqual(status, 200) - self.assertEqual(content_type, "application/vnd.apple.mpegurl") - self.assertIn("http://127.0.0.1:9978/proxy?do=py&type=m3u8&url=https%3A%2F%2Fcdn.example.com%2Fpath%2Fseg-1.ts", text) - self.assertIn("http://127.0.0.1:9978/proxy?do=py&type=m3u8&url=https%3A%2F%2Fcdn.example.com%2Fseg-2.ts", text) - self.assertIn("http://127.0.0.1:9978/proxy?do=py&type=m3u8&url=https%3A%2F%2Fup.example.com%2Fseg-3.ts", text) - - def test_local_proxy_rejects_unsupported_type(self): - self.assertIsNone(self.spider.localProxy({"type": "other", "url": "https://cdn.example.com/a"})) - - def test_aes_ecb_hex_matches_reference_cipher(self): - self.assertEqual( - self.spider._aes_ecb_hex("1234567890abcdef", "hello"), - "3ae1bf1edbd01914c379e7a9e993bf59", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_凡客TV.py b/py/tests/test_凡客TV.py deleted file mode 100644 index 61bee37..0000000 --- a/py/tests/test_凡客TV.py +++ /dev/null @@ -1,271 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("fktv_spider", str(ROOT / "凡客TV.py")).load_module() -Spider = MODULE.Spider - - -class TestFKTVSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_expected_classes(self): - result = self.spider.homeContent(False) - self.assertEqual( - result["class"], - [ - {"type_id": "1", "type_name": "电影"}, - {"type_id": "2", "type_name": "剧集"}, - {"type_id": "4", "type_name": "动漫"}, - {"type_id": "3", "type_name": "综艺"}, - {"type_id": "8", "type_name": "短剧"}, - {"type_id": "6", "type_name": "纪录片"}, - {"type_id": "7", "type_name": "解说"}, - {"type_id": "5", "type_name": "音乐"}, - ], - ) - - def test_encode_and_decode_play_id_round_trip(self): - encoded = self.spider._encode_play_id( - { - "movie_id": "9001", - "link_id": "ep-1", - "line_id": "line-a", - "line_name": "线路A", - "episode_name": "第1集", - "type": "switch", - "page": "https://fktv.me/movie/detail/9001", - } - ) - payload = self.spider._decode_play_id(encoded) - self.assertEqual(payload["movie_id"], "9001") - self.assertEqual(payload["link_id"], "ep-1") - self.assertEqual(payload["line_id"], "line-a") - self.assertEqual(payload["episode_name"], "第1集") - self.assertEqual(payload["page"], "https://fktv.me/movie/detail/9001") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_url_and_parses_cards(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    - 示例电影 - - 电影 - 更新中 -
    -
    - """ - result = self.spider.categoryContent("1", "2", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://fktv.me/channel?page=2&cat_id=1&page_size=32&order=new", - ) - self.assertEqual( - result["list"], - [ - { - "vod_id": "abc123", - "vod_name": "示例电影", - "vod_pic": "https://fktv.me/poster.jpg", - "vod_remarks": "电影 | 更新中", - "type_name": "电影", - } - ], - ) - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_builds_url_and_handles_blank_keyword(self, mock_request_html): - blank = self.spider.searchContent("", False, "1") - self.assertEqual(blank, {"page": 1, "limit": 0, "total": 0, "list": []}) - mock_request_html.assert_not_called() - - mock_request_html.return_value = """ -
    - 搜索影片 - - 剧集 -
    - """ - result = self.spider.searchContent("繁花", False, "3") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://fktv.me/search?keyword=%E7%B9%81%E8%8A%B1", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["list"][0]["vod_id"], "xyz789") - - @patch.object(Spider, "_request_html") - def test_detail_content_extracts_state_and_builds_multiline_playlist(self, mock_request_html): - mock_request_html.return_value = """ - - - 示例详情 -免费在线观看-凡客影视 - - - - -
    线路A
    -
    线路B
    - - - - """ - result = self.spider.detailContent(["9001"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "9001") - self.assertEqual(vod["vod_name"], "示例详情") - self.assertEqual(vod["vod_pic"], "https://img.example/detail.jpg") - self.assertEqual(vod["vod_content"], "这里是剧情简介") - self.assertEqual(vod["vod_play_from"], "线路A$$$线路B") - self.assertIn("第1集$", vod["vod_play_url"]) - self.assertIn("第2集$", vod["vod_play_url"]) - - @patch.object(Spider, "_request_html") - def test_detail_content_falls_back_to_play_links_when_line_tabs_missing(self, mock_request_html): - mock_request_html.return_value = """ - - 无线路tab - - - - - """ - result = self.spider.detailContent(["9002"]) - vod = result["list"][0] - self.assertEqual(vod["vod_play_from"], "备用线路") - self.assertIn("ep-9", vod["vod_play_url"]) - self.assertIn("VIP", vod["vod_remarks"]) - - def test_player_content_returns_direct_media_without_request(self): - result = self.spider.playerContent("fktv", "https://cdn.example.com/demo.m3u8", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://cdn.example.com/demo.m3u8") - - @patch.object(Spider, "_request_json") - @patch.object(Spider, "_request_html") - def test_player_content_uses_switch_api_and_filters_by_line_id( - self, mock_request_html, mock_request_json - ): - play_id = self.spider._encode_play_id( - { - "movie_id": "9001", - "link_id": "ep-2", - "line_id": "line-b", - "line_name": "线路B", - "episode_name": "第2集", - "type": "switch", - "page": "https://fktv.me/movie/detail/9001", - } - ) - mock_request_html.return_value = """ - - """ - mock_request_json.return_value = { - "status": True, - "data": { - "play_links": [ - {"id": "line-a", "name": "线路A", "m3u8_url": "/media/a.m3u8"}, - {"id": "line-b", "name": "线路B", "m3u8_url": "https://cdn.example.com/b.m3u8"}, - ] - }, - } - result = self.spider.playerContent("fktv", play_id, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://cdn.example.com/b.m3u8") - self.assertEqual(mock_request_json.call_args.args[0], "https://fktv.me/movie/detail/9001") - self.assertEqual( - mock_request_json.call_args.kwargs["data"], - {"link_id": "ep-2", "is_switch": 1}, - ) - - @patch.object(Spider, "_request_json") - @patch.object(Spider, "_request_html") - def test_player_content_returns_all_lines_when_line_not_specified( - self, mock_request_html, mock_request_json - ): - play_id = self.spider._encode_play_id( - { - "movie_id": "9003", - "link_id": "ep-8", - "line_id": "", - "line_name": "", - "episode_name": "正片", - "type": "switch", - "page": "https://fktv.me/movie/detail/9003", - } - ) - mock_request_html.return_value = ( - "" - ) - mock_request_json.return_value = { - "status": True, - "data": { - "play_links": [ - {"id": "line-a", "name": "线路A", "m3u8_url": "/media/a.m3u8"}, - {"id": "line-b", "name": "线路B", "preview_m3u8_url": "/media/b.m3u8"}, - ] - }, - } - result = self.spider.playerContent("fktv", play_id, {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(len(result["urls"]), 2) - self.assertEqual(result["url"], "https://fktv.me/media/a.m3u8") - - @patch.object(Spider, "_request_json") - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_to_detail_page_when_api_has_no_url( - self, mock_request_html, mock_request_json - ): - play_id = self.spider._encode_play_id( - { - "movie_id": "9004", - "link_id": "ep-3", - "line_id": "line-z", - "line_name": "线路Z", - "episode_name": "第3集", - "type": "switch", - "page": "https://fktv.me/movie/detail/9004", - } - ) - mock_request_html.return_value = ( - "" - ) - mock_request_json.return_value = { - "status": True, - "data": {"play_links": [], "play_error_type": "need_vip"}, - } - result = self.spider.playerContent("fktv", play_id, {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["url"], "https://fktv.me/movie/detail/9004") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_剧圈圈.py b/py/tests/test_剧圈圈.py deleted file mode 100644 index 3693f34..0000000 --- a/py/tests/test_剧圈圈.py +++ /dev/null @@ -1,246 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from requests.exceptions import ConnectionError -from unittest.mock import call, patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("juquanquan_spider", str(ROOT / "剧圈圈.py")).load_module() -Spider = MODULE.Spider - - -class TestJuQuanQuanSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["dianying", "juji", "dongman", "zongyi", "duanju"], - ) - - def test_encode_and_decode_detail_and_play_ids(self): - self.assertEqual(self.spider._encode_vod_id("/vod/123.html"), "vod/123") - self.assertEqual(self.spider._decode_vod_id("vod/123"), "https://www.jqqzx.cc/vod/123.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://www.jqqzx.cc/play/123-1-2.html") - - def test_parse_search_list_maps_items_to_compact_vod_ids(self): - payload = '{"list":[{"id":"888","name":"搜索影片","pic":"https://img.example/888.jpg"}]}' - self.assertEqual( - self.spider._parse_search_list(payload), - [ - { - "vod_id": "vod/888", - "vod_name": "搜索影片", - "vod_pic": "https://img.example/888.jpg", - "vod_remarks": "", - } - ], - ) - - def test_parse_cards_extracts_compact_vod_ids(self): - html = """ - - -
    示例影片
    -
    更新至1集
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "vod/123", - "vod_name": "示例影片", - "vod_pic": "https://www.jqqzx.cc/cover.jpg", - "vod_remarks": "更新至1集", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_limits_recommendations(self, mock_request_html): - mock_request_html.return_value = "".join( - f'
    影片{index}
    ' - for index in range(1, 45) - ) - result = self.spider.homeVideoContent() - self.assertEqual(len(result["list"]), 40) - self.assertEqual(result["list"][0]["vod_id"], "vod/1") - - @patch.object(Spider, "_request_html") - def test_search_content_uses_suggest_api(self, mock_request_html): - mock_request_html.return_value = '{"list":[{"id":"777","name":"搜索结果","pic":"/pic.jpg"}]}' - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.jqqzx.cc/index.php/ajax/suggest?mid=1&wd=%E7%B9%81%E8%8A%B1", - ) - self.assertEqual(result["list"][0]["vod_id"], "vod/777") - self.assertNotIn("pagecount", result) - - def test_parse_detail_page_extracts_metadata_and_playlists(self): - html = """ -

    详情标题

    -
    -
    -
    导演
    - -
    -
    -
    主演
    - -
    -
    -
    备注
    -
    更新至3集
    -
    -
    一段剧情简介
    - -
    -
    -
    -
    - -
    - 正片 -
    - """ - result = self.spider._parse_detail_page(html, "vod/123") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "vod/123") - self.assertEqual(vod["vod_name"], "详情标题") - self.assertEqual(vod["vod_pic"], "https://www.jqqzx.cc/poster.jpg") - self.assertEqual(vod["type_name"], "古装 / 剧情") - self.assertEqual(vod["vod_director"], "导演甲") - self.assertEqual(vod["vod_actor"], "演员甲 / 演员乙") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_play_from"], "线路A$$$线路B") - 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_decodes_compact_vod_id(self, mock_request_html): - mock_request_html.return_value = '

    详情标题

    ' - self.spider.detailContent(["vod/321"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.jqqzx.cc/vod/321.html") - - def test_extract_player_data_reads_player_aaaa(self): - html = '' - self.assertEqual(self.spider._extract_player_data(html)["url"], "https://video.example/direct.m3u8") - - @patch.object(Spider, "post") - @patch.object(Spider, "fetch") - def test_request_with_headers_uses_get_without_data_and_post_with_data(self, mock_fetch, mock_post): - class MockResponse: - status_code = 200 - text = "ok" - headers = {"set-cookie": ["foo=bar; Path=/"]} - - mock_fetch.return_value = MockResponse() - mock_post.return_value = MockResponse() - - get_result = self.spider._request_with_headers("https://www.jqqzx.cc/play/123-1-1.html") - post_result = self.spider._request_with_headers( - "https://www.jqqzx.cc/jx/api.php", - headers={"X-Requested-With": "XMLHttpRequest"}, - data="vid=demo", - ) - - self.assertEqual(get_result["body"], "ok") - self.assertEqual(post_result["body"], "ok") - self.assertEqual(mock_fetch.call_count, 1) - self.assertEqual(mock_post.call_count, 1) - - @patch.object(Spider, "_curl_request") - @patch.object(Spider, "fetch") - def test_request_with_headers_falls_back_to_curl_on_get_error(self, mock_fetch, mock_curl_request): - mock_fetch.side_effect = ConnectionError("dns failed") - mock_curl_request.return_value = {"body": "ok", "headers": {"set-cookie": ["a=b; Path=/"]}, "status_code": 200} - - result = self.spider._request_with_headers("https://www.jqqzx.cc/play/62215-5-1.html") - - self.assertEqual(result["body"], "ok") - self.assertEqual(result["status_code"], 200) - self.assertEqual(mock_curl_request.call_count, 1) - - @patch.object(Spider, "_curl_request") - @patch.object(Spider, "post") - def test_request_with_headers_falls_back_to_curl_on_post_error(self, mock_post, mock_curl_request): - mock_post.side_effect = ConnectionError("dns failed") - mock_curl_request.return_value = {"body": '{"code":200}', "headers": {}, "status_code": 200} - - result = self.spider._request_with_headers( - "https://www.jqqzx.cc/jx/api.php", - headers={"X-Requested-With": "XMLHttpRequest"}, - data="vid=demo", - ) - - self.assertEqual(result["body"], '{"code":200}') - self.assertEqual(result["status_code"], 200) - self.assertEqual(mock_curl_request.call_count, 1) - - @patch.object(Spider, "_request_with_headers") - def test_player_content_returns_direct_media_url(self, mock_request_with_headers): - mock_request_with_headers.return_value = { - "body": '', - "headers": {}, - "status_code": 200, - } - result = self.spider.playerContent("线路A", "play/123-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/direct.m3u8") - - @patch.object(Spider, "_request_with_headers") - def test_player_content_uses_parse_api_when_player_vid_is_not_direct(self, mock_request_with_headers): - mock_request_with_headers.side_effect = [ - { - "body": '', - "headers": {"set-cookie": ["foo=bar; Path=/"]}, - "status_code": 200, - }, - { - "body": "", - "headers": {"set-cookie": ["token=abc; Path=/"]}, - "status_code": 200, - }, - { - "body": '{"code":200,"data":{"url":"error://apiRes_dummy"}}', - "headers": {}, - "status_code": 200, - }, - ] - def decode_stub(value): - if value == "error://apiRes_dummy": - return "https://video.example/fallback.m3u8" - return "" - - self.spider._decode_url = decode_stub - result = self.spider.playerContent("线路A", "play/123-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/fallback.m3u8") - api_call = mock_request_with_headers.call_args_list[2] - self.assertEqual(api_call.args[0], "https://www.jqqzx.cc/jx/api.php") - self.assertEqual(api_call.kwargs["data"], "vid=https%3A//middle.example/embed%3Fid%3D1") - self.assertEqual(api_call.kwargs["headers"]["Content-Type"], "application/x-www-form-urlencoded; charset=UTF-8") - self.assertEqual(api_call.kwargs["headers"]["Accept"], "*/*") - self.assertEqual(api_call.kwargs["headers"]["X-Requested-With"], "XMLHttpRequest") - - @patch.object(Spider, "_request_with_headers") - def test_player_content_falls_back_to_play_page_when_player_data_missing(self, mock_request_with_headers): - mock_request_with_headers.return_value = {"body": "", "headers": {}, "status_code": 200} - result = self.spider.playerContent("线路A", "play/123-1-1", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://www.jqqzx.cc/play/123-1-1.html") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_剧迷.py b/py/tests/test_剧迷.py deleted file mode 100644 index 8e908d7..0000000 --- a/py/tests/test_剧迷.py +++ /dev/null @@ -1,228 +0,0 @@ -import unittest -import base64 -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("gimy_spider", str(ROOT / "剧迷.py")).load_module() -Spider = MODULE.Spider - - -class TestGimySpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories_and_filters(self): - content = self.spider.homeContent(False) - class_ids = [item["type_id"] for item in content["class"]] - self.assertEqual(class_ids, ["1", "2", "4", "29", "34", "13"]) - self.assertEqual(content["filters"]["2"][0]["key"], "by") - self.assertEqual(content["filters"]["2"][0]["init"], "time") - - def test_parse_cards_extracts_vod_id_title_cover_and_remarks(self): - html = """ -
    - -
    更新至第3集
    -
    - """ - items = self.spider._extract_cards(html) - self.assertEqual( - items, - [ - { - "vod_id": "123", - "vod_name": "斗罗大陆", - "vod_pic": "https://gimyai.tw/cover.jpg", - "vod_remarks": "更新至第3集", - } - ], - ) - - def test_build_category_url_uses_default_sort_for_page1(self): - self.assertEqual( - self.spider._build_category_url("2", "1", {}), - "https://gimyai.tw/genre/2.html", - ) - - def test_build_category_url_appends_page_and_sort_when_needed(self): - self.assertEqual( - self.spider._build_category_url("2", "3", {"by": "hits"}), - "https://gimyai.tw/genre/2.html?page=3&by=hits", - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_limits_to_24_items(self, mock_request_html): - cards = [] - for index in range(1, 27): - cards.append( - f'
    ' - ) - mock_request_html.return_value = "".join(cards) - result = self.spider.homeVideoContent() - self.assertEqual(len(result["list"]), 24) - self.assertEqual(result["list"][0]["vod_id"], "1") - - @patch.object(Spider, "_request_html") - def test_category_content_builds_page_result(self, mock_request_html): - mock_request_html.return_value = """ -
    - -
    HD
    -
    - """ - result = self.spider.categoryContent("2", "2", False, {"by": "hits"}) - self.assertEqual(mock_request_html.call_args.args[0], "https://gimyai.tw/genre/2.html?page=2&by=hits") - self.assertEqual(result["page"], 2) - self.assertNotIn("pagecount", result) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - - def test_parse_detail_page_extracts_meta_and_playlists(self): - html = """ - -

    機動戰士鋼彈

    -
      -
    • 狀態:更新至第2集
    • -
    • 類別:動畫
    • -
    • 年代:2026
    • -
    • 國家/地區:日本
    • -
    • 導演:导演甲
    • -
    • 主演:演员甲演员乙
    • -
    -
    一段劇情簡介
    -
    - Gimy - 雲播 -
    - -
    - 第1集 -
    - """ - result = self.spider._parse_detail_page(html, "100") - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "100") - self.assertEqual(vod["vod_name"], "机动战士钢弹") - self.assertEqual(vod["vod_pic"], "https://gimyai.tw/poster.jpg") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_remarks"], "更新至第2集") - self.assertEqual(vod["type_name"], "动画") - self.assertEqual(vod["vod_year"], "2026") - self.assertEqual(vod["vod_area"], "日本") - self.assertEqual(vod["vod_actor"], "演员甲,演员乙") - self.assertEqual(vod["vod_director"], "导演甲") - self.assertEqual(vod["vod_play_from"], "Gimy$$$云播") - self.assertEqual(vod["vod_play_url"], "第1集$100-1-1#第2集$100-1-2$$$第1集$100-2-1") - - @patch.object(Spider, "_request_html") - def test_detail_content_accepts_numeric_id(self, mock_request_html): - mock_request_html.return_value = """ -

    详情影片

    - - - """ - result = self.spider.detailContent(["300"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://gimyai.tw/detail/300.html") - self.assertEqual(result["list"][0]["vod_id"], "300") - - def test_normalize_search_keyword_removes_noise_and_converts_traditional(self): - self.assertEqual(self.spider._normalize_search_keyword("鬥羅大陸 線上看"), "斗罗大陆线上看") - - def test_refine_search_results_prefers_exact_match_after_simplification(self): - result = self.spider._refine_search_results( - [ - {"vod_name": "鬥羅大陸", "vod_id": "1"}, - {"vod_name": "斗罗大陆2绝世唐门", "vod_id": "2"}, - {"vod_name": "海贼王", "vod_id": "3"}, - ], - "斗罗大陆", - ) - self.assertEqual([item["vod_id"] for item in result], ["1", "2"]) - - @patch.object(Spider, "_request_html") - def test_search_content_filters_raw_results(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    - -
    -
    - -
    -
    - """ - result = self.spider.searchContent("斗罗大陆", False, "1") - self.assertEqual([item["vod_id"] for item in result["list"]], ["11"]) - self.assertEqual(result["page"], 1) - self.assertNotIn("pagecount", result) - - def test_extract_player_data_reads_json_block(self): - html = '' - data = self.spider._extract_player_data(html) - self.assertEqual(data["url"], "https://video.example/direct.m3u8") - - def test_decode_play_url_supports_encrypt_0_1_2(self): - self.assertEqual( - self.spider._decode_play_url({"url": "https://video.example/raw.m3u8", "encrypt": "0"}), - "https://video.example/raw.m3u8", - ) - self.assertEqual( - self.spider._decode_play_url({"url": "https%3A%2F%2Fvideo.example%2Fescape.m3u8", "encrypt": "1"}), - "https://video.example/escape.m3u8", - ) - encoded = base64.b64encode("https://video.example/base64.m3u8".encode("utf-8")).decode("utf-8") - self.assertEqual( - self.spider._decode_play_url({"url": encoded, "encrypt": "2"}), - "https://video.example/base64.m3u8", - ) - - @patch.object(Spider, "_request_html") - def test_player_content_returns_direct_media_url(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.playerContent("Gimy", "100-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/final.m3u8") - self.assertEqual(result["header"]["Referer"], "https://gimyai.tw/play/100-1-1.html") - - @patch.object(Spider, "_request_html") - def test_player_content_uses_parse_php_when_available(self, mock_request_html): - mock_request_html.side_effect = [ - '', - '{"url":"https://video.example/parsed.m3u8"}', - ] - result = self.spider.playerContent("Gimy", "100-1-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://video.example/parsed.m3u8") - self.assertEqual(result["header"]["Origin"], "https://play.gimyai.tw") - - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_to_raw_url_when_parse_php_returns_empty(self, mock_request_html): - mock_request_html.side_effect = [ - '', - '{}', - ] - result = self.spider.playerContent("Gimy", "100-1-1", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://middle.example/embed?id=1") - - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_to_play_page_when_player_data_missing(self, mock_request_html): - mock_request_html.return_value = "empty" - result = self.spider.playerContent("Gimy", "100-1-1", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://gimyai.tw/play/100-1-1.html") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_博看听书.py b/py/tests/test_博看听书.py deleted file mode 100644 index e8ff887..0000000 --- a/py/tests/test_博看听书.py +++ /dev/null @@ -1,170 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("bookan_tingshu_spider", str(ROOT / "博看听书.py")).load_module() -Spider = MODULE.Spider - - -class TestBookanTingShuSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - @patch.object(Spider, "_fetch_category_books") - def test_home_content_returns_sorted_classes_and_aggregated_list(self, mock_fetch_category_books): - mock_fetch_category_books.side_effect = [ - [{"id": "11", "name": "相声一", "cover": "11.jpg", "author": "甲"}], - [{"id": "12", "name": "国学一", "cover": "12.jpg", "author": "乙"}], - [], - [], - [], - [], - [], - [], - [], - [], - [], - ] - result = self.spider.homeContent(False) - self.assertEqual(result["class"][0], {"type_id": "1319", "type_name": "相声评书"}) - self.assertEqual(result["class"][1], {"type_id": "1320", "type_name": "国学经典"}) - self.assertEqual([item["vod_id"] for item in result["list"]], ["11", "12"]) - self.assertEqual(result["list"][0]["vod_remarks"], "甲 相声评书") - - @patch.object(Spider, "homeContent") - def test_home_video_content_reuses_home_content_list(self, mock_home_content): - mock_home_content.return_value = {"class": [], "list": [{"vod_id": "1"}]} - self.assertEqual(self.spider.homeVideoContent(), {"list": [{"vod_id": "1"}]}) - - @patch.object(Spider, "_api_get") - def test_category_content_maps_books_and_total(self, mock_api_get): - mock_api_get.return_value = { - "data": { - "list": [{"id": "21", "name": "故事书", "cover": "21.jpg", "author": "丙"}], - "total": 30, - } - } - result = self.spider.categoryContent("1303", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertEqual(result["limit"], 24) - self.assertEqual(result["total"], 30) - self.assertEqual(result["list"][0]["vod_id"], "21") - self.assertEqual(mock_api_get.call_args.args[0], "/voice/book/list") - self.assertEqual(mock_api_get.call_args.args[1]["page"], 2) - - def test_category_content_returns_empty_for_unknown_category(self): - result = self.spider.categoryContent("9999", "1", False, {}) - self.assertEqual(result, {"page": 1, "limit": 24, "total": 0, "list": []}) - - @patch.object(Spider, "_search_get") - def test_search_content_maps_results(self, mock_search_get): - mock_search_get.return_value = { - "data": { - "list": [ - {"id": "31", "name": "三国演义", "cover": "31.jpg", "author": "播讲A"}, - {"id": "32", "title": "水浒传", "cover": "32.jpg", "author": "播讲B"}, - ], - "total": 25, - } - } - result = self.spider.searchContent("名著", False, "2") - self.assertEqual(result["page"], 2) - self.assertEqual(result["limit"], 20) - self.assertEqual(result["total"], 25) - self.assertEqual([item["vod_id"] for item in result["list"]], ["31", "32"]) - self.assertEqual(mock_search_get.call_args.args[0], "/api/v3/voice/book") - self.assertEqual(mock_search_get.call_args.args[1]["keyword"], "名著") - - def test_search_content_returns_empty_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "limit": 0, "total": 0, "list": []}) - - @patch.object(Spider, "fetch") - def test_api_get_builds_url_and_parses_json(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text='{"data":{"list":[{"id":"41"}]}}') - payload = self.spider._api_get("/voice/book/list", {"page": 3, "category_id": "1303"}) - self.assertEqual(payload["data"]["list"][0]["id"], "41") - self.assertIn("page=3", mock_fetch.call_args.args[0]) - self.assertEqual(mock_fetch.call_args.kwargs["headers"]["User-Agent"], self.spider.headers["User-Agent"]) - - @patch.object(Spider, "_api_get") - def test_fetch_album_units_merges_multiple_pages(self, mock_api_get): - mock_api_get.side_effect = [ - {"data": {"list": [{"title": "第一集", "file": "https://audio/1.mp3"}], "total": 201}}, - {"data": {"list": [{"title": "第二集", "file": "https://audio/2.mp3"}], "total": 201}}, - ] - items = self.spider._fetch_album_units("99") - self.assertEqual(len(items), 2) - self.assertEqual(items[1]["title"], "第二集") - self.assertEqual(mock_api_get.call_args_list[1].args[1]["page"], 2) - - @patch.object(Spider, "_fetch_album_info") - @patch.object(Spider, "_find_album_from_categories") - @patch.object(Spider, "_fetch_album_units") - def test_detail_content_builds_play_url(self, mock_fetch_album_units, mock_find_album, mock_fetch_album_info): - mock_find_album.return_value = {"vod_name": "分类标题", "vod_pic": "cover.jpg", "vod_author": "作者甲", "found": True} - mock_fetch_album_info.return_value = { - "success": True, - "data": { - "vod_name": "接口标题", - "vod_pic": "api.jpg", - "vod_author": "接口作者", - "vod_desc": "简介", - "created_at": "2026-03-07", - "updated_at": "2026-03-08", - }, - } - mock_fetch_album_units.return_value = [ - {"title": "第一集", "file": "https://audio/1.mp3"}, - {"title": "第二集", "file": "https://audio/2.mp3"}, - ] - result = self.spider.detailContent(["88"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "88") - self.assertEqual(vod["vod_name"], "分类标题") - self.assertEqual(vod["vod_play_from"], "博看听书") - self.assertEqual(vod["vod_play_url"], "1.第一集$https://audio/1.mp3#2.第二集$https://audio/2.mp3") - self.assertEqual(vod["vod_year"], "2026年") - self.assertEqual(vod["vod_remarks"], "作者甲 共2集") - - @patch.object(Spider, "_fetch_album_info") - @patch.object(Spider, "_find_album_from_categories") - @patch.object(Spider, "_fetch_album_units") - def test_detail_content_falls_back_to_album_info_when_category_misses( - self, - mock_fetch_album_units, - mock_find_album, - mock_fetch_album_info, - ): - mock_find_album.return_value = {"vod_name": "", "vod_pic": "", "vod_author": "", "found": False} - mock_fetch_album_info.return_value = { - "success": True, - "data": { - "vod_name": "接口标题", - "vod_pic": "api.jpg", - "vod_author": "接口作者", - "vod_desc": "简介", - "created_at": "2026-03-07", - "updated_at": "2026-03-08", - }, - } - mock_fetch_album_units.return_value = [{"title": "第一集", "file": "https://audio/1.mp3"}] - vod = self.spider.detailContent(["89"])["list"][0] - self.assertEqual(vod["vod_name"], "接口标题") - self.assertEqual(vod["vod_pic"], "api.jpg") - self.assertEqual(vod["vod_remarks"], "接口作者 共1集") - - def test_player_content_returns_direct_audio_url(self): - result = self.spider.playerContent("博看听书", "https://audio/1.mp3", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://audio/1.mp3") - self.assertEqual(result["header"]["User-Agent"], self.spider.headers["User-Agent"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_双星.py b/py/tests/test_双星.py deleted file mode 100644 index 5629102..0000000 --- a/py/tests/test_双星.py +++ /dev/null @@ -1,171 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from urllib.parse import quote -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("shuangxing_spider", str(ROOT / "双星.py")).load_module() -Spider = MODULE.Spider - - -class FakeResponse: - def __init__(self, status_code=200, text="", cookies=None): - self.status_code = status_code - self.text = text - self.cookies = cookies or {} - self.headers = {} - self.url = "" - - -class TestShuangXingSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - - def test_home_content_exposes_reference_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("ju", "国剧"), - ("zy", "综艺"), - ("mv", "电影"), - ("rh", "日韩"), - ("ym", "英美"), - ("wj", "外剧"), - ("dm", "动漫"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - @patch.object(Spider, "fetch") - def test_init_collects_cookie_pairs_and_headers_include_cookie(self, mock_fetch): - mock_fetch.return_value = FakeResponse(cookies={"foo": "bar", "token": "xyz"}) - self.spider.init() - self.assertEqual(self.spider.cookie, "foo=bar; token=xyz") - self.assertEqual(self.spider._headers()["cookie"], "foo=bar; token=xyz") - - def test_headers_without_cookie_keep_base_headers_only(self): - self.assertEqual( - self.spider._headers(), - { - "User-Agent": Spider.UA, - "Referer": Spider.BASE_URL, - }, - ) - - def test_detect_pan_type_returns_expected_keys(self): - self.assertEqual(self.spider._detect_pan_type("https://pan.quark.cn/s/demo"), "quark") - self.assertEqual(self.spider._detect_pan_type("https://www.alipan.com/s/demo"), "ali") - self.assertEqual(self.spider._detect_pan_type("https://example.com/video"), "") - - @patch.object(Spider, "_get_html") - def test_category_content_builds_reference_url_and_parses_cards(self, mock_get_html): - mock_get_html.return_value = """ - - - - """ - result = self.spider.categoryContent("ju", "3", False, {}) - self.assertEqual(mock_get_html.call_args.args[0], "https://1.star2.cn/ju_3/") - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 15) - self.assertEqual(result["total"], 32) - self.assertEqual( - result["list"], - [ - {"vod_id": "/post/alpha", "vod_name": "示例国剧", "vod_pic": "", "vod_remarks": ""}, - {"vod_id": "/post/beta", "vod_name": "示例综艺", "vod_pic": "", "vod_remarks": ""}, - ], - ) - - @patch.object(Spider, "_get_html") - def test_search_content_builds_reference_url_and_parses_results(self, mock_get_html): - mock_get_html.return_value = """ - - - - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_get_html.call_args.args[0], - f"https://1.star2.cn/search/?keyword={quote('繁花')}&page=2", - ) - self.assertEqual(result["page"], 2) - self.assertEqual( - result["list"], - [{"vod_id": "/post/search", "vod_name": "搜索结果", "vod_pic": "", "vod_remarks": ""}], - ) - - def test_search_content_short_circuits_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - @patch.object(Spider, "_get_html") - def test_detail_content_extracts_title_and_sorted_deduplicated_pan_lines(self, mock_get_html): - mock_get_html.return_value = """ - -
    -
    -

    双星示例

    -
    -
    -
    -
    - - - - -
    -
    - - """ - result = self.spider.detailContent(["/post/demo"]) - self.assertEqual(mock_get_html.call_args.args[0], "https://1.star2.cn/post/demo") - self.assertEqual( - result, - { - "list": [ - { - "vod_id": "/post/demo", - "vod_name": "双星示例", - "vod_pic": "", - "vod_remarks": "", - "vod_content": "", - "vod_director": "", - "vod_actor": "", - "vod_play_from": "quark$$$baidu", - "vod_play_url": "夸克资源$https://pan.quark.cn/s/q-demo$$$百度资源$https://pan.baidu.com/s/b-demo", - } - ] - }, - ) - - @patch.object(Spider, "_get_html") - def test_detail_content_returns_empty_list_for_blank_html(self, mock_get_html): - mock_get_html.return_value = "" - self.assertEqual(self.spider.detailContent(["/post/missing"]), {"list": []}) - - def test_player_content_passthroughs_supported_pan_links(self): - self.assertEqual( - self.spider.playerContent("quark", "https://pan.quark.cn/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.quark.cn/s/demo"}, - ) - - def test_player_content_rejects_unknown_links(self): - self.assertEqual( - self.spider.playerContent("site", "https://example.com/video", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_听友FM.py b/py/tests/test_听友FM.py deleted file mode 100644 index 1a50e87..0000000 --- a/py/tests/test_听友FM.py +++ /dev/null @@ -1,319 +0,0 @@ -import json -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - -from Crypto.Cipher import AES -from Crypto.Cipher import ChaCha20_Poly1305 - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("tingyoufm_spider", str(ROOT / "听友FM.py")).load_module() -Spider = MODULE.Spider - - -HOME_HTML = """ - - - 有声小说 - 武侠小说 - - 鬼吹灯 -

    鬼吹灯 作者:天下霸唱 播音:周建龙 12期 已完结

    -
    - - -""" - - -HOME_HTML_WITH_NUXT_COVERS = """ - - - 有声小说 - - 鬼吹灯 -

    鬼吹灯 作者:天下霸唱 播音:周建龙 12期 已完结

    -
    - - - -""" - - -CATEGORY_HTML_WITH_NUXT = """ - - - - - -""" - - -CATEGORY_HTML_WITH_NUXT_REFERENCES = """ - - - - - -""" - - -SEARCH_HTML_WITHOUT_NUXT = """ - - - - 鬼吹灯之精绝古城 -

    鬼吹灯之精绝古城 播音:主播甲

    -
    - - -""" - - -DETAIL_HTML = """ - - - - - - - -
    -
    -

    鬼吹灯

    -
    -
    - 分类: 有声小说 -
    - -
    -
      -
    • -

      1

      -
      第1集
      -
    • -
    • -

      2

      -
      第2集
      -
    • -
    - - -""" - - -class TestTingYouFMSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def _build_v1_payload(self, plain_text): - key = bytes.fromhex(self.spider.payload_key_hex) - iv = bytes(range(12)) - cipher = AES.new(key, AES.MODE_GCM, nonce=iv) - encrypted, tag = cipher.encrypt_and_digest(plain_text.encode("utf-8")) - raw = bytes([1]) + iv + encrypted + tag - return raw.hex() - - @staticmethod - def _build_v2_payload(reversed_cipher=b"cba"): - nonce = bytes(range(1, 25)) - raw = bytes([2]) + nonce + reversed_cipher - return raw.hex() - - def _build_xchacha_v1_payload(self, plain_text): - key = bytes.fromhex(self.spider.payload_key_hex) - nonce = bytes(range(1, 25)) - cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce) - encrypted, tag = cipher.encrypt_and_digest(plain_text.encode("utf-8")) - raw = bytes([1]) + nonce + encrypted + tag - return raw.hex() - - @patch.object(Spider, "fetch") - def test_home_content_extracts_categories_and_album_cards(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=HOME_HTML) - result = self.spider.homeContent(False) - self.assertEqual(result["class"][:2], [{"type_id": "46", "type_name": "有声小说"}, {"type_id": "11", "type_name": "武侠小说"}]) - self.assertEqual(result["list"][0]["vod_id"], "1001") - self.assertEqual(result["list"][0]["vod_name"], "鬼吹灯") - self.assertEqual(result["list"][0]["vod_pic"], "https://tingyou.fm/cover1.jpg") - self.assertIn("12期", result["list"][0]["vod_remarks"]) - - @patch.object(Spider, "fetch") - def test_home_content_prefers_nuxt_cover_when_dom_img_has_no_src(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=HOME_HTML_WITH_NUXT_COVERS) - result = self.spider.homeContent(False) - self.assertEqual(result["list"][0]["vod_id"], "1001") - self.assertEqual(result["list"][0]["vod_pic"], "https://img.test/1001.jpg") - self.assertIn("12期", result["list"][0]["vod_remarks"]) - - @patch.object(Spider, "fetch") - def test_category_content_prefers_nuxt_data(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=CATEGORY_HTML_WITH_NUXT) - result = self.spider.categoryContent("46", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "2001") - self.assertEqual(result["list"][0]["vod_name"], "盗墓笔记") - self.assertEqual(result["list"][0]["vod_pic"], "https://img.test/2001.jpg") - self.assertEqual(result["list"][0]["type_id"], "46") - self.assertEqual(result["limit"], 1) - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "fetch") - def test_category_content_decodes_nuxt_reference_table(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=CATEGORY_HTML_WITH_NUXT_REFERENCES) - result = self.spider.categoryContent("46", "1", False, {}) - self.assertEqual(result["page"], 1) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_id"], "2002") - self.assertEqual(result["list"][0]["vod_name"], "沙海") - self.assertEqual(result["list"][0]["vod_pic"], "https://img.test/2002.jpg") - self.assertIn("88期", result["list"][0]["vod_remarks"]) - - @patch.object(Spider, "fetch") - def test_search_content_falls_back_to_dom_and_filters_blank_keyword(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=SEARCH_HTML_WITHOUT_NUXT) - result = self.spider.searchContent("鬼吹灯", False, "1") - self.assertEqual(result["list"][0]["vod_id"], "3001") - self.assertIn("鬼吹灯", result["list"][0]["vod_name"]) - self.assertEqual( - self.spider.searchContent("", False, "1"), - {"page": 1, "limit": 0, "total": 0, "list": []}, - ) - - @patch.object(Spider, "fetch") - def test_detail_content_extracts_album_and_playlist(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text=DETAIL_HTML) - result = self.spider.detailContent(["1001"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "1001") - self.assertEqual(vod["vod_name"], "鬼吹灯") - self.assertEqual(vod["type_name"], "有声小说") - self.assertEqual(vod["vod_play_from"], "听友FM") - self.assertEqual(vod["vod_play_url"], "第1集$1001|1#第2集$1001|2") - - @patch.object(Spider, "fetch") - def test_detail_content_prefers_full_nuxt_chapter_list_over_truncated_dom(self, mock_fetch): - dom_items = "".join( - f""" -
  • -

    {index}

    -
    第{index}集
    -
  • -""" - for index in range(1, 41) - ) - nuxt_payload = [ - None, - { - "data": { - "album-detail-1001": { - "title": "鬼吹灯", - "cover_url": "https://img.test/detail.jpg", - "synopsis": "摸金校尉探险故事", - }, - "album-chapters-1001": { - "id": 1001, - "available": True, - "count": 42, - "detail": 30, - "chapters": [ - {"id": 5000 + index, "index": str(index), "title": f"{index:03d}.第{index}集"} - for index in range(1, 43) - ], - }, - } - }, - ] - html = f""" - - - - - - - - -
    -
    -

    鬼吹灯

    -
    -
    - 分类: 有声小说 -
    - -
    -
      {dom_items} -
    - - -""" - mock_fetch.return_value = SimpleNamespace(status_code=200, text=html) - result = self.spider.detailContent(["1001"]) - vod = result["list"][0] - play_urls = vod["vod_play_url"].split("#") - self.assertEqual(len(play_urls), 42) - self.assertEqual(play_urls[0], "001.第1集$1001|1") - self.assertEqual(play_urls[-1], "042.第42集$1001|42") - - def test_encrypt_payload_prefixes_version_and_decrypts_v1_payload(self): - payload = self.spider._encrypt_payload('{"album_id":1001,"chapter_idx":1}') - self.assertTrue(payload.startswith("01")) - plain = self.spider._decrypt_payload(self._build_v1_payload('{"url":"https://audio.test/1.m4a"}')) - self.assertEqual(plain, '{"url":"https://audio.test/1.m4a"}') - - def test_decrypt_payload_supports_xchacha_v1_response(self): - plain = self.spider._decrypt_payload(self._build_xchacha_v1_payload('{"auth_token":"guest-token"}')) - self.assertEqual(plain, '{"auth_token":"guest-token"}') - - def test_decrypt_v2_payload_reverses_cipher_before_decoder(self): - calls = {} - - def fake_decrypt(key, nonce, body): - calls["key"] = key - calls["nonce"] = nonce - calls["body"] = body - return b'{"url":"https://audio.test/2.m4a"}' - - self.spider._decrypt_xchacha_body = fake_decrypt - plain = self.spider._decrypt_payload(self._build_v2_payload()) - self.assertEqual(plain, '{"url":"https://audio.test/2.m4a"}') - self.assertEqual(calls["nonce"], bytes(range(1, 25))) - self.assertEqual(calls["body"], b"abc") - - @patch.object(Spider, "_anonymous_auth") - @patch.object(Spider, "_api_post") - def test_player_content_prefers_api_url_and_falls_back_to_audio_page(self, mock_api_post, mock_anonymous_auth): - mock_anonymous_auth.return_value = { - "auth_token": "guest-token", - "cookie": "dfp=f-demo:f-token", - } - mock_api_post.return_value = {"payload": self._build_xchacha_v1_payload('{"play_url":"https://audio.test/play.m4a"}')} - api_result = self.spider.playerContent("听友FM", "1001|1", {}) - self.assertEqual(api_result["parse"], 0) - self.assertEqual(api_result["url"], "https://audio.test/play.m4a") - mock_anonymous_auth.assert_called_once() - self.assertEqual(mock_api_post.call_args.args[0], "/api/play_token") - self.assertEqual(mock_api_post.call_args.args[1], {"album_id": 1001, "chapter_idx": 1}) - self.assertEqual(mock_api_post.call_args.kwargs["extra_headers"]["Authorization"], "Bearer guest-token") - self.assertEqual(mock_api_post.call_args.kwargs["extra_headers"]["Cookie"], "dfp=f-demo:f-token") - - mock_api_post.side_effect = RuntimeError("boom") - fallback_result = self.spider.playerContent("听友FM", "1001|1", {}) - self.assertEqual(fallback_result["parse"], 1) - self.assertEqual(fallback_result["url"], "https://tingyou.fm/audios/1001/1") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_四万影视.py b/py/tests/test_四万影视.py deleted file mode 100644 index 5fd903c..0000000 --- a/py/tests/test_四万影视.py +++ /dev/null @@ -1,205 +0,0 @@ -import json -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("siwan_spider", str(ROOT / "四万影视.py")).load_module() -Spider = MODULE.Spider - - -class TestSiWanSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def _response(self, status_code=200, payload=None, text=None): - body = text if text is not None else json.dumps(payload or {}, ensure_ascii=False) - return SimpleNamespace(status_code=status_code, text=body) - - def test_home_content_exposes_expected_classes_and_filters(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["20", "30", "39", "45", "32"], - ) - self.assertEqual( - [item["key"] for item in content["filters"]["20"]], - ["subType", "year", "sort"], - ) - self.assertEqual(content["filters"]["20"][0]["init"], "20") - self.assertEqual(content["filters"]["20"][1]["value"][0], {"n": "全部", "v": ""}) - self.assertEqual(content["filters"]["20"][2]["value"][0], {"n": "时间", "v": "time"}) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - @patch.object(Spider, "fetch") - def test_api_get_requests_maccms_endpoint_with_browser_headers(self, mock_fetch): - mock_fetch.return_value = self._response(payload={"list": []}) - data = self.spider._api_get({"ac": "detail", "pg": 1}) - self.assertEqual(data, {"list": []}) - self.assertEqual(mock_fetch.call_args.args[0], "https://40000.me/api/maccms") - self.assertEqual(mock_fetch.call_args.kwargs["params"], {"ac": "detail", "pg": 1}) - self.assertEqual(mock_fetch.call_args.kwargs["headers"]["Referer"], "https://40000.me/") - - @patch.object(Spider, "fetch") - def test_api_get_rejects_non_200_and_non_object_body(self, mock_fetch): - mock_fetch.return_value = self._response(status_code=500, payload={"msg": "bad"}) - with self.assertRaises(ValueError): - self.spider._api_get({"ac": "detail"}) - - mock_fetch.return_value = self._response(text="[]") - with self.assertRaises(ValueError): - self.spider._api_get({"ac": "detail"}) - - def test_normalize_vod_applies_type_name_actor_cleanup_and_fallback_cover(self): - self.assertEqual( - self.spider._normalize_vod( - { - "vod_id": 7, - "vod_name": "示例影片", - "vod_pic": "", - "type_id": "20", - "vod_remarks": "HD", - "vod_year": "2025", - "vod_actor": "O&#039;Brien , 张三", - } - ), - { - "vod_id": "7", - "vod_name": "示例影片", - "vod_pic": "https://40000.me/public/favicon.png", - "vod_remarks": "HD", - "vod_year": "2025", - "type_name": "电影", - "vod_actor": "O'Brien, 张三", - }, - ) - - @patch.object(Spider, "_api_get") - def test_category_content_maps_subtype_year_sort_and_page(self, mock_api_get): - mock_api_get.return_value = { - "page": 2, - "total": 55, - "list": [ - {"vod_id": 100, "vod_name": "分类影片", "vod_pic": "/p.jpg", "type_id": "21", "vod_remarks": "更新中"} - ], - } - result = self.spider.categoryContent("20", "2", False, {"subType": "21", "year": "2025", "sort": "hits"}) - self.assertEqual( - mock_api_get.call_args.args[0], - {"ac": "detail", "t": "21", "pg": 2, "h": "2025", "by": "hits"}, - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["total"], 55) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_id"], "100") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_api_get") - def test_search_content_maps_keyword_and_page(self, mock_api_get): - mock_api_get.return_value = { - "page": 3, - "total": 10, - "list": [ - {"vod_id": 200, "vod_name": "搜索结果", "vod_pic": "", "type_id": "30", "vod_remarks": "完结"} - ], - } - result = self.spider.searchContent("繁花", False, "3") - self.assertEqual(mock_api_get.call_args.args[0], {"ac": "detail", "wd": "繁花", "pg": 3}) - self.assertEqual(result["page"], 3) - self.assertEqual(result["list"][0]["type_name"], "电视剧") - - def test_search_content_returns_empty_list_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "limit": 0, "total": 0, "list": []}) - - def test_parse_play_groups_builds_repo_style_play_fields(self): - self.assertEqual( - self.spider._parse_play_groups( - { - "vod_play_from": "量子$$$", - "vod_play_url": "第1集$https://cdn.example/1.m3u8#第2集$abc$$$solo", - } - ), - { - "vod_play_from": "量子$$$线路2", - "vod_play_url": "第1集$https://cdn.example/1.m3u8#第2集$abc$$$第1集$solo", - }, - ) - - @patch.object(Spider, "_api_get") - def test_detail_content_builds_detail_and_play_fields(self, mock_api_get): - mock_api_get.return_value = { - "list": [ - { - "vod_id": 300, - "vod_name": "详情影片", - "vod_pic": "/poster.jpg", - "type_id": "30", - "type_name": "", - "vod_year": "2024", - "vod_area": "大陆", - "vod_director": "导演甲", - "vod_actor": "A&#039;B , 演员乙", - "vod_blurb": "一段简介", - "vod_remarks": "更新至2集", - "vod_play_from": "量子$$$非凡", - "vod_play_url": "第1集$https://cdn.example/1.m3u8#第2集$play-2$$$正片$play-3", - } - ] - } - result = self.spider.detailContent(["300"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "300") - self.assertEqual(vod["type_name"], "电视剧") - self.assertEqual(vod["vod_actor"], "A'B, 演员乙") - self.assertEqual(vod["vod_play_from"], "量子$$$非凡") - self.assertEqual( - vod["vod_play_url"], - "第1集$https://cdn.example/1.m3u8#第2集$play-2$$$正片$play-3", - ) - - @patch.object(Spider, "_api_get") - def test_category_content_returns_empty_payload_on_api_error(self, mock_api_get): - mock_api_get.side_effect = ValueError("boom") - self.assertEqual( - self.spider.categoryContent("20", "1", False, {}), - {"page": 1, "limit": 0, "total": 0, "list": []}, - ) - - @patch.object(Spider, "_api_get") - def test_detail_content_returns_empty_list_on_api_error(self, mock_api_get): - mock_api_get.side_effect = ValueError("boom") - self.assertEqual(self.spider.detailContent(["9"]), {"list": []}) - - def test_player_content_returns_direct_url_for_http_play_id(self): - result = self.spider.playerContent("量子", "https://cdn.example/test.m3u8", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://cdn.example/test.m3u8") - self.assertEqual(result["header"]["Referer"], "https://40000.me/") - - def test_player_content_marks_non_http_play_id_for_parse(self): - result = self.spider.playerContent("量子", "play-2", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "play-2") - - def test_player_content_returns_empty_parse_payload_for_blank_id(self): - self.assertEqual( - self.spider.playerContent("量子", "", {}), - { - "parse": 1, - "jx": 1, - "url": "", - "header": { - "User-Agent": self.spider.headers["User-Agent"], - "Referer": "https://40000.me/", - }, - }, - ) diff --git a/py/tests/test_在线之家.py b/py/tests/test_在线之家.py deleted file mode 100644 index 94fd827..0000000 --- a/py/tests/test_在线之家.py +++ /dev/null @@ -1,291 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("zxzj_spider", str(ROOT / "在线之家.py")).load_module() -Spider = MODULE.Spider - - -class TestZXZJSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_classes_and_filter_keys(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["1", "2", "3", "4", "5", "6"], - ) - self.assertEqual( - [item["key"] for item in content["filters"]["1"]], - ["class", "area", "year", "by"], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_category_url_applies_default_and_selected_filters(self): - url = self.spider._build_category_url("1", "2", {"area": "欧美", "by": "hits", "year": "2025"}) - self.assertEqual(url, "https://www.zxzjhd.com/vodshow/1-欧美-hits------2---2025.html") - - def test_build_category_url_for_first_page_keeps_page_1_segment(self): - url = self.spider._build_category_url("2", "1", {}) - self.assertEqual(url, "https://www.zxzjhd.com/vodshow/2--------1---.html") - - def test_fix_json_wrapped_html_unwraps_html_string(self): - wrapped = "\"ok\"" - self.assertEqual(self.spider._fix_json_wrapped_html(wrapped), "ok") - - def test_parse_cards_extracts_short_vod_id_title_cover_and_remarks(self): - html = """ -
      -
    • - - 更新至10集 -
    • -
    - """ - cards = self.spider._parse_cards(html) - self.assertEqual( - cards, - [ - { - "vod_id": "voddetail/12345.html", - "vod_name": "示例影片", - "vod_pic": "https://www.zxzjhd.com/cover.jpg", - "vod_remarks": "更新至10集", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_uses_built_url_and_returns_page_result(self, mock_request_html): - mock_request_html.return_value = """ -
      -
    • - - HD -
    • -
    - """ - result = self.spider.categoryContent("1", "2", False, {"area": "欧美"}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.zxzjhd.com/vodshow/1-欧美-------2---.html") - self.assertEqual(result["page"], 2) - self.assertEqual(result["limit"], 24) - self.assertEqual(result["list"][0]["vod_id"], "voddetail/23456.html") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_parses_search_cards(self, mock_request_html): - mock_request_html.return_value = """ -
      -
    • - - 抢先版 -
    • -
    - """ - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.zxzjhd.com/vodsearch/%E7%B9%81%E8%8A%B1-------------.html", - ) - 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, []) - - def test_decrypt_url_round_trip_with_fixture(self): - self.assertEqual( - self.spider._decrypt_url("d6f636e256c607d6168756e2f67464544434241456469667f2f2a33707474786"), - "https://video.example.com", - ) - - def test_extract_iframe_url_reads_jx_target(self): - html = '' - self.assertEqual( - self.spider._extract_iframe_url(html), - "https://jx.zxzj.example/player?id=1", - ) - - def test_extract_iframe_url_reads_player_aaaa_target(self): - html = """ - - """ - self.assertEqual( - self.spider._extract_iframe_url(html), - "https://jx.zxzjys.com:9876/player-v2/common/demo/token", - ) - - @patch.object(Spider, "_request_html") - def test_player_content_returns_decoded_direct_url_for_zxzj(self, mock_request_html): - mock_request_html.side_effect = [ - '', - '', - ] - result = self.spider.playerContent("zxzj", "vodplay/999-1-1.html", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://video.example.com") - self.assertEqual(result["header"]["Referer"], "https://jx.zxzj.example/player?id=1") - - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_to_play_page_when_decrypt_fails(self, mock_request_html): - mock_request_html.side_effect = [ - '', - '', - ] - result = self.spider.playerContent("zxzj", "vodplay/999-1-1.html", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://www.zxzjhd.com/vodplay/999-1-1.html") - - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_to_parser_page_when_new_line5_target_cannot_be_decoded(self, mock_request_html): - mock_request_html.side_effect = [ - """ - - """, - "", - ] - result = self.spider.playerContent("zxzj", "vodplay/4627-1-1.html", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://jx.zxzjys.com:9876/player-v2/common/demo/token") - self.assertEqual(result["header"]["Referer"], "https://www.zxzjhd.com/vodplay/4627-1-1.html") - - @patch.object(Spider, "fetch") - def test_player_content_uses_browser_like_headers_for_new_line5_parser_request(self, mock_fetch): - play_page = """ - - """ - parser_page = """ - - """ - - def fake_fetch(url, headers=None, timeout=10): - if url == "https://www.zxzjhd.com/vodplay/4627-1-1.html": - return SimpleNamespace(status_code=200, text=play_page) - if url == "https://jx.zxzjys.com:9876/player-v2/common/demo/token": - self.assertEqual(headers["Referer"], "https://www.zxzjhd.com/vodplay/4627-1-1.html") - self.assertEqual(headers["Origin"], "https://www.zxzjhd.com") - self.assertEqual(headers["Sec-Fetch-Dest"], "iframe") - self.assertEqual(headers["Sec-Fetch-Mode"], "navigate") - self.assertEqual(headers["Sec-Fetch-Site"], "cross-site") - self.assertEqual(headers["Upgrade-Insecure-Requests"], "1") - return SimpleNamespace(status_code=200, text=parser_page) - raise AssertionError(url) - - mock_fetch.side_effect = fake_fetch - result = self.spider.playerContent("zxzj", "vodplay/4627-1-1.html", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://video.example.com") - - def test_player_content_returns_direct_netdisk_link(self): - result = self.spider.playerContent("quark", "https://pan.quark.cn/s/demo", {}) - self.assertEqual( - result, - {"parse": 0, "jx": 0, "playUrl": "", "url": "https://pan.quark.cn/s/demo", "header": {}}, - ) - - def test_home_content_keeps_reference_filter_values_for_movie_and_anime(self): - filters = self.spider.homeContent(False)["filters"] - movie_filters = filters["1"] - anime_filters = filters["6"] - self.assertIn({"n": "喜剧", "v": "喜剧"}, movie_filters[0]["value"]) - self.assertIn({"n": "欧美", "v": "欧美"}, movie_filters[1]["value"]) - self.assertIn({"n": "2025", "v": "2025"}, movie_filters[2]["value"]) - self.assertEqual(movie_filters[3]["value"][1], {"n": "人气", "v": "hits"}) - self.assertIn({"n": "国产", "v": "国产"}, anime_filters[1]["value"]) - self.assertIn({"n": "热血", "v": "热血"}, anime_filters[0]["value"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_奕搜.py b/py/tests/test_奕搜.py deleted file mode 100644 index 05f0369..0000000 --- a/py/tests/test_奕搜.py +++ /dev/null @@ -1,165 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from urllib.parse import quote -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("yisou_spider", str(ROOT / "奕搜.py")).load_module() -Spider = MODULE.Spider - - -class TestYiSouSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["dy", "dsj", "zy", "dm", "jlp", "dj"], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_extract_remark_from_title_prefers_update_score_then_year(self): - self.assertEqual(self.spider._extract_remark_from_title("片名 [更12] [8.5分] [2025]"), "更新至12集") - self.assertEqual(self.spider._extract_remark_from_title("片名 [8.5分] [2025]"), "评分:8.5") - self.assertEqual(self.spider._extract_remark_from_title("片名 [2025]"), "首播:2025") - - def test_clean_title_text_removes_bracket_tags(self): - self.assertEqual(self.spider._clean_title_text("片名 [更12] [2025]"), "片名") - - def test_parse_list_boxes_extracts_short_ids_and_remark(self): - html = """ -
    - 示例影片 [更12] [8.2分] - - -
    列表备注
    -
    - """ - self.assertEqual( - self.spider._parse_list_boxes(html), - [ - { - "vod_id": "/resource/demo-1", - "vod_name": "示例影片", - "vod_pic": "https://ysso.cc/cover.jpg", - "vod_remarks": "更新至12集", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_uses_expected_url(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.categoryContent("dy", "3", False, {}) - self.assertEqual(mock_request_html.call_args.args[0], "https://ysso.cc/dy.html?page=3") - self.assertEqual(result["page"], 3) - self.assertEqual(result["list"][0]["vod_id"], "/resource/demo-2") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_uses_encoded_keyword(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - f"https://ysso.cc/search.html?keyword={quote('繁花')}&page=2", - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_remarks"], "评分:8.9") - - def test_search_content_empty_keyword_returns_empty_list(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - def test_identify_disk(self): - self.assertEqual(self.spider._identify_disk("https://pan.baidu.com/s/demo"), "baidu") - self.assertEqual(self.spider._identify_disk("https://pan.quark.cn/s/demo"), "quark") - self.assertEqual(self.spider._identify_disk("https://drive.uc.cn/s/demo"), "uc") - self.assertEqual(self.spider._identify_disk("https://www.alipan.com/s/demo"), "aliyun") - self.assertEqual(self.spider._identify_disk("https://pan.xunlei.com/s/demo"), "xunlei") - self.assertEqual(self.spider._identify_disk("https://example.com/file"), "") - - def test_build_play_data_groups_and_sorts_share_links(self): - play = self.spider._build_play_data( - [ - "https://pan.quark.cn/s/quark1", - "https://pan.baidu.com/s/baidu1", - "https://pan.quark.cn/s/quark1", - "https://pan.xunlei.com/s/xl1", - ] - ) - self.assertEqual(play["vod_play_from"], "baidu$$$quark$$$xunlei") - self.assertIn("baidu$https://pan.baidu.com/s/baidu1", play["vod_play_url"]) - self.assertIn("quark$https://pan.quark.cn/s/quark1", play["vod_play_url"]) - - def test_build_play_data_falls_back_to_push_urls_for_unknown_links(self): - play = self.spider._build_play_data( - ["https://example.com/file1", "https://example.com/file2"] - ) - self.assertEqual(play["vod_play_from"], "奕搜") - self.assertEqual( - play["vod_play_url"], - "1$push://https://example.com/file1#2$push://https://example.com/file2", - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_extracts_metadata_and_netdisk_links(self, mock_request_html): - mock_request_html.return_value = """ - -

    示例详情 [更12] [2025]

    -
    -
    - 导演:导演甲编剧乙 - 主演:演员甲演员乙 -
    -

    这是一段足够长的剧情简介,用于测试详情页内容提取逻辑。

    - 夸克 - 百度 -
    提取码:a1b2
    - - """ - result = self.spider.detailContent(["/resource/demo-1"]) - vod = result["list"][0] - self.assertEqual(mock_request_html.call_args.args[0], "https://ysso.cc/resource/demo-1") - self.assertEqual(vod["vod_id"], "/resource/demo-1") - self.assertEqual(vod["vod_name"], "示例详情") - self.assertEqual(vod["vod_pic"], "https://ysso.cc/poster.jpg") - self.assertEqual(vod["vod_director"], "导演甲, 编剧乙") - self.assertEqual(vod["vod_actor"], "演员甲, 演员乙") - self.assertIn("剧情简介", vod["vod_content"]) - self.assertEqual(vod["vod_play_from"], "baidu$$$quark") - self.assertIn("提取码: a1b2", vod["vod_remarks"]) - - def test_player_content_strips_push_prefix(self): - result = self.spider.playerContent("奕搜", "push://https://pan.quark.cn/s/abc123", {}) - self.assertEqual(result, {"parse": 0, "url": "https://pan.quark.cn/s/abc123"}) - - def test_player_content_passthroughs_http_url(self): - result = self.spider.playerContent("quark", "https://pan.quark.cn/s/abc123", {}) - self.assertEqual(result, {"parse": 0, "url": "https://pan.quark.cn/s/abc123"}) - - @patch.object(Spider, "_request_html") - def test_detail_content_returns_empty_list_when_html_missing(self, mock_request_html): - mock_request_html.return_value = "" - self.assertEqual(self.spider.detailContent(["/resource/missing"]), {"list": []}) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_如意资源.py b/py/tests/test_如意资源.py deleted file mode 100644 index bd9a9e0..0000000 --- a/py/tests/test_如意资源.py +++ /dev/null @@ -1,315 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("ruyi_spider", str(ROOT / "如意资源.py")).load_module() -Spider = MODULE.Spider - - -class FakeResponse: - def __init__(self, status_code=200, text="{}"): - self.status_code = status_code - self.text = text - self.encoding = "utf-8" - - -class TestRuYiSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_hardcoded_classes_and_filters(self): - result = self.spider.homeContent(False) - class_ids = [item["type_id"] for item in result["class"]] - self.assertEqual(class_ids, ["1", "2", "3", "4", "35", "36"]) - self.assertEqual(result["filters"]["1"][0]["key"], "type") - self.assertEqual(result["filters"]["1"][0]["value"][0], {"n": "动作片", "v": "7"}) - self.assertEqual(result["filters"]["2"][0]["value"][0], {"n": "国产剧", "v": "13"}) - self.assertEqual(result["filters"]["35"], []) - self.assertEqual(result["list"], []) - - def test_get_pic_url_handles_empty_absolute_and_relative_values(self): - self.assertEqual(self.spider._get_pic_url(""), "") - self.assertEqual(self.spider._get_pic_url(""), "") - self.assertEqual( - self.spider._get_pic_url("https://img.example.com/poster.jpg"), - "https://img.example.com/poster.jpg", - ) - self.assertEqual( - self.spider._get_pic_url("/upload/poster.jpg"), - "https://ps.ryzypics.com/upload/poster.jpg", - ) - - @patch.object(Spider, "fetch") - def test_request_json_falls_back_to_second_api_after_failure(self, mock_fetch): - mock_fetch.side_effect = [ - FakeResponse(status_code=500, text=""), - FakeResponse( - text=( - '{"list":[{"vod_id":"9","vod_name":"后备命中","vod_pic":"/poster.jpg",' - '"vod_remarks":"HD","vod_year":"2026","type_id":"7"}]}' - ) - ), - ] - result = self.spider._request_json({"ac": "list", "pg": "1", "pagesize": "20"}) - self.assertEqual(result["list"][0]["vod_name"], "后备命中") - self.assertEqual(mock_fetch.call_count, 2) - first_url = mock_fetch.call_args_list[0].args[0] - second_url = mock_fetch.call_args_list[1].args[0] - self.assertIn("https://cj.rycjapi.com/api.php/provide/vod", first_url) - self.assertIn("https://cj.rytvapi.com/api.php/provide/vod", second_url) - - @patch.object(Spider, "_request_json") - def test_home_video_content_maps_recommend_list(self, mock_request_json): - mock_request_json.return_value = { - "list": [ - { - "vod_id": "101", - "vod_name": "推荐影片", - "vod_pic": "/cover.jpg", - "vod_remarks": "更新至1集", - "vod_year": "2025", - "type_id": "7", - } - ] - } - result = self.spider.homeVideoContent() - self.assertEqual( - result, - { - "list": [ - { - "vod_id": "101", - "vod_name": "推荐影片", - "vod_pic": "https://ps.ryzypics.com/cover.jpg", - "vod_remarks": "更新至1集", - "vod_year": "2025", - "type_id": "7", - } - ] - }, - ) - self.assertEqual(mock_request_json.call_args.args[0], {"ac": "list", "pg": "1", "pagesize": "20"}) - - @patch.object(Spider, "_request_json") - def test_home_video_content_enriches_missing_cover_from_videolist(self, mock_request_json): - mock_request_json.side_effect = [ - { - "list": [ - { - "vod_id": "101", - "vod_name": "推荐影片", - "vod_pic": "", - "vod_remarks": "更新至1集", - "vod_year": "2025", - "type_id": "7", - } - ] - }, - { - "list": [ - { - "vod_id": "101", - "vod_pic": "/detail-cover.jpg", - } - ] - }, - ] - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_pic"], "https://ps.ryzypics.com/detail-cover.jpg") - self.assertEqual( - mock_request_json.call_args_list[1].args[0], - {"ac": "videolist", "ids": "101"}, - ) - - @patch.object(Spider, "_request_json") - def test_category_content_uses_default_sub_type_for_main_class(self, mock_request_json): - mock_request_json.side_effect = [ - { - "list": [ - { - "vod_id": "202", - "vod_name": "动作电影", - "vod_pic": "", - "vod_remarks": "HD", - "vod_year": "2024", - "type_id": "7", - } - ] - }, - {"list": []}, - ] - result = self.spider.categoryContent("1", "2", False, {}) - self.assertEqual( - mock_request_json.call_args_list[0].args[0], - {"ac": "list", "t": "7", "pg": "2", "pagesize": "20"}, - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["limit"], 20) - self.assertEqual(result["list"][0]["vod_id"], "202") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_json") - def test_category_content_prefers_extend_type(self, mock_request_json): - mock_request_json.return_value = {"list": []} - self.spider.categoryContent("1", "1", False, {"type": "10"}) - self.assertEqual(mock_request_json.call_args.args[0]["t"], "10") - - def test_search_content_returns_empty_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "limit": 30, "total": 0, "list": []}) - - @patch.object(Spider, "_request_json") - def test_search_content_filters_titles_by_keyword(self, mock_request_json): - mock_request_json.return_value = { - "list": [ - { - "vod_id": "301", - "vod_name": "繁花", - "vod_pic": "/a.jpg", - "vod_remarks": "完结", - "vod_year": "2024", - "type_id": "13", - }, - { - "vod_id": "302", - "vod_name": "狂飙", - "vod_pic": "/b.jpg", - "vod_remarks": "完结", - "vod_year": "2023", - "type_id": "13", - }, - ] - } - result = self.spider.searchContent("繁花", False, "3") - self.assertEqual(mock_request_json.call_args.args[0], {"ac": "list", "wd": "繁花", "pg": "3", "pagesize": "30"}) - self.assertEqual(result["page"], 3) - self.assertEqual( - result["list"], - [ - { - "vod_id": "301", - "vod_name": "繁花", - "vod_pic": "https://ps.ryzypics.com/a.jpg", - "vod_remarks": "完结", - "vod_year": "2024", - "type_id": "13", - } - ], - ) - - @patch.object(Spider, "_request_json") - def test_detail_content_maps_metadata_and_play_groups(self, mock_request_json): - mock_request_json.return_value = { - "list": [ - { - "vod_id": "888", - "vod_name": "示例剧", - "vod_pic": "/detail.jpg", - "type_name": "国产剧", - "vod_year": "2026", - "vod_area": "大陆", - "vod_remarks": "更新至2集", - "vod_actor": "张三,李四", - "vod_director": "导演甲", - "vod_content": "一段简介", - "vod_play_from": "如意线路,备用线路", - "vod_play_url": "第1集$https://cdn.example.com/1.m3u8#第2集$https://parser.example.com/play/2", - } - ] - } - result = self.spider.detailContent(["888"]) - vod = result["list"][0] - self.assertEqual(mock_request_json.call_args.args[0], {"ac": "videolist", "ids": "888"}) - self.assertEqual(vod["vod_id"], "888") - self.assertEqual(vod["vod_pic"], "https://ps.ryzypics.com/detail.jpg") - self.assertEqual(vod["type_name"], "国产剧") - self.assertEqual(vod["vod_play_from"], "如意线路$$$备用线路") - self.assertEqual( - vod["vod_play_url"], - ( - "第1集$https://cdn.example.com/1.m3u8#第2集$https://parser.example.com/play/2$$$" - "第1集$https://cdn.example.com/1.m3u8#第2集$https://parser.example.com/play/2" - ), - ) - - def test_detail_content_returns_empty_for_blank_id(self): - self.assertEqual(self.spider.detailContent([""]), {"list": []}) - - def test_parse_play_groups_skips_invalid_entries_and_fills_missing_names(self): - play_from, play_url = self.spider._parse_play_groups( - "主线", - "$https://cdn.example.com/a.m3u8#预告$#https://cdn.example.com/b.mp4", - ) - self.assertEqual(play_from, "主线") - self.assertEqual(play_url, "第1集$https://cdn.example.com/a.m3u8#第3集$https://cdn.example.com/b.mp4") - - def test_player_content_returns_direct_media_url(self): - result = self.spider.playerContent("如意线路", "https://cdn.example.com/1.m3u8", {}) - self.assertEqual( - result, - {"parse": 0, "playUrl": "", "url": "https://cdn.example.com/1.m3u8", "header": {}}, - ) - - def test_player_content_returns_parser_url_for_non_media_link(self): - result = self.spider.playerContent("如意线路", "https://parser.example.com/play/2", {}) - self.assertEqual( - result, - {"parse": 1, "playUrl": "", "url": "https://parser.example.com/play/2", "header": {}}, - ) - - @patch.object(Spider, "fetch") - def test_request_json_returns_empty_dict_when_all_apis_fail(self, mock_fetch): - mock_fetch.side_effect = [ - FakeResponse(status_code=500, text=""), - FakeResponse(status_code=200, text="{bad json"), - FakeResponse(status_code=404, text=""), - ] - self.assertEqual(self.spider._request_json({"ac": "list"}), {}) - - @patch.object(Spider, "fetch") - def test_request_json_continues_when_first_api_raises(self, mock_fetch): - mock_fetch.side_effect = [ - RuntimeError("boom"), - FakeResponse(text='{"list":[{"vod_id":"777","vod_name":"异常后命中"}]}'), - ] - result = self.spider._request_json({"ac": "list", "pg": "1"}) - self.assertEqual(result["list"][0]["vod_id"], "777") - self.assertEqual(mock_fetch.call_count, 2) - - @patch.object(Spider, "_request_json") - def test_home_video_content_filters_invalid_vod_rows(self, mock_request_json): - mock_request_json.return_value = { - "list": [ - {"vod_id": "0", "vod_name": "无效", "vod_pic": "", "vod_remarks": "", "vod_year": "", "type_id": ""}, - {"vod_id": "", "vod_name": "空ID", "vod_pic": "", "vod_remarks": "", "vod_year": "", "type_id": ""}, - {"vod_id": "501", "vod_name": "", "vod_pic": "null", "vod_remarks": "", "vod_year": "2022", "type_id": "7"}, - ] - } - result = self.spider.homeVideoContent() - self.assertEqual( - result["list"], - [ - { - "vod_id": "501", - "vod_name": "未知标题", - "vod_pic": "", - "vod_remarks": "2022", - "vod_year": "2022", - "type_id": "7", - } - ], - ) - - def test_player_content_returns_empty_payload_for_blank_id(self): - self.assertEqual( - self.spider.playerContent("如意线路", "", {}), - {"parse": 0, "playUrl": "", "url": "", "header": {}}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_布布影视.py b/py/tests/test_布布影视.py deleted file mode 100644 index 2346b89..0000000 --- a/py/tests/test_布布影视.py +++ /dev/null @@ -1,254 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("bbys_spider", str(ROOT / "布布影视.py")).load_module() -Spider = MODULE.Spider - - -class TestBuBuYingShiSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_classes_and_filters(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["1", "2", "3", "4"], - ) - self.assertEqual( - [item["key"] for item in content["filters"]["1"]], - ["class", "area", "year", "by"], - ) - - @patch("bbys_spider.time.time", return_value=1713523200) - @patch("bbys_spider.random.randint", side_effect=[8, 37]) - def test_build_app_headers_includes_signed_fields(self, mock_randint, mock_time): - headers = self.spider._get_app_headers() - self.assertEqual(headers["x-aid"], "com.sunshine.tv") - self.assertEqual(headers["x-ave"], "4") - self.assertEqual(headers["x-time"], "1713523200") - self.assertEqual(headers["x-nonc"], "837") - self.assertEqual(len(headers["x-sign"]), 64) - - @patch("bbys_spider.time.time", return_value=1713523200) - @patch("bbys_spider.random.randint", side_effect=[8, 37]) - def test_build_web_headers_adds_web_specific_fields(self, mock_randint, mock_time): - headers = self.spider._get_web_headers() - self.assertEqual(headers["web-sign"], "f65f3a83d6d9ad6f") - self.assertEqual(headers["X-Client"], "8f3d2a1c7b6e5d4c9a0b1f2e3d4c5b6a") - - def test_generate_years_for_movie_starts_with_current_year_and_includes_ranges(self): - years = self.spider._generate_years("电影") - self.assertEqual(years[0], {"n": "全部", "v": ""}) - self.assertEqual(years[1]["n"], str(self.spider.current_year)) - self.assertIn({"n": "2015-2011", "v": "2015-2011"}, years) - - def test_convert_json_to_vods_maps_common_fields(self): - items = self.spider._convert_json_to_vods( - [ - { - "vod_id": 11, - "vod_name": "首页片", - "vod_pic": "https://img.example/a.jpg", - "vod_duration": "更新至2集", - "vod_class": ["剧情", "悬疑"], - "vod_year": 2026, - "vod_area": "大陆", - } - ] - ) - self.assertEqual(items[0]["vod_id"], "11") - self.assertEqual(items[0]["type_name"], "剧情/悬疑") - self.assertEqual(items[0]["vod_year"], "2026") - - @patch.object(Spider, "_request_json") - def test_home_video_content_reads_home_categories(self, mock_request_json): - mock_request_json.return_value = { - "data": { - "categories": [ - { - "type_id": "1", - "type_name": "电影", - "videos": [{"vod_id": 7, "vod_name": "首页影片", "vod_pic": "/a.jpg"}], - } - ] - } - } - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_id"], "7") - self.assertEqual(result["list"][0]["vod_name"], "首页影片") - - @patch.object(Spider, "_request_json") - @patch.object(Spider, "_get_class_list") - def test_home_video_content_falls_back_to_main_categories(self, mock_get_class_list, mock_request_json): - mock_request_json.return_value = {"data": {"categories": []}} - mock_get_class_list.side_effect = [ - {"list": [{"vod_id": "1", "vod_name": "电影片"}]}, - {"list": [{"vod_id": "2", "vod_name": "剧集片"}]}, - {"list": []}, - {"list": []}, - ] - result = self.spider.homeVideoContent() - self.assertEqual([item["vod_id"] for item in result["list"]], ["1", "2"]) - - @patch.object(Spider, "_request_json") - def test_category_content_builds_expected_web_request_and_page_shape(self, mock_request_json): - mock_request_json.return_value = { - "data": [{"vod_id": 9, "vod_name": "分类片", "vod_pic": "/cate.jpg", "vod_remarks": "HD"}] - } - result = self.spider.categoryContent("电影", "2", False, {"area": "大陆", "year": "2025", "by": "score"}) - kwargs = mock_request_json.call_args.kwargs - self.assertEqual(kwargs["headers"]["web-sign"], "f65f3a83d6d9ad6f") - self.assertEqual(kwargs["params"]["type_name"], "电影") - self.assertEqual(kwargs["params"]["page"], 2) - self.assertEqual(kwargs["params"]["sort"], "score") - self.assertEqual(result["page"], 2) - self.assertNotIn("pagecount", result) - self.assertEqual(result["list"][0]["vod_id"], "9") - - @patch.object(Spider, "_get_class_list") - def test_category_content_unknown_type_falls_back_to_hot_aggregate(self, mock_get_class_list): - mock_get_class_list.side_effect = [ - {"list": [{"vod_id": "1", "vod_name": "电影片"}]}, - {"list": [{"vod_id": "2", "vod_name": "剧集片"}]}, - {"list": [{"vod_id": "3", "vod_name": "综艺片"}]}, - {"list": [{"vod_id": "4", "vod_name": "动漫片"}]}, - ] - result = self.spider.categoryContent("步步影视", "1", False, {}) - self.assertEqual([item["vod_id"] for item in result["list"]], ["1", "2", "3", "4"]) - - @patch.object(Spider, "_request_json") - def test_search_content_skips_blank_keyword_and_builds_app_request(self, mock_request_json): - blank = self.spider.searchContent("", False, "1") - self.assertEqual(blank, {"page": 1, "limit": 0, "total": 0, "list": []}) - mock_request_json.assert_not_called() - - mock_request_json.return_value = { - "data": [{"vod_id": 13, "vod_name": "搜索片", "vod_pic": "/search.jpg"}] - } - result = self.spider.searchContent("繁花", False, "3") - kwargs = mock_request_json.call_args.kwargs - self.assertEqual(kwargs["params"]["wd"], "繁花") - self.assertEqual(kwargs["params"]["page"], 3) - self.assertEqual(kwargs["params"]["limit"], 15) - self.assertEqual(result["list"][0]["vod_id"], "13") - - def test_clean_html_content_strips_tags_and_keeps_line_breaks(self): - text = self.spider._clean_html_content("

    第一段

    第二段
    第三行

    ") - self.assertEqual(text, "第一段\n第二段\n第三行") - - def test_build_play_data_packs_group_names_and_urls(self): - data = self.spider._build_play_data( - "线路一$$$线路二", - "第1集$/play/1#第2集$/play/2$$$正片$/movie/main", - ) - self.assertEqual(data["vod_play_from"], "线路一(2)$$$线路二(1)") - self.assertIn("第1集$线路一@1@/play/1", data["vod_play_url"]) - self.assertIn("正片$线路二@1@/movie/main", data["vod_play_url"]) - - @patch.object(Spider, "_request_json") - def test_detail_content_uses_vodplayer_from_codes_and_show_names(self, mock_request_json): - mock_request_json.return_value = { - "data": [ - { - "vod_id": 18, - "vod_name": "详情片", - "vod_pic": "/detail.jpg", - "vod_remarks": "更新至2集", - "vod_year": "2026", - "vod_area": "大陆", - "vod_actor": "甲/乙", - "vod_director": "丙", - "vod_content": "

    一段简介

    ", - "vod_class": "剧情", - "vod_play_from": "rose", - "vod_play_url": "第1集$rose_token", - } - ], - "vodplayer": [{"from": "rose", "show": "SE蓝光", "decode_status": "1"}], - } - result = self.spider.detailContent(["18"]) - detail = result["list"][0] - self.assertEqual(detail["vod_id"], "18") - self.assertEqual(detail["vod_content"], "一段简介") - self.assertEqual(detail["type_name"], "剧情") - self.assertEqual(detail["vod_play_from"], "SE蓝光(1)") - self.assertEqual(detail["vod_play_url"], "第1集$rose@1@rose_token") - - def test_extract_decode_url_accepts_multiple_shapes(self): - self.assertEqual(self.spider._extract_decode_url({"data": "https://a.example/m3u8"}), "https://a.example/m3u8") - self.assertEqual( - self.spider._extract_decode_url({"data": {"url": "https://b.example/m3u8"}}), - "https://b.example/m3u8", - ) - self.assertEqual(self.spider._extract_decode_url({"url": "https://c.example/m3u8"}), "https://c.example/m3u8") - - @patch.object(Spider, "_request_json") - def test_player_content_decodes_encoded_play_id(self, mock_request_json): - mock_request_json.return_value = {"data": {"url": "https://cdn.example/real.m3u8"}} - result = self.spider.playerContent("线路一", "线路一@1@/play/1", {}) - kwargs = mock_request_json.call_args.kwargs - self.assertEqual(kwargs["params"]["url"], "/play/1") - self.assertEqual(kwargs["params"]["vodFrom"], "线路一") - self.assertEqual(result["parse"], 0) - self.assertEqual(result["playUrl"], "") - self.assertEqual(result["url"], "https://cdn.example/real.m3u8") - - @patch.object(Spider, "_request_json") - def test_player_content_parses_non_binary_decode_status(self, mock_request_json): - mock_request_json.return_value = {"data": {"url": "https://cdn.example/jd4k.m3u8"}} - result = self.spider.playerContent("JD蓝光", "JD4K@2@JD-a8fa0064", {}) - kwargs = mock_request_json.call_args.kwargs - self.assertEqual(kwargs["params"]["url"], "JD-a8fa0064") - self.assertEqual(kwargs["params"]["vodFrom"], "JD4K") - self.assertEqual(result["url"], "https://cdn.example/jd4k.m3u8") - - @patch.object(Spider, "_request_json") - def test_player_content_sets_jx_for_major_video_sites(self, mock_request_json): - mock_request_json.return_value = {"data": {"url": "https://v.qq.com/x/cover/demo.html"}} - result = self.spider.playerContent("线路一", "线路一@1@/play/1", {}) - self.assertEqual(result["jx"], 1) - - def test_player_content_passthrough_for_direct_url(self): - result = self.spider.playerContent("直连", "https://cdn.example/direct.m3u8", {}) - self.assertEqual(result["url"], "https://cdn.example/direct.m3u8") - self.assertEqual(result["jx"], 0) - - def test_home_content_keeps_reference_filter_values(self): - movie_filters = self.spider.homeContent(False)["filters"]["1"] - self.assertIn({"n": "动作", "v": "动作"}, movie_filters[0]["value"]) - self.assertIn({"n": "大陆", "v": "大陆"}, movie_filters[1]["value"]) - self.assertIn({"n": "评分", "v": "score"}, movie_filters[3]["value"]) - - @patch.object(Spider, "_request_json") - def test_detail_content_returns_empty_list_when_payload_missing(self, mock_request_json): - mock_request_json.return_value = {"data": []} - self.assertEqual(self.spider.detailContent(["404"]), {"list": []}) - - @patch.object(Spider, "_request_json") - def test_player_content_falls_back_to_raw_input_when_decode_fails(self, mock_request_json): - mock_request_json.return_value = {} - result = self.spider.playerContent("线路一", "线路一@1@/play/404", {}) - self.assertEqual(result["url"], "/play/404") - - @patch.object(Spider, "_request_json") - def test_player_content_marks_disabled_decode_line_as_unplayable(self, mock_request_json): - mock_request_json.return_value = { - "code": 0, - "msg": "线路暂时停用,请稍后再试", - "data": "JD-2eb205f0c280bdb5b93e14c25d5e72140", - } - result = self.spider.playerContent("JD蓝光", "JD4K@2@JD-2eb205f0c280bdb5b93e14c25d5e72140", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_新韩剧网.py b/py/tests/test_新韩剧网.py deleted file mode 100644 index bfb34fc..0000000 --- a/py/tests/test_新韩剧网.py +++ /dev/null @@ -1,235 +0,0 @@ -import base64 -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - -from Crypto.Cipher import AES -from Crypto.Util.Padding import pad - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("hanju7_spider", str(ROOT / "新韩剧网.py")).load_module() -Spider = MODULE.Spider - - -def encrypt_player_payload(url): - iv = b"0123456789abcdef" - key = MODULE.PLAYER_AES_KEY.encode("utf-8") - cipher = AES.new(key, AES.MODE_CBC, iv) - body = cipher.encrypt(pad(url.encode("utf-8"), AES.block_size)) - return base64.b64encode(iv + body).decode("utf-8") - - -HOME_HTML = """ -
    - -
    -""" - -LIST_HTML = """ -
    -
      -
    • - - HD -
    • -
    • - - 完结 -
    • -
    -
    -""" - -SEARCH_HTML = """ -
    - -
    -""" - -DETAIL_HTML = """ -
    -
    -
    -
    比天堂还美丽
    -
    金惠子 / 孙锡久
    -
    韩国
    -
    剧情
    -
    更新至第3集
    -
    2026
    -
    -
    -
    一段剧情简介
    -
    新韩剧线路
    -
    - -
    -""" - -HOT_HTML = """ -
    - -
    -""" - - -class FakeResponse: - def __init__(self, text="", status_code=200, headers=None): - self.text = text - self.status_code = status_code - self.headers = headers or {} - self.encoding = "utf-8" - - -class TestHanJu7Spider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_expected_classes(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["1", "3", "4", "hot", "new"], - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_parses_list_cards(self, mock_request_html): - mock_request_html.return_value = HOME_HTML - result = self.spider.homeVideoContent() - self.assertEqual( - result["list"], - [ - { - "vod_id": "abc123", - "vod_name": "苦尽柑来遇见你", - "vod_pic": "https://img.example.com/a.jpg", - "vod_remarks": "更新至第2集", - }, - { - "vod_id": "def456", - "vod_name": "机智住院医生生活", - "vod_pic": "https://www.hanju7.com/upload/b.jpg", - "vod_remarks": "2026-04-24", - }, - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_uses_list_path_for_regular_category(self, mock_request_html): - mock_request_html.return_value = LIST_HTML - result = self.spider.categoryContent("3", "2", False, {}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.hanju7.com/list/3---1.html") - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "xyz001") - self.assertEqual(result["list"][1]["vod_pic"], "https://pics.hanju7.com/pics/xyz002.jpg") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_category_content_paginates_hot_locally(self, mock_request_html): - mock_request_html.return_value = HOT_HTML - result = self.spider.categoryContent("hot", "1", False, {}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.hanju7.com/hot.html") - self.assertEqual(result["page"], 1) - self.assertEqual(result["total"], 3) - self.assertEqual(result["list"][0]["vod_id"], "h1") - - def test_extract_redirect_location_from_native_search_response(self): - response = FakeResponse(status_code=302, headers={"Location": "/search.php?searchword=test"}) - self.assertEqual(self.spider._extract_redirect_location(response), "/search.php?searchword=test") - - @patch.object(Spider, "_request_html") - @patch.object(Spider, "_native_post_search") - def test_search_content_follows_redirect_and_parses_results(self, mock_native_search, mock_request_html): - mock_native_search.return_value = ("/search.php?page=1&searchword=%E6%96%B0%E4%B9%8C%E6%89%98%E9%82%A6", "k=v") - mock_request_html.return_value = SEARCH_HTML - result = self.spider.searchContent("新乌托邦", False, "1") - self.assertEqual(result["page"], 1) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.hanju7.com/search.php?page=1&searchword=%E6%96%B0%E4%B9%8C%E6%89%98%E9%82%A6") - self.assertEqual(mock_request_html.call_args.kwargs["headers"]["Cookie"], "k=v") - self.assertEqual( - result["list"], - [ - { - "vod_id": "search001", - "vod_name": "新乌托邦", - "vod_pic": MODULE.DEFAULT_PIC, - "vod_remarks": "朴正民 / 金智秀", - }, - { - "vod_id": "search002", - "vod_name": "协商的技术", - "vod_pic": MODULE.DEFAULT_PIC, - "vod_remarks": "李帝勋", - }, - ], - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_parses_metadata_and_playlists(self, mock_request_html): - mock_request_html.return_value = DETAIL_HTML - result = self.spider.detailContent(["abc123"]) - vod = result["list"][0] - self.assertEqual(mock_request_html.call_args.args[0], "https://www.hanju7.com/detail/abc123.html") - self.assertEqual(vod["vod_name"], "比天堂还美丽") - self.assertEqual(vod["vod_pic"], "https://img.example.com/poster.jpg") - self.assertEqual(vod["vod_actor"], "金惠子 / 孙锡久") - self.assertEqual(vod["vod_remarks"], "更新至第3集") - self.assertEqual(vod["vod_year"], "2026") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_play_from"], "新韩剧线路") - self.assertEqual(vod["vod_play_url"], "第1集$p001#第2集$p002") - - def test_decrypt_play_url_decodes_prefixed_iv_payload(self): - encrypted = encrypt_player_payload("https://cdn.example.com/final.m3u8") - self.assertEqual(self.spider._decrypt_play_url(encrypted), "https://cdn.example.com/final.m3u8") - - @patch.object(Spider, "_request_text") - def test_player_content_returns_direct_media(self, mock_request_text): - mock_request_text.return_value = encrypt_player_payload("https://cdn.example.com/final.m3u8") - result = self.spider.playerContent("新韩剧线路", "p001", {}) - self.assertEqual(mock_request_text.call_args.args[0], "https://www.hanju7.com/u/u1.php?ud=p001") - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://cdn.example.com/final.m3u8") - self.assertEqual(result["header"]["Referer"], "https://www.hanju7.com/") - - @patch.object(Spider, "_request_text") - def test_player_content_falls_back_to_parser_for_embed_url(self, mock_request_text): - mock_request_text.return_value = encrypt_player_payload("https://player.example.com/embed?id=1") - result = self.spider.playerContent("新韩剧线路", "p002", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://player.example.com/embed?id=1") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_星星短剧.py b/py/tests/test_星星短剧.py deleted file mode 100644 index 96b7a67..0000000 --- a/py/tests/test_星星短剧.py +++ /dev/null @@ -1,144 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import MagicMock, patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("xingxingduanju_spider", str(ROOT / "星星短剧.py")).load_module() -Spider = MODULE.Spider - - -class TestXingXingDuanJuSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_fixed_categories(self): - content = self.spider.homeContent(False) - self.assertEqual(content["class"][0]["type_id"], "1287") - self.assertEqual(content["class"][-1]["type_name"], "古代") - self.assertEqual(content["filters"], {}) - - @patch.object(Spider, "_get_json") - def test_home_video_content_uses_default_category(self, mock_json): - mock_json.return_value = { - "data": { - "datalist": [ - { - "id": 1001, - "name": "闪婚后成了大佬心尖宠", - "icon": "http://img/1.jpg", - "introduction": "剧情简介", - "heat": 291.6, - } - ], - "page": {"totalCount": 34}, - } - } - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_id"], "1001@@闪婚后成了大佬心尖宠@@剧情简介") - self.assertEqual(result["list"][0]["vod_name"], "闪婚后成了大佬心尖宠") - self.assertEqual(result["list"][0]["vod_remarks"], "291.6万播放") - self.assertIn("resourceId=1287", mock_json.call_args.args[0]) - self.assertIn("pageNum=1", mock_json.call_args.args[0]) - - @patch.object(Spider, "_get_json") - def test_category_content_builds_query_and_maps_items(self, mock_json): - mock_json.return_value = { - "data": { - "datalist": [ - { - "id": 2002, - "name": "宸王的复仇王妃", - "icon": "http://img/2.jpg", - "introduction": "王妃复仇记", - "heat": 398.3, - } - ], - "page": {"totalCount": 34}, - } - } - result = self.spider.categoryContent("1291", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["total"], 34) - self.assertEqual(result["list"][0]["vod_id"], "2002@@宸王的复仇王妃@@王妃复仇记") - self.assertNotIn("pagecount", result) - self.assertIn("resourceId=1291", mock_json.call_args.args[0]) - self.assertIn("pageNum=2", mock_json.call_args.args[0]) - self.assertIn("pageSize=10", mock_json.call_args.args[0]) - - @patch.object(Spider, "_get_json") - def test_detail_content_builds_single_playlist(self, mock_json): - mock_json.return_value = { - "data": [ - { - "chapterName": "第1集", - "shortPlayList": [ - { - "chapterShortPlayVoList": [ - {"shortPlayUrl": "http://video/1.m3u8"} - ] - } - ], - }, - { - "chapterName": "第2集", - "shortPlayList": [ - { - "chapterShortPlayVoList": [ - {"shortPlayUrl": "http://video/2.m3u8"} - ] - } - ], - }, - ] - } - result = self.spider.detailContent(["422599939194881@@穿越诡计爱上你@@一段简介"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "422599939194881@@穿越诡计爱上你@@一段简介") - self.assertEqual(vod["vod_name"], "穿越诡计爱上你") - self.assertEqual(vod["vod_content"], "一段简介") - self.assertEqual(vod["vod_play_from"], "星星短剧") - self.assertIn("第1集$http://video/1.m3u8", vod["vod_play_url"]) - self.assertIn("第2集$http://video/2.m3u8", vod["vod_play_url"]) - self.assertIn("bookId=422599939194881", mock_json.call_args.args[0]) - - @patch.object(Spider, "_get_json") - def test_detail_content_returns_empty_when_id_missing(self, mock_json): - self.assertEqual(self.spider.detailContent([""]), {"list": []}) - mock_json.assert_not_called() - - def test_search_content_returns_empty(self): - self.assertEqual( - self.spider.searchContent("任意关键词", False, "3"), - {"page": 3, "limit": 0, "total": 0, "list": []}, - ) - - def test_player_content_returns_direct_url(self): - result = self.spider.playerContent("星星短剧", "http://video/play.m3u8", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "http://video/play.m3u8") - self.assertIn("Mozilla/5.0", result["header"]["User-Agent"]) - - def test_player_content_upgrades_known_cdn_to_https(self): - result = self.spider.playerContent( - "星星短剧", - "http://img.novel.wsljf.xyz/shortPlay-mp4/demo/output.m3u8", - {}, - ) - self.assertEqual( - result["url"], - "https://img.novel.wsljf.xyz/shortPlay-mp4/demo/output.m3u8", - ) - - @patch.object(Spider, "fetch") - def test_get_json_returns_empty_on_invalid_json(self, mock_fetch): - mock_fetch.return_value = MagicMock(status_code=200, text="blocked") - self.assertEqual(self.spider._get_json("http://example.com/api"), {}) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_木偶.py b/py/tests/test_木偶.py deleted file mode 100644 index 1e1fff3..0000000 --- a/py/tests/test_木偶.py +++ /dev/null @@ -1,225 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("muou_spider", str(ROOT / "木偶.py")).load_module() -Spider = MODULE.Spider - - -class TestMuOuSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_all_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("25", "木偶臻选"), - ("1", "木偶电影"), - ("2", "木偶电视剧"), - ("3", "木偶动漫"), - ("4", "木偶纪录片"), - ("29", "木偶综艺"), - ("30", "木偶原盘"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_url_and_detect_pan_type(self): - self.assertEqual( - self.spider._build_url("/voddetail/1.html"), - "https://www.muou.site/voddetail/1.html", - ) - self.assertEqual(self.spider._detect_pan_type("https://pan.baidu.com/s/demo"), ("baidu", "百度资源")) - self.assertEqual(self.spider._detect_pan_type("https://pan.quark.cn/s/demo"), ("quark", "夸克资源")) - self.assertEqual(self.spider._detect_pan_type("https://example.com/video"), ("", "")) - - def test_parse_cards_extracts_short_path_ids(self): - html = """ -
    -
    -
    - - 示例影片 -
    -
    HD
    -
    2025
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/voddetail/123.html", - "vod_name": "示例影片", - "vod_pic": "https://www.muou.site/poster.jpg", - "vod_remarks": "HD", - "vod_year": "2025", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_reference_url_and_returns_page_payload(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    -
    - - 分类影片 -
    -
    更新至10集
    -
    2024
    -
    -
    - """ - result = self.spider.categoryContent("25", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.muou.site/index.php/vod/type/id/25/page/3.html", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "fetch") - def test_category_content_tries_next_domain_when_primary_fails(self, mock_fetch): - html = """ -
    -
    -
    - - 分类影片 -
    -
    更新至10集
    -
    2024
    -
    -
    - """ - - def fake_fetch(url, headers=None, timeout=10): - if url.startswith("https://www.muou.site"): - raise RuntimeError("boom") - return SimpleNamespace(status_code=200, text=html) - - mock_fetch.side_effect = fake_fetch - result = self.spider.categoryContent("25", "1", False, {}) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertEqual(self.spider.hosts[0], "https://www.muou.asia") - - @patch.object(Spider, "_request_html") - def test_search_content_builds_reference_search_url_and_parses_results(self, mock_request_html): - mock_request_html.return_value = """ -
    - 抢先版 -
    - 搜索影片 -
    -
    - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.muou.site/vodsearch/-------------.html?wd=%E7%B9%81%E8%8A%B1&page=2", - ) - self.assertEqual( - result["list"][0], - { - "vod_id": "/voddetail/789.html", - "vod_name": "搜索影片", - "vod_pic": "https://www.muou.site/search.jpg", - "vod_remarks": "抢先版", - }, - ) - - def test_search_content_returns_empty_list_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - def test_build_pan_lines_deduplicates_and_sorts_supported_links(self): - detail = { - "pan_urls": [ - "https://pan.quark.cn/s/q1", - "https://pan.baidu.com/s/b1", - "https://pan.baidu.com/s/b1", - "https://example.com/ignored", - ] - } - self.assertEqual( - self.spider._build_pan_lines(detail), - [ - ("baidu#木偶", "百度资源$https://pan.baidu.com/s/b1"), - ("quark#木偶", "夸克资源$https://pan.quark.cn/s/q1"), - ], - ) - - def test_parse_detail_page_extracts_meta_content_and_pan_urls(self): - html = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - detail = self.spider._parse_detail_page("/voddetail/123.html", html) - self.assertEqual(detail["vod_name"], "示例剧") - self.assertEqual(detail["vod_pic"], "https://www.muou.site/poster.jpg") - self.assertEqual(detail["vod_year"], "2024") - self.assertEqual(detail["vod_director"], "导演甲") - self.assertEqual(detail["vod_actor"], "演员甲,演员乙") - self.assertEqual(detail["vod_content"], "一段剧情简介") - self.assertEqual(detail["pan_urls"], ["https://pan.quark.cn/s/q1", "https://pan.baidu.com/s/b1"]) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_pan_play_fields(self, mock_request_html): - mock_request_html.return_value = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - result = self.spider.detailContent(["/voddetail/123.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["vod_play_from"], "baidu#木偶$$$quark#木偶") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/q1", - ) - - def test_player_content_passthroughs_supported_pan_urls(self): - self.assertEqual( - self.spider.playerContent("baidu#木偶", "https://pan.baidu.com/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.baidu.com/s/demo"}, - ) - - def test_player_content_rejects_non_pan_url(self): - self.assertEqual( - self.spider.playerContent("site", "/vodplay/1-1-1.html", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_樱花动漫.py b/py/tests/test_樱花动漫.py deleted file mode 100644 index 76e5a8a..0000000 --- a/py/tests/test_樱花动漫.py +++ /dev/null @@ -1,236 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("yinghua_spider", str(ROOT / "樱花动漫.py")).load_module() -Spider = MODULE.Spider - - -SAMPLE_DETAIL_HTML = """ - -

    进击的巨人

    -
    -
    状态:已完结
    -
    年份:2023
    -
    地区:日本
    -
    类型:热血
    -
    主演:梶裕贵
    -
  • 简介讲述巨人的故事
  • -
    高清
    -
    ikun
    - - - -""" - -SAMPLE_LIST_HTML = """ - -
      -
    • 已完结

    • -
    • 更新中

    • -
    • -
    - -""" - -SAMPLE_SEARCH_HTML = """ - -找到 5 条结果 - - -""" - -SAMPLE_PLAY_HTML = """ - - - -""" - -SAMPLE_CATEGORY_HTML = """ - -
      -
    • 已完结

    • -
    - - -""" - - -class TestYingHuaSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_classes(self): - content = self.spider.homeContent(False) - ids = [c["type_id"] for c in content["class"]] - self.assertEqual(len(content["class"]), 4) - self.assertIn("guoman", ids) - self.assertIn("riman", ids) - self.assertIn("oman", ids) - self.assertIn("dmfilm", ids) - - def test_get_name(self): - self.assertEqual(self.spider.getName(), "樱花动漫") - - @patch.object(Spider, "_get_html") - def test_home_video_content(self, mock_html): - mock_html.return_value = SAMPLE_LIST_HTML - result = self.spider.homeVideoContent() - self.assertEqual(len(result["list"]), 2) - self.assertEqual(result["list"][0]["vod_name"], "进击的巨人") - self.assertEqual(result["list"][0]["vod_remarks"], "已完结") - self.assertEqual(result["list"][1]["vod_name"], "鬼灭之刃") - - @patch.object(Spider, "_get_html") - def test_home_video_dedup(self, mock_html): - mock_html.return_value = """ -
      -
    • -
    • -
    - """ - result = self.spider.homeVideoContent() - self.assertEqual(len(result["list"]), 1) - - @patch.object(Spider, "_get_html") - def test_category_content(self, mock_html): - mock_html.return_value = SAMPLE_CATEGORY_HTML - result = self.spider.categoryContent("riman", "1", False, {}) - self.assertEqual(len(result["list"]), 1) - self.assertEqual(result["list"][0]["vod_name"], "进击的巨人") - self.assertEqual(result["pagecount"], 5) - - @patch.object(Spider, "_get_html") - def test_category_page2_url(self, mock_html): - mock_html.return_value = SAMPLE_LIST_HTML - self.spider.categoryContent("riman", "2", False, {}) - mock_html.assert_called_with("https://www.dmvvv.com/type/riman/2/") - - @patch.object(Spider, "_get_html") - def test_detail_content(self, mock_html): - mock_html.return_value = SAMPLE_DETAIL_HTML - result = self.spider.detailContent(["/detail/12345/"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "进击的巨人") - self.assertEqual(vod["vod_pic"], "https://pic.example.com/zr.jpg") - self.assertEqual(vod["vod_remarks"], "已完结") - self.assertEqual(vod["vod_year"], "2023") - self.assertEqual(vod["vod_area"], "日本") - self.assertEqual(vod["vod_content"], "讲述巨人的故事") - self.assertIn("高清", vod["vod_play_from"]) - self.assertIn("ikun", vod["vod_play_from"]) - self.assertIn("第01集$/play/12345-1-1/", vod["vod_play_url"]) - self.assertIn("第02集$/play/12345-1-2/", vod["vod_play_url"]) - - @patch.object(Spider, "_get_html") - def test_detail_multi_source_grouping(self, mock_html): - mock_html.return_value = SAMPLE_DETAIL_HTML - result = self.spider.detailContent(["/detail/12345/"]) - vod = result["list"][0] - from_list = vod["vod_play_from"].split("$$$") - url_groups = vod["vod_play_url"].split("$$$") - self.assertEqual(len(from_list), 2) - self.assertEqual(len(url_groups), 2) - # 高清 source (sourceIdx=1) - self.assertEqual(from_list[0], "高清") - self.assertIn("第01集$/play/12345-1-1/", url_groups[0]) - self.assertIn("第03集$/play/12345-1-3/", url_groups[0]) - # ikun source (sourceIdx=2) - self.assertEqual(from_list[1], "ikun") - self.assertIn("第01集$/play/12345-2-1/", url_groups[1]) - self.assertIn("第02集$/play/12345-2-2/", url_groups[1]) - # ensure no cross-contamination - self.assertNotIn("12345-2-", url_groups[0]) - self.assertNotIn("12345-1-", url_groups[1]) - - @patch.object(Spider, "_get_html") - def test_detail_title_from_title_tag(self, mock_html): - mock_html.return_value = "鬼灭之刃 - 樱花动漫" - result = self.spider.detailContent(["/detail/999/"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "鬼灭之刃") - - @patch.object(Spider, "_get_html") - def test_detail_no_episodes(self, mock_html): - mock_html.return_value = """ - -

    测试

    - - """ - result = self.spider.detailContent(["/detail/1/"]) - vod = result["list"][0] - self.assertEqual(vod["vod_play_from"], "default") - self.assertEqual(vod["vod_play_url"], "") - - @patch.object(Spider, "_get_html") - def test_search_content(self, mock_html): - mock_html.return_value = SAMPLE_SEARCH_HTML - result = self.spider.searchContent("巨人", False, "1") - self.assertEqual(len(result["list"]), 2) - self.assertEqual(result["list"][0]["vod_name"], "进击的巨人") - self.assertEqual(result["pagecount"], 1) - - def test_search_empty_keyword(self): - result = self.spider.searchContent("", False, "1") - self.assertEqual(result["list"], []) - - @patch.object(Spider, "_get_html") - def test_search_quick_mode(self, mock_html): - mock_html.return_value = SAMPLE_SEARCH_HTML - result = self.spider.searchContent("巨人", True, "1") - self.assertEqual(len(result["list"]), 2) - - @patch.object(Spider, "_get_html") - def test_player_content_artplayer(self, mock_html): - mock_html.return_value = SAMPLE_PLAY_HTML - result = self.spider.playerContent("高清", "/play/12345-1-1/", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://cdn.example.com/video/12345.m3u8?key=abc") - self.assertIn("Referer", result["header"]) - - @patch.object(Spider, "_get_html") - def test_player_content_m3u8_fallback(self, mock_html): - mock_html.return_value = '' - result = self.spider.playerContent("高清", "/play/12345-1-1/", {}) - self.assertEqual(result["url"], "https://cdn.example.com/stream.m3u8") - - @patch.object(Spider, "_get_html") - def test_player_content_no_match(self, mock_html): - mock_html.return_value = "no video" - result = self.spider.playerContent("高清", "/play/12345-1-1/", {}) - self.assertEqual(result["url"], "https://www.dmvvv.com/play/12345-1-1/") - - @patch.object(Spider, "_get_html") - def test_detail_full_url(self, mock_html): - mock_html.return_value = SAMPLE_DETAIL_HTML - self.spider.detailContent(["https://www.dmvvv.com/detail/12345/"]) - mock_html.assert_called_with("https://www.dmvvv.com/detail/12345/") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_橘子TV.py b/py/tests/test_橘子TV.py deleted file mode 100644 index f927f9a..0000000 --- a/py/tests/test_橘子TV.py +++ /dev/null @@ -1,309 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("juzi_spider", str(ROOT / "橘子TV.py")).load_module() -Spider = MODULE.Spider - - -class TestJuZiTVSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - - @patch.object(Spider, "fetch") - def test_init_loads_api_host_from_json_config(self, mock_fetch): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse('["https://api1.example.com","https://api2.example.com"]') - self.spider.init() - self.assertEqual(self.spider.api_host, "https://api1.example.com") - - @patch.object(Spider, "fetch") - def test_init_loads_api_host_from_plain_text_config(self, mock_fetch): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse("https://api3.example.com,\nhttps://api4.example.com") - self.spider.init() - self.assertEqual(self.spider.api_host, "https://api3.example.com") - - @patch.object(Spider, "fetch") - def test_build_payload_adds_sign_and_common_fields(self, mock_fetch): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse('["https://api1.example.com"]') - self.spider.init() - payload = self.spider._build_payload({"vodId": "123"}) - self.assertEqual(payload["appId"], "fea23e11fc1241409682880e15fb2851") - self.assertEqual(payload["bundlerId"], "com.voraguzzee.ts") - self.assertEqual(payload["vodId"], "123") - self.assertIn("requestId", payload) - self.assertIn("sign", payload) - self.assertEqual(len(payload["sign"]), 32) - - @patch.object(Spider, "_post_api") - @patch.object(Spider, "fetch") - def test_home_content_maps_channel_list(self, mock_fetch, mock_post_api): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse('["https://api1.example.com"]') - mock_post_api.return_value = { - "data": { - "channeList": [ - {"channelId": 1, "channelName": "电影"}, - {"channelId": 2, "channelName": "剧集"}, - ] - } - } - self.spider.init() - result = self.spider.homeContent(False) - self.assertEqual( - result, - { - "class": [ - {"type_id": "1", "type_name": "电影"}, - {"type_id": "2", "type_name": "剧集"}, - ] - }, - ) - - @patch.object(Spider, "_post_api") - @patch.object(Spider, "fetch") - def test_home_video_content_flattens_topic_vod_list(self, mock_fetch, mock_post_api): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse('["https://api1.example.com"]') - mock_post_api.return_value = { - "data": { - "vodTopicList": [ - { - "vodList": [ - { - "vodId": 11, - "vodName": "推荐影片", - "coverImg": "https://img.example.com/a.jpg", - "remark": "更新至1集", - "flags": "2026 / 大陆", - } - ] - } - ] - } - } - self.spider.init() - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_id"], "11") - self.assertEqual(result["list"][0]["vod_name"], "推荐影片") - self.assertEqual(result["list"][0]["vod_year"], "2026") - - @patch.object(Spider, "_post_api") - @patch.object(Spider, "fetch") - def test_category_content_maps_items_and_page_fields(self, mock_fetch, mock_post_api): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse('["https://api1.example.com"]') - mock_post_api.return_value = { - "data": { - "hasNext": 1, - "nextVal": "token-2", - "items": [ - { - "vodId": 21, - "vodName": "分类影片", - "coverImg": "https://img.example.com/cate.jpg", - "remark": "完结", - "flags": "2025 / 美国", - "intro": "分类简介", - } - ], - } - } - self.spider.init() - result = self.spider.categoryContent("7", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertNotIn("pagecount", result) - self.assertEqual(result["list"][0]["vod_id"], "21") - self.assertEqual(result["list"][0]["vod_content"], "分类简介") - payload = mock_post_api.call_args.args[1] - self.assertEqual(payload["nextCount"], 18) - self.assertEqual(payload["nextVal"], "") - self.assertEqual(payload["sortType"], "") - self.assertEqual(payload["queryValueJson"], '[{"filerName":"channelId","filerValue":"7"}]') - - @patch.object(Spider, "_post_api") - @patch.object(Spider, "fetch") - def test_search_content_maps_items_without_next_page(self, mock_fetch, mock_post_api): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse('["https://api1.example.com"]') - mock_post_api.return_value = { - "data": { - "hasNext": 0, - "items": [ - { - "vodId": 31, - "vodName": "搜索影片", - "coverImg": "https://img.example.com/search.jpg", - "remark": "", - "score": "8.5", - "flags": "2024 / 日本", - "intro": "搜索简介", - } - ], - } - } - self.spider.init() - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(result["page"], 1) - self.assertNotIn("pagecount", result) - self.assertEqual(result["list"][0]["vod_id"], "31") - self.assertEqual(result["list"][0]["vod_remarks"], "评分:8.5") - payload = mock_post_api.call_args.args[1] - self.assertEqual(payload["keyword"], "繁花") - self.assertEqual(payload["nextVal"], "") - - def test_parse_people_joins_worker_names(self): - self.assertEqual( - self.spider._parse_people( - [ - {"vodWorkerId": 1, "vodWorkerName": "张三"}, - {"vodWorkerId": 2, "vodWorkerName": "李四"}, - ] - ), - "张三,李四", - ) - - @patch.object(Spider, "_post_api") - @patch.object(Spider, "fetch") - def test_detail_content_maps_meta_and_multi_line_playlists(self, mock_fetch, mock_post_api): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse('["https://api1.example.com"]') - mock_post_api.return_value = { - "data": { - "vodId": 41, - "vodName": "详情影片", - "coverImg": "https://img.example.com/detail.jpg", - "updateRemark": "更新至3集", - "year": "2026", - "areaName": "大陆", - "intro": "详情简介", - "actorList": [{"vodWorkerName": "主演甲"}, {"vodWorkerName": "主演乙"}], - "directorList": [{"vodWorkerName": "导演甲"}], - "playerList": [ - { - "playerName": "线路A", - "epList": [{"epName": "第1集", "epId": "ep-a-1"}, {"epName": "第2集", "epId": "ep-a-2"}], - }, - { - "playerName": "线路B", - "epList": [{"epName": "正片", "epId": "ep-b-1"}], - }, - ], - } - } - self.spider.init() - result = self.spider.detailContent(["41"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "41") - self.assertEqual(vod["vod_name"], "详情影片") - self.assertEqual(vod["vod_remarks"], "更新至3集") - self.assertEqual(vod["vod_year"], "2026") - self.assertEqual(vod["vod_area"], "大陆") - self.assertEqual(vod["vod_actor"], "主演甲,主演乙") - self.assertEqual(vod["vod_director"], "导演甲") - self.assertEqual(vod["vod_content"], "详情简介") - self.assertEqual(vod["vod_play_from"], "线路A$$$线路B") - self.assertEqual(vod["vod_play_url"], "第1集$ep-a-1#第2集$ep-a-2$$$正片$ep-b-1") - - @patch.object(Spider, "_post_api") - @patch.object(Spider, "fetch") - def test_player_content_returns_best_resolution_url(self, mock_fetch, mock_post_api): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse('["https://api1.example.com"]') - mock_post_api.side_effect = [ - { - "data": [ - {"showName": "高清", "vodResolution": "1080p"}, - {"showName": "标清", "vodResolution": "720p"}, - ] - }, - {"data": {"playUrl": "https://cdn.example.com/1080.m3u8"}}, - {"data": {"playUrl": "https://cdn.example.com/720.m3u8"}}, - ] - self.spider.init() - result = self.spider.playerContent("线路A", "ep-a-1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["playUrl"], "") - self.assertEqual(result["url"], "https://cdn.example.com/1080.m3u8") - self.assertEqual(result["header"]["User-Agent"], "ExoPlayer") - - @patch.object(Spider, "_post_api") - @patch.object(Spider, "fetch") - def test_player_content_prefers_higher_ranked_named_quality(self, mock_fetch, mock_post_api): - class FakeResponse: - def __init__(self, text): - self.text = text - self.status_code = 200 - self.encoding = "utf-8" - - mock_fetch.return_value = FakeResponse('["https://api1.example.com"]') - mock_post_api.side_effect = [ - { - "data": [ - {"showName": "流畅", "vodResolution": "sd"}, - {"showName": "超清", "vodResolution": "uhd"}, - {"showName": "高清", "vodResolution": "hd"}, - ] - }, - {"data": {"playUrl": "https://cdn.example.com/sd.m3u8"}}, - {"data": {"playUrl": "https://cdn.example.com/uhd.m3u8"}}, - {"data": {"playUrl": "https://cdn.example.com/hd.m3u8"}}, - ] - self.spider.init() - result = self.spider.playerContent("线路A", "ep-a-1", {}) - self.assertEqual(result["url"], "https://cdn.example.com/uhd.m3u8") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_欧歌.py b/py/tests/test_欧歌.py deleted file mode 100644 index 07625c3..0000000 --- a/py/tests/test_欧歌.py +++ /dev/null @@ -1,214 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("ouge_spider", str(ROOT / "欧歌.py")).load_module() -Spider = MODULE.Spider - - -class TestOuGeSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_reference_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("1", "欧歌电影"), - ("2", "欧哥剧集"), - ("3", "欧歌动漫"), - ("4", "欧歌综艺"), - ("5", "欧歌短剧"), - ("21", "欧歌综合"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_url_joins_relative_paths_against_host(self): - self.assertEqual( - self.spider._build_url("/index.php/vod/detail/id/1.html"), - "https://woog.nxog.eu.org/index.php/vod/detail/id/1.html", - ) - self.assertEqual( - self.spider._build_url("https://cdn.example.com/poster.jpg"), - "https://cdn.example.com/poster.jpg", - ) - - def test_detect_pan_type_returns_expected_type_and_label(self): - self.assertEqual( - self.spider._detect_pan_type("https://pan.baidu.com/s/demo"), - ("baidu", "百度资源"), - ) - self.assertEqual( - self.spider._detect_pan_type("https://pan.quark.cn/s/demo"), - ("quark", "夸克资源"), - ) - self.assertEqual( - self.spider._detect_pan_type("https://example.com/video"), - ("", ""), - ) - - def test_parse_cards_extracts_short_path_id_and_cover(self): - html = """ -
    -
    -
    - - 示例影片 -
    -
    HD
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/index.php/vod/detail/id/123.html", - "vod_name": "示例影片", - "vod_pic": "https://woog.nxog.eu.org/poster.jpg", - "vod_remarks": "HD", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_reference_url_and_returns_page_payload(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    -
    - - 分类影片 -
    -
    更新至10集
    -
    -
    - """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://woog.nxog.eu.org/index.php/vod/show/id/2/page/3.html", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_builds_reference_url_and_parses_results(self, mock_request_html): - mock_request_html.return_value = """ -
    - 抢先版 -
    - 搜索影片 -
    -
    - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://woog.nxog.eu.org/index.php/vod/search/page/2/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual( - result["list"][0], - { - "vod_id": "/index.php/vod/detail/id/789.html", - "vod_name": "搜索影片", - "vod_pic": "https://woog.nxog.eu.org/search.jpg", - "vod_remarks": "抢先版", - }, - ) - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_short_circuits_blank_keyword(self, mock_request_html): - result = self.spider.searchContent("", False, "1") - 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/tests/test_滴答影视.py b/py/tests/test_滴答影视.py deleted file mode 100644 index c7c359e..0000000 --- a/py/tests/test_滴答影视.py +++ /dev/null @@ -1,210 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("dida_spider", str(ROOT / "滴答影视.py")).load_module() -Spider = MODULE.Spider - - -class TestDidaSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_expected_classes_and_filters(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["1", "2", "5", "4", "3"], - ) - self.assertEqual( - [item["key"] for item in content["filters"]["1"]], - ["class", "area", "year", "lang", "sort"], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_category_url_applies_default_and_selected_filters(self): - url = self.spider._build_category_url( - "1", - "2", - {"area": "香港", "sort": "score", "class": "动作", "lang": "粤语", "year": "2025"}, - ) - self.assertEqual(url, "https://www.didahd.pro/show/1-香港-score-动作-粤语----2---2025") - - def test_build_category_url_for_first_page_keeps_page_segment(self): - url = self.spider._build_category_url("2", "1", {}) - self.assertEqual(url, "https://www.didahd.pro/show/2--time------1---") - - def test_parse_cards_extracts_expected_fields(self): - html = """ -
    -
    - - HD -
    - """ - cards = self.spider._parse_cards(html) - self.assertEqual( - cards, - [ - { - "vod_id": "detail/888", - "vod_name": "示例影片", - "vod_pic": "https://www.didahd.pro/cover.jpg", - "vod_remarks": "HD", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_uses_built_url_and_returns_page_result(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    - - 完结 -
    - """ - result = self.spider.categoryContent("1", "2", False, {"area": "香港", "sort": "score"}) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.didahd.pro/show/1-香港-score------2---") - self.assertEqual(result["page"], 2) - self.assertNotIn("pagecount", result) - self.assertEqual(result["limit"], 12) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - - @patch.object(Spider, "_request_html") - def test_search_content_uses_search_url_and_parses_cards(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    - - 抢先版 -
    - """ - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.didahd.pro/search/-------------.html?wd=%E7%B9%81%E8%8A%B1", - ) - self.assertEqual(result["list"][0]["vod_id"], "detail/321") - self.assertNotIn("pagecount", result) - - def test_extract_netdisk_groups_deduplicates_links_and_sorts_by_priority(self): - html = """ -
    -

    滴答示例

    -

    分类:动作

    -

    地区:大陆

    -

    年份:2025

    -

    导演:导演甲

    -

    主演:主演甲主演乙

    -
    -
    -

    剧情简介:一段剧情简介

    -
    -
    -

    夸克:查看

    -

    百度:合集

    -

    夸克:一键复制

    -

    迅雷:迅雷资源

    -
    - """ - vod = self.spider._parse_detail_page(html, "https://www.didahd.pro/detail/demo.html") - self.assertEqual(vod["vod_name"], "滴答示例") - 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"], "baidu$$$quark$$$xunlei") - self.assertEqual( - vod["vod_play_url"], - "合集$https://pan.baidu.com/s/b1$$$查看$https://pan.quark.cn/s/q1$$$迅雷资源$https://pan.xunlei.com/s/x1", - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_keeps_only_netdisk_sources(self, mock_request_html): - mock_request_html.return_value = """ -

    详情标题

    -
    -

    UC 网盘:合集

    -
    - - """ - result = self.spider.detailContent(["detail/111"]) - self.assertEqual(mock_request_html.call_args.args[0], "https://www.didahd.pro/detail/111.html") - self.assertEqual(result["list"][0]["vod_id"], "detail/111") - self.assertEqual(result["list"][0]["vod_play_from"], "uc") - self.assertEqual(result["list"][0]["vod_play_url"], "合集$https://drive.uc.cn/s/u1") - - def test_extract_netdisk_groups_ignores_non_netdisk_external_links(self): - html = """ -
    -

    豆瓣:豆瓣详情

    -

    夸克:查看

    -
    - """ - self.assertEqual( - self.spider._extract_netdisk_groups(html), - [{"from": "quark", "urls": "查看$https://pan.quark.cn/s/q1"}], - ) - - def test_extract_netdisk_groups_prioritizes_domain_over_generic_label(self): - html = """ -
    -

    UC 网盘:UC合集

    -

    资源链接:百度合集

    -
    - """ - self.assertEqual( - self.spider._extract_netdisk_groups(html), - [ - {"from": "baidu", "urls": "百度合集$https://pan.baidu.com/s/b1"}, - {"from": "uc", "urls": "UC合集$https://drive.uc.cn/s/u1"}, - ], - ) - - def test_player_content_returns_direct_netdisk_link(self): - result = self.spider.playerContent("quark", "https://pan.quark.cn/s/demo", {}) - self.assertEqual(result, {"parse": 0, "playUrl": "", "url": "https://pan.quark.cn/s/demo"}) - - def test_player_content_supports_uc_baidu_xunlei_aliyun_links(self): - self.assertEqual( - self.spider.playerContent("uc", "https://drive.uc.cn/s/u1", {})["url"], - "https://drive.uc.cn/s/u1", - ) - self.assertEqual( - self.spider.playerContent("baidu", "https://pan.baidu.com/s/b1", {})["url"], - "https://pan.baidu.com/s/b1", - ) - self.assertEqual( - self.spider.playerContent("xunlei", "https://pan.xunlei.com/s/x1", {})["url"], - "https://pan.xunlei.com/s/x1", - ) - self.assertEqual( - self.spider.playerContent("aliyun", "https://www.alipan.com/s/a1", {})["url"], - "https://www.alipan.com/s/a1", - ) - - def test_player_content_returns_empty_for_non_netdisk_input(self): - result = self.spider.playerContent("播放线路", "/play/111-1-1.html", {}) - self.assertEqual(result, {"parse": 0, "playUrl": "", "url": ""}) - - def test_home_content_keeps_reference_filter_values(self): - movie_filters = self.spider.homeContent(False)["filters"]["1"] - self.assertIn({"n": "动作", "v": "动作"}, movie_filters[0]["value"]) - self.assertIn({"n": "香港", "v": "香港"}, movie_filters[1]["value"]) - self.assertIn({"n": "2025", "v": "2025"}, movie_filters[2]["value"]) - self.assertIn({"n": "粤语", "v": "粤语"}, movie_filters[3]["value"]) - self.assertEqual(movie_filters[4]["value"][1], {"n": "人气", "v": "hits"}) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_玩偶哥哥.py b/py/tests/test_玩偶哥哥.py deleted file mode 100644 index 5347775..0000000 --- a/py/tests/test_玩偶哥哥.py +++ /dev/null @@ -1,239 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("wanougege_spider", str(ROOT / "玩偶哥哥.py")).load_module() -Spider = MODULE.Spider - - -class TestWanOuGeGeSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_all_eight_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("1", "玩偶电影"), - ("2", "玩偶剧集"), - ("3", "玩偶动漫"), - ("4", "玩偶综艺"), - ("44", "臻彩视界"), - ("6", "玩偶短剧"), - ("5", "玩偶音乐"), - ("46", "玩偶纪录"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_and_fix_urls(self): - self.assertEqual(self.spider._build_url("/voddetail/1.html"), "http://wogg.xxooo.cf/voddetail/1.html") - self.assertEqual( - self.spider._fix_img_url("/db.php?url=https://img.example/poster.jpg"), - "http://wogg.xxooo.cf/db.php?url=https://img.example/poster.jpg", - ) - self.assertEqual( - self.spider._fix_img_url("https://gimg1.baidu.com/gimg/?src=data:image/png;base64,AAAA"), - "", - ) - - def test_detect_pan_type_returns_type_and_title(self): - self.assertEqual(self.spider._detect_pan_type("https://pan.baidu.com/s/demo"), ("baidu", "百度资源")) - self.assertEqual(self.spider._detect_pan_type("https://pan.quark.cn/s/demo"), ("quark", "夸克资源")) - self.assertEqual(self.spider._detect_pan_type("https://example.com/video"), ("", "")) - - def test_parse_cards_extracts_short_path_ids(self): - html = """ -
    -
    -
    - - 示例影片 -
    -
    HD
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/voddetail/123.html", - "vod_name": "示例影片", - "vod_pic": "http://wogg.xxooo.cf/poster.jpg", - "vod_remarks": "HD", - } - ], - ) - - def test_parse_cards_ignores_module_items_container_and_uses_first_card_cover(self): - html = """ -
    -
    -
    -
    - - 示例影片A -
    -
    HD
    -
    -
    -
    - - 示例影片B -
    -
    更新至10集
    -
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/voddetail/123.html", - "vod_name": "示例影片A", - "vod_pic": "http://wogg.xxooo.cf/poster-a.jpg", - "vod_remarks": "HD", - }, - { - "vod_id": "/voddetail/456.html", - "vod_name": "示例影片B", - "vod_pic": "http://wogg.xxooo.cf/poster-b.jpg", - "vod_remarks": "更新至10集", - }, - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_reference_url_and_returns_page_payload(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    -
    - - 分类影片 -
    -
    更新至10集
    -
    -
    - """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "http://wogg.xxooo.cf/vodshow/2--------3---.html", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_builds_reference_search_url_and_parses_results(self, mock_request_html): - mock_request_html.return_value = """ -
    - 抢先版 -
    - 搜索影片 -
    -
    - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "http://wogg.xxooo.cf/vodsearch/-------------.html?wd=%E7%B9%81%E8%8A%B1&page=2", - ) - self.assertEqual( - result["list"][0], - { - "vod_id": "/voddetail/789.html", - "vod_name": "搜索影片", - "vod_pic": "http://wogg.xxooo.cf/search.jpg", - "vod_remarks": "抢先版", - }, - ) - - def test_search_content_returns_empty_list_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - def test_build_pan_lines_deduplicates_and_sorts_supported_links(self): - detail = { - "pan_urls": [ - "https://pan.quark.cn/s/q1", - "https://pan.baidu.com/s/b1", - "https://pan.baidu.com/s/b1", - "https://example.com/ignored", - ] - } - self.assertEqual( - self.spider._build_pan_lines(detail), - [ - ("baidu#玩偶哥哥", "百度资源$https://pan.baidu.com/s/b1"), - ("quark#玩偶哥哥", "夸克资源$https://pan.quark.cn/s/q1"), - ], - ) - - def test_parse_detail_page_extracts_meta_content_and_pan_urls(self): - html = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - detail = self.spider._parse_detail_page("/voddetail/123.html", html) - self.assertEqual(detail["vod_name"], "示例剧") - self.assertEqual(detail["vod_pic"], "http://wogg.xxooo.cf/poster.jpg") - self.assertEqual(detail["vod_year"], "2024") - self.assertEqual(detail["vod_director"], "导演甲") - self.assertEqual(detail["vod_actor"], "演员甲,演员乙") - self.assertEqual(detail["vod_content"], "一段剧情简介") - self.assertEqual(detail["pan_urls"], ["https://pan.quark.cn/s/q1", "https://pan.baidu.com/s/b1"]) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_pan_play_fields(self, mock_request_html): - mock_request_html.return_value = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - result = self.spider.detailContent(["/voddetail/123.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["vod_play_from"], "baidu#玩偶哥哥$$$quark#玩偶哥哥") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/q1", - ) - - def test_player_content_passthroughs_supported_pan_urls(self): - self.assertEqual( - self.spider.playerContent("baidu#玩偶哥哥", "https://pan.baidu.com/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.baidu.com/s/demo"}, - ) - - def test_player_content_rejects_non_pan_url(self): - self.assertEqual( - self.spider.playerContent("site", "/vodplay/1-1-1.html", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) diff --git a/py/tests/test_玩偶聚合.py b/py/tests/test_玩偶聚合.py deleted file mode 100644 index e7dc21e..0000000 --- a/py/tests/test_玩偶聚合.py +++ /dev/null @@ -1,770 +0,0 @@ -import base64 -import json -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("wanou_aggregate_spider", str(ROOT / "玩偶聚合.py")).load_module() -Spider = MODULE.Spider - - -class TestWanouAggregateSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_recommend_and_site_classes_in_priority_order(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"][:6]], - [ - "site_recommend", - "site_wanou", - "site_muou", - "site_labi", - "site_zhizhen", - "site_erxiao", - ], - ) - self.assertEqual(content["class"][0]["type_name"], "推荐") - self.assertEqual(content["filters"]["site_wanou"][0]["key"], "categoryId") - self.assertEqual(content["filters"]["site_wanou"][0]["value"][1], {"n": "臻彩", "v": "44"}) - self.assertEqual(content["filters"]["site_recommend"][0]["value"][0], {"n": "全部", "v": "all"}) - - def test_home_content_exposes_zhizhen_site_and_categories(self): - content = self.spider.homeContent(False) - type_ids = [item["type_id"] for item in content["class"]] - self.assertIn("site_zhizhen", type_ids) - self.assertEqual( - content["filters"]["site_zhizhen"][0]["value"][1:], - [ - {"n": "臻彩", "v": "26"}, - {"n": "电影", "v": "1"}, - {"n": "电视剧", "v": "2"}, - {"n": "动漫", "v": "3"}, - {"n": "综艺", "v": "4"}, - {"n": "短剧", "v": "5"}, - {"n": "老剧", "v": "24"}, - ], - ) - - def test_home_content_exposes_muou_site_and_categories(self): - content = self.spider.homeContent(False) - type_ids = [item["type_id"] for item in content["class"]] - self.assertIn("site_muou", type_ids) - self.assertEqual( - content["filters"]["site_muou"][0]["value"][1:], - [ - {"n": "臻选", "v": "25"}, - {"n": "电影", "v": "1"}, - {"n": "电视剧", "v": "2"}, - {"n": "动漫", "v": "3"}, - {"n": "纪录片", "v": "4"}, - {"n": "综艺", "v": "29"}, - {"n": "原盘", "v": "30"}, - ], - ) - - def test_home_content_exposes_kuaiying_and_ouge_site_categories(self): - content = self.spider.homeContent(False) - type_ids = [item["type_id"] for item in content["class"]] - self.assertIn("site_kuaiying", type_ids) - self.assertIn("site_ouge", type_ids) - self.assertEqual( - content["filters"]["site_kuaiying"][0]["value"][1:], - [ - {"n": "臻彩", "v": "5"}, - {"n": "电影", "v": "1"}, - {"n": "电视剧", "v": "2"}, - {"n": "综艺", "v": "3"}, - {"n": "动漫", "v": "4"}, - {"n": "短剧", "v": "6"}, - {"n": "115", "v": "30"}, - {"n": "123", "v": "35"}, - {"n": "天移迅", "v": "36"}, - ], - ) - self.assertEqual(content["class"][-1]["type_name"], "欧哥") - self.assertEqual( - content["filters"]["site_ouge"][0]["value"][1:], - [ - {"n": "电影", "v": "1"}, - {"n": "电视剧", "v": "2"}, - {"n": "动漫", "v": "3"}, - {"n": "综艺", "v": "4"}, - {"n": "短剧", "v": "5"}, - {"n": "综合", "v": "21"}, - ], - ) - - @patch.object( - Spider, - "_load_local_filter_groups", - return_value=[ - { - "key": "year", - "name": "年份", - "init": "", - "value": [{"n": "全部", "v": ""}, {"n": "2025", "v": "2025"}], - } - ], - ) - def test_home_content_appends_local_filter_groups_after_category_filter(self, mock_load_local_filter_groups): - content = self.spider.homeContent(False) - self.assertEqual( - [item["key"] for item in content["filters"]["site_wanou"][:2]], - ["categoryId", "year"], - ) - - def test_encode_and_decode_site_vod_id_round_trip(self): - vod_id = self.spider._encode_site_vod_id("wanou", "/voddetail/12345.html") - self.assertEqual(vod_id, "site:wanou:/voddetail/12345.html") - self.assertEqual( - self.spider._decode_site_vod_id(vod_id), - {"site": "wanou", "path": "/voddetail/12345.html"}, - ) - - def test_encode_and_decode_aggregate_vod_id_round_trip(self): - payload = [ - {"site": "wanou", "path": "/voddetail/1.html", "name": "繁花", "year": "2024"}, - {"site": "muou", "path": "/voddetail/2.html", "name": "繁花", "year": "2024"}, - ] - vod_id = self.spider._encode_aggregate_vod_id(payload) - self.assertTrue(vod_id.startswith("agg:")) - decoded = self.spider._decode_aggregate_vod_id(vod_id) - self.assertEqual(decoded, payload) - - def test_normalize_title_removes_spaces_punctuation_and_resolution_tags(self): - self.assertEqual( - self.spider._normalize_title(" 繁花 4K.HDR-玩偶 "), - "繁花", - ) - - def test_is_same_title_rejects_year_conflict(self): - left = {"vod_name": "繁花", "vod_year": "2024"} - right = {"vod_name": "繁花", "vod_year": "2023"} - self.assertFalse(self.spider._is_same_title(left, right)) - - def test_build_category_url_uses_selected_category_and_filters(self): - site = { - "id": "wanou", - "domains": ["https://www.wogg.net"], - "category_url": "/vodshow/{categoryId}--------{page}---.html", - "category_url_with_filters": "/vodshow/{categoryId}-{area}-{by}-{class}-----{page}---{year}.html", - } - url = self.spider._build_category_url( - site, - "1", - "2", - {"categoryId": "1", "area": "香港", "by": "score", "class": "动作", "year": "2025"}, - ) - self.assertEqual( - url, - "https://www.wogg.net/vodshow/1-%E9%A6%99%E6%B8%AF-score-%E5%8A%A8%E4%BD%9C-----2---2025.html", - ) - - @patch.object(Spider, "fetch") - def test_request_with_failover_tries_next_domain_when_first_fails(self, mock_fetch): - def fake_fetch(url, headers=None, timeout=10): - if url.startswith("https://bad.example"): - raise RuntimeError("boom") - return SimpleNamespace(status_code=200, text="ok") - - mock_fetch.side_effect = fake_fetch - site = {"domains": ["https://bad.example", "https://good.example"]} - html = self.spider._request_with_failover(site, "/vodshow/1--------1---.html") - self.assertIn("ok", html) - self.assertEqual(site["domains"][0], "https://good.example") - - def test_parse_cards_extracts_short_site_vod_id_title_cover_and_remarks(self): - site = {"id": "wanou", "domains": ["https://www.wogg.net"], "list_xpath": "//*[contains(@class,'module-item')]"} - html = """ -
    -
    - - 示例影片 -
    -
    HD
    -
    - """ - cards = self.spider._parse_cards(site, html) - self.assertEqual( - cards, - [ - { - "vod_id": "site:wanou:/voddetail/123.html", - "vod_name": "示例影片", - "vod_pic": "https://www.wogg.net/poster.jpg", - "vod_remarks": "HD", - "vod_year": "", - "_site": "wanou", - "_detail_path": "/voddetail/123.html", - } - ], - ) - - def test_parse_cards_ignores_module_items_wrapper(self): - site = {"id": "erxiao", "domains": ["https://www.2xiaopan.top"], "list_xpath": "//*[contains(@class,'module-item')]"} - html = """ -
    -
    -
    -
    - - 影片一 -
    -
    HD
    -
    -
    -
    - - 影片二 -
    -
    更新至2集
    -
    -
    -
    - """ - cards = self.spider._parse_cards(site, html) - self.assertEqual( - cards, - [ - { - "vod_id": "site:erxiao:/index.php/vod/detail/id/111.html", - "vod_name": "影片一", - "vod_pic": "https://www.2xiaopan.top/poster-1.jpg", - "vod_remarks": "HD", - "vod_year": "", - "_site": "erxiao", - "_detail_path": "/index.php/vod/detail/id/111.html", - }, - { - "vod_id": "site:erxiao:/index.php/vod/detail/id/222.html", - "vod_name": "影片二", - "vod_pic": "https://www.2xiaopan.top/poster-2.jpg", - "vod_remarks": "更新至2集", - "vod_year": "", - "_site": "erxiao", - "_detail_path": "/index.php/vod/detail/id/222.html", - }, - ], - ) - - @patch.object(Spider, "_request_with_failover") - def test_category_content_uses_default_category_when_extend_missing(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
    -
    - - 分类影片 -
    -
    更新至10集
    -
    - """ - result = self.spider.categoryContent("site_wanou", "2", False, {}) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - - @patch.object(Spider, "_fetch_site_home_recommend") - def test_category_content_returns_recommend_results_with_site_tags(self, mock_fetch_site_home_recommend): - mock_fetch_site_home_recommend.side_effect = lambda site, limit=30: { - "wanou": [ - { - "vod_id": "site:wanou:/voddetail/1.html", - "vod_name": "繁花", - "vod_pic": "https://img.example/w.jpg", - "vod_remarks": "玩偶版", - "vod_year": "2024", - "_site": "wanou", - "_detail_path": "/voddetail/1.html", - } - ], - "muou": [ - { - "vod_id": "site:muou:/voddetail/2.html", - "vod_name": "繁花", - "vod_pic": "https://img.example/m.jpg", - "vod_remarks": "木偶版", - "vod_year": "2024", - "_site": "muou", - "_detail_path": "/voddetail/2.html", - } - ], - }.get(site["id"], []) - result = self.spider.categoryContent("site_recommend", "1", False, {"recommendSite": "all"}) - self.assertEqual(result["total"], 1) - self.assertEqual(result["list"][0]["vod_name"], "繁花") - self.assertEqual(result["list"][0]["vod_remarks"], "[玩偶] 玩偶版") - - def test_aggregate_search_results_merges_same_title_and_keeps_highest_priority_source(self): - raw_results = [ - { - "vod_id": "site:muou:/voddetail/2.html", - "vod_name": "繁花", - "vod_pic": "https://img.example/m.jpg", - "vod_remarks": "木偶版", - "vod_year": "2024", - "_site": "muou", - "_detail_path": "/voddetail/2.html", - }, - { - "vod_id": "site:wanou:/voddetail/1.html", - "vod_name": "繁花", - "vod_pic": "https://img.example/w.jpg", - "vod_remarks": "玩偶版", - "vod_year": "2024", - "_site": "wanou", - "_detail_path": "/voddetail/1.html", - }, - ] - aggregated = self.spider._aggregate_search_results(raw_results) - self.assertEqual(len(aggregated), 1) - self.assertEqual(aggregated[0]["vod_name"], "繁花") - self.assertEqual(aggregated[0]["vod_pic"], "https://img.example/w.jpg") - self.assertEqual(aggregated[0]["vod_remarks"], "玩偶版") - self.assertTrue(aggregated[0]["vod_id"].startswith("agg:")) - - def test_aggregate_search_results_keeps_year_conflict_as_two_items(self): - raw_results = [ - { - "vod_id": "site:wanou:/voddetail/1.html", - "vod_name": "倚天屠龙记", - "vod_year": "2019", - "_site": "wanou", - "_detail_path": "/voddetail/1.html", - }, - { - "vod_id": "site:muou:/voddetail/2.html", - "vod_name": "倚天屠龙记", - "vod_year": "2022", - "_site": "muou", - "_detail_path": "/voddetail/2.html", - }, - ] - aggregated = self.spider._aggregate_search_results(raw_results) - self.assertEqual(len(aggregated), 2) - - @patch.object(Spider, "_fetch_site_search") - def test_search_content_queries_sites_and_returns_aggregated_items(self, mock_fetch_site_search): - results_by_site = { - "wanou": [ - { - "vod_id": "site:wanou:/voddetail/1.html", - "vod_name": "繁花", - "vod_pic": "https://img.example/w.jpg", - "vod_remarks": "玩偶版", - "vod_year": "2024", - "_site": "wanou", - "_detail_path": "/voddetail/1.html", - } - ], - "muou": [ - { - "vod_id": "site:muou:/voddetail/2.html", - "vod_name": "繁花", - "vod_pic": "https://img.example/m.jpg", - "vod_remarks": "木偶版", - "vod_year": "2024", - "_site": "muou", - "_detail_path": "/voddetail/2.html", - } - ], - } - mock_fetch_site_search.side_effect = lambda site, keyword, page: results_by_site.get(site["id"], []) - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(len(result["list"]), 1) - self.assertEqual(result["list"][0]["vod_name"], "繁花") - self.assertNotIn("pagecount", result) - - def test_parse_detail_page_extracts_meta_and_netdisk_links(self): - site = { - "id": "wanou", - "name": "玩偶", - "domains": ["https://www.wogg.net"], - "detail_pan_xpath": "//*[contains(@class,'module-row-info')]//p", - } - html = """ -
    示例剧
    -
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - detail = self.spider._parse_detail_page(site, "/voddetail/123.html", html) - self.assertEqual(detail["vod_name"], "示例剧") - self.assertEqual(detail["vod_pic"], "https://www.wogg.net/poster.jpg") - self.assertEqual(detail["vod_director"], "导演甲") - self.assertEqual(detail["vod_actor"], "演员甲,演员乙") - self.assertEqual(detail["pan_urls"], ["https://pan.quark.cn/s/q1", "https://pan.baidu.com/s/b1"]) - - @patch.object(Spider, "_fetch_site_detail") - def test_detail_content_for_aggregate_id_merges_lines_from_multiple_sites(self, mock_fetch_site_detail): - mock_fetch_site_detail.side_effect = [ - { - "vod_name": "繁花", - "vod_pic": "https://img.example/w.jpg", - "vod_year": "2024", - "vod_director": "导演甲", - "vod_actor": "演员甲", - "vod_content": "玩偶简介", - "pan_urls": ["https://pan.baidu.com/s/b1", "https://pan.quark.cn/s/q1"], - "_site_name": "玩偶", - }, - { - "vod_name": "繁花", - "vod_pic": "https://img.example/m.jpg", - "vod_year": "2024", - "vod_director": "导演乙", - "vod_actor": "演员乙", - "vod_content": "木偶简介", - "pan_urls": ["https://pan.quark.cn/s/q2", "https://pan.baidu.com/s/b1"], - "_site_name": "木偶", - }, - ] - payload = [ - {"site": "wanou", "path": "/voddetail/1.html", "name": "繁花", "year": "2024"}, - {"site": "muou", "path": "/voddetail/2.html", "name": "繁花", "year": "2024"}, - ] - result = self.spider.detailContent([self.spider._encode_aggregate_vod_id(payload)]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "繁花") - self.assertEqual(vod["vod_pic"], "https://img.example/w.jpg") - self.assertEqual(vod["vod_play_from"], "baidu#玩偶$$$quark#玩偶$$$quark#木偶") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/q1$$$夸克资源$https://pan.quark.cn/s/q2", - ) - - def test_player_content_passthroughs_known_pan_domains(self): - self.assertEqual( - self.spider.playerContent("quark#玩偶", "https://pan.quark.cn/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.quark.cn/s/demo"}, - ) - self.assertEqual( - self.spider.playerContent("quark#玩偶", "https://www.quark.cn/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://www.quark.cn/s/demo"}, - ) - self.assertEqual( - self.spider.playerContent("baidu#玩偶", "https://pan.baidu.com/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.baidu.com/s/demo"}, - ) - self.assertEqual( - self.spider.playerContent("a189#玩偶", "https://cloud.189.cn/t/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://cloud.189.cn/t/demo"}, - ) - self.assertEqual( - self.spider.playerContent("a139#玩偶", "https://yun.139.com/share/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://yun.139.com/share/demo"}, - ) - - def test_player_content_rejects_non_pan_url(self): - self.assertEqual( - self.spider.playerContent("zxzj", "/vodplay/1-1-1.html", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) - - @patch.object(Spider, "_request_with_failover") - def test_fetch_site_search_builds_search_url_and_parses_results(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
    -
    -
    搜索影片
    -
    抢先版
    -
    - """ - site = { - "id": "wanou", - "domains": ["https://www.wogg.net"], - "list_xpath": "//*[contains(@class,'module-item')]", - "search_xpath": "//*[contains(@class,'module-search-item')]", - "search_url": "/vodsearch/-------------.html?wd={keyword}&page={page}", - } - results = self.spider._fetch_site_search(site, "繁花", 1) - self.assertEqual(results[0]["vod_id"], "site:wanou:/voddetail/789.html") - self.assertEqual(results[0]["vod_name"], "搜索影片") - - @patch.object(Spider, "_request_with_failover") - def test_fetch_site_search_builds_zhizhen_search_url_and_parses_results(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
    - 抢先版 -
    至臻影片
    -
    - """ - site = self.spider._get_site("zhizhen") - results = self.spider._fetch_site_search(site, "繁花", 1) - self.assertEqual( - mock_request_with_failover.call_args.args[1], - "/index.php/vod/search/page/1/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual( - results[0], - { - "vod_id": "site:zhizhen:/index.php/vod/detail/id/789.html", - "vod_name": "至臻影片", - "vod_pic": "http://www.miqk.cc/search.jpg", - "vod_remarks": "", - "vod_year": "", - "_site": "zhizhen", - "_detail_path": "/index.php/vod/detail/id/789.html", - }, - ) - - @patch.object(Spider, "_request_with_failover") - def test_fetch_site_search_builds_shandian_search_url_and_parses_results(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
    - 抢先版 -
    闪电影片
    -
    - """ - site = self.spider._get_site("shandian") - results = self.spider._fetch_site_search(site, "繁花", 1) - self.assertEqual( - mock_request_with_failover.call_args.args[1], - "/index.php/vod/search/page/1/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual( - results[0], - { - "vod_id": "site:shandian:/index.php/vod/detail/id/789.html", - "vod_name": "闪电影片", - "vod_pic": "https://sd.sduc.site/search.jpg", - "vod_remarks": "", - "vod_year": "", - "_site": "shandian", - "_detail_path": "/index.php/vod/detail/id/789.html", - }, - ) - - @patch.object(Spider, "_request_with_failover") - def test_fetch_site_search_builds_ouge_search_url_and_parses_results(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
    - 抢先版 -
    欧歌影片
    -
    抢先版
    -
    - """ - site = self.spider._get_site("ouge") - results = self.spider._fetch_site_search(site, "繁花", 1) - self.assertEqual( - mock_request_with_failover.call_args.args[1], - "/index.php/vod/search/page/1/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual( - results[0], - { - "vod_id": "site:ouge:/index.php/vod/detail/id/789.html", - "vod_name": "欧歌影片", - "vod_pic": "https://woog.nxog.eu.org/search.jpg", - "vod_remarks": "抢先版", - "vod_year": "", - "_site": "ouge", - "_detail_path": "/index.php/vod/detail/id/789.html", - }, - ) - - @patch.object(Spider, "_fetch_site_search") - def test_search_content_skips_site_errors(self, mock_fetch_site_search): - def fake_fetch(site, keyword, page): - if site["id"] == "wanou": - raise RuntimeError("boom") - if site["id"] == "muou": - return [ - { - "vod_id": "site:muou:/voddetail/2.html", - "vod_name": "繁花", - "vod_pic": "", - "vod_remarks": "", - "vod_year": "2024", - "_site": "muou", - "_detail_path": "/voddetail/2.html", - } - ] - return [] - - mock_fetch_site_search.side_effect = fake_fetch - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(result["total"], 1) - self.assertEqual(result["list"][0]["vod_name"], "繁花") - - def test_home_content_does_not_expose_content_first_categories(self): - content = self.spider.homeContent(False) - self.assertNotIn("movie", [item["type_id"] for item in content["class"]]) - - def test_search_content_returns_empty_list_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - @patch.object(Spider, "_fetch_site_detail") - def test_detail_content_for_aggregate_id_merges_zhizhen_pan_lines(self, mock_fetch_site_detail): - mock_fetch_site_detail.side_effect = [ - { - "vod_name": "繁花", - "vod_pic": "https://img.example/w.jpg", - "vod_year": "2024", - "vod_director": "导演甲", - "vod_actor": "演员甲", - "vod_content": "玩偶简介", - "pan_urls": ["https://pan.baidu.com/s/b1"], - "_site_name": "玩偶", - }, - { - "vod_name": "繁花", - "vod_pic": "http://www.miqk.cc/poster.jpg", - "vod_year": "2024", - "vod_director": "导演乙", - "vod_actor": "演员乙", - "vod_content": "至臻简介", - "pan_urls": ["https://pan.quark.cn/s/z1", "https://pan.baidu.com/s/b1"], - "_site_name": "至臻", - }, - ] - payload = [ - {"site": "wanou", "path": "/voddetail/1.html", "name": "繁花", "year": "2024"}, - {"site": "zhizhen", "path": "/index.php/vod/detail/id/2.html", "name": "繁花", "year": "2024"}, - ] - result = self.spider.detailContent([self.spider._encode_aggregate_vod_id(payload)]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "繁花") - self.assertEqual(vod["vod_play_from"], "baidu#玩偶$$$quark#至臻") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/z1", - ) - - @patch.object(Spider, "_fetch_site_detail") - def test_detail_content_for_aggregate_id_merges_ouge_pan_lines(self, mock_fetch_site_detail): - mock_fetch_site_detail.side_effect = [ - { - "vod_name": "繁花", - "vod_pic": "https://img.example/w.jpg", - "vod_year": "2024", - "vod_director": "导演甲", - "vod_actor": "演员甲", - "vod_content": "玩偶简介", - "pan_urls": ["https://pan.baidu.com/s/b1"], - "_site_name": "玩偶", - }, - { - "vod_name": "繁花", - "vod_pic": "https://woog.nxog.eu.org/poster.jpg", - "vod_year": "2024", - "vod_director": "导演乙", - "vod_actor": "演员乙", - "vod_content": "欧歌简介", - "pan_urls": ["https://pan.quark.cn/s/o1", "https://pan.baidu.com/s/b1"], - "_site_name": "欧歌", - }, - ] - payload = [ - {"site": "wanou", "path": "/voddetail/1.html", "name": "繁花", "year": "2024"}, - {"site": "ouge", "path": "/index.php/vod/detail/id/2.html", "name": "繁花", "year": "2024"}, - ] - result = self.spider.detailContent([self.spider._encode_aggregate_vod_id(payload)]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "繁花") - self.assertEqual(vod["vod_play_from"], "baidu#玩偶$$$quark#欧歌") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/o1", - ) - - @patch.object(Spider, "_request_with_failover") - def test_category_content_builds_zhizhen_category_url(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
    -
    - - 至臻分类片 -
    -
    HD
    -
    - """ - result = self.spider.categoryContent("site_zhizhen", "2", False, {"categoryId": "24"}) - self.assertEqual( - mock_request_with_failover.call_args.args[1], - "http://www.miqk.cc/index.php/vod/show/id/24/page/2.html", - ) - self.assertEqual(result["list"][0]["vod_id"], "site:zhizhen:/index.php/vod/detail/id/456.html") - - @patch.object(Spider, "_request_with_failover") - def test_category_content_builds_shandian_category_url(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
    -
    - - 闪电分类片 -
    -
    HD
    -
    - """ - result = self.spider.categoryContent("site_shandian", "2", False, {"categoryId": "30"}) - self.assertEqual( - mock_request_with_failover.call_args.args[1], - "https://sd.sduc.site/index.php/vod/show/id/30/page/2.html", - ) - self.assertEqual(result["list"][0]["vod_id"], "site:shandian:/index.php/vod/detail/id/456.html") - - @patch.object(Spider, "_fetch_site_detail") - def test_detail_content_for_aggregate_id_merges_shandian_pan_lines(self, mock_fetch_site_detail): - mock_fetch_site_detail.side_effect = [ - { - "vod_name": "繁花", - "vod_pic": "https://img.example/w.jpg", - "vod_year": "2024", - "vod_director": "导演甲", - "vod_actor": "演员甲", - "vod_content": "玩偶简介", - "pan_urls": ["https://pan.baidu.com/s/b1"], - "_site_name": "玩偶", - }, - { - "vod_name": "繁花", - "vod_pic": "https://sd.sduc.site/poster.jpg", - "vod_year": "2024", - "vod_director": "导演乙", - "vod_actor": "演员乙", - "vod_content": "闪电简介", - "pan_urls": ["https://pan.quark.cn/s/s1", "https://pan.baidu.com/s/b1"], - "_site_name": "闪电", - }, - ] - payload = [ - {"site": "wanou", "path": "/voddetail/1.html", "name": "繁花", "year": "2024"}, - {"site": "shandian", "path": "/index.php/vod/detail/id/2.html", "name": "繁花", "year": "2024"}, - ] - result = self.spider.detailContent([self.spider._encode_aggregate_vod_id(payload)]) - vod = result["list"][0] - self.assertEqual(vod["vod_play_from"], "baidu#玩偶$$$quark#闪电") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/s1", - ) - - @patch.object(Spider, "_request_with_failover") - def test_category_content_builds_ouge_category_url(self, mock_request_with_failover): - mock_request_with_failover.return_value = """ -
    -
    - - 欧歌分类片 -
    -
    HD
    -
    - """ - result = self.spider.categoryContent("site_ouge", "2", False, {"categoryId": "21"}) - self.assertEqual( - mock_request_with_failover.call_args.args[1], - "https://woog.nxog.eu.org/index.php/vod/show/id/21/page/2.html", - ) - self.assertEqual(result["list"][0]["vod_id"], "site:ouge:/index.php/vod/detail/id/456.html") diff --git a/py/tests/test_短剧优选.py b/py/tests/test_短剧优选.py deleted file mode 100644 index ed44867..0000000 --- a/py/tests/test_短剧优选.py +++ /dev/null @@ -1,286 +0,0 @@ -import json -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("duanjuyx_spider", str(ROOT / "短剧优选.py")).load_module() -Spider = MODULE.Spider - - -class TestDuanjuYouxuanSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.xingya_headers = {"authorization": "token"} - - def test_home_content_has_active_platforms(self): - content = self.spider.homeContent(False) - ids = [c["type_id"] for c in content["class"]] - self.assertEqual(ids, ["七猫", "星芽", "星星", "西饭", "锦鲤", "红果", "微观", "短剧网"]) - self.assertNotIn("七星", ids) - - def test_home_content_has_filters(self): - content = self.spider.homeContent(False) - self.assertIn("七猫", content["filters"]) - self.assertIn("星星", content["filters"]) - self.assertIn("锦鲤", content["filters"]) - self.assertIn("微观", content["filters"]) - self.assertIn("短剧网", content["filters"]) - self.assertEqual(content["filters"]["锦鲤"][0]["key"], "area") - - def test_home_video_content_returns_empty(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - @patch.object(Spider, "_get_json") - def test_player_hongguo_uses_video_api_result(self, mock_json): - mock_json.return_value = {"url": "https://media.example.com/play.mp4"} - result = self.spider.playerContent("红果短剧", "vid123", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://media.example.com/play.mp4") - - def test_player_xingya_passthrough(self): - result = self.spider.playerContent("星芽短剧", "https://example.com/video.m3u8", {}) - self.assertEqual(result["url"], "https://example.com/video.m3u8") - - def test_player_duanjuwang_passthrough(self): - result = self.spider.playerContent("短剧网", "push://pan.quark.cn/abc", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "push://pan.quark.cn/abc") - - def test_player_weiguan_passthrough(self): - result = self.spider.playerContent("1080P", "https://media.example.com/1080.mp4", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://media.example.com/1080.mp4") - - @patch.object(Spider, "_post_json") - def test_category_jinli(self, mock_post_json): - mock_post_json.return_value = { - "data": { - "list": [ - {"vod_id": "j1", "vod_name": "重生逆袭遇良人", "vod_pic": "http://pic/j1.jpg", "vod_total": 88} - ] - } - } - result = self.spider.categoryContent("锦鲤", "1", False, {"area": ""}) - self.assertEqual(len(result["list"]), 1) - self.assertEqual(result["list"][0]["vod_id"], "锦鲤@j1") - self.assertEqual(result["list"][0]["vod_name"], "重生逆袭遇良人") - self.assertGreater(result["total"], 0) - - @patch.object(Spider, "_get_json") - def test_category_xingxing(self, mock_get_json): - mock_get_json.return_value = { - "data": { - "datalist": [ - { - "id": "420424102051841", - "name": "穿越诡计爱上你", - "introduction": "简介", - "icon": "http://pic/xx.jpg", - "heat": "42", - } - ], - "total": 10, - } - } - result = self.spider.categoryContent("星星", "1", False, {"area": "1287"}) - self.assertEqual(len(result["list"]), 1) - self.assertEqual(result["list"][0]["vod_id"], "星星@420424102051841@@穿越诡计爱上你@@简介") - self.assertEqual(result["list"][0]["vod_remarks"], "42万播放") - - @patch.object(Spider, "_get_json") - def test_category_hongguo(self, mock_get_json): - mock_get_json.return_value = { - "data": [ - {"book_id": "b1", "title": "逆袭人生", "cover": "http://pic/1.jpg", "sub_title": "60集"}, - {"book_id": "b2", "title": "霸总归来", "cover": "http://pic/2.jpg", "sub_title": "80集"}, - ] - } - result = self.spider.categoryContent("红果", "1", False, {"area": "逆袭"}) - self.assertEqual(len(result["list"]), 2) - self.assertEqual(result["list"][0]["vod_id"], "红果@b1") - self.assertEqual(result["page"], 1) - - @patch.object(Spider, "_post_json") - def test_category_weiguan(self, mock_post_json): - mock_post_json.return_value = { - "data": [ - { - "oneId": "wg1", - "title": "重生后我爆锤渣男", - "horzPoster": "http://pic/w1.jpg", - "episodeCount": "72集", - } - ] - } - result = self.spider.categoryContent("微观", "1", False, {"area": "逆袭"}) - self.assertEqual(len(result["list"]), 1) - self.assertEqual(result["list"][0]["vod_id"], "微观@wg1") - self.assertEqual(result["list"][0]["vod_pic"], "http://pic/w1.jpg") - self.assertIn("version_code=1500", mock_post_json.call_args.args[0]) - self.assertEqual(mock_post_json.call_args.kwargs["body"]["subject"], "逆袭") - - @patch.object(Spider, "_get_text") - def test_category_duanjuwang(self, mock_get_text): - mock_get_text.return_value = """ -
  • -

    短剧名(60集完结)

    - -
  • - """ - result = self.spider.categoryContent("短剧网", "1", False, {"area": "1"}) - self.assertEqual(len(result["list"]), 1) - self.assertEqual(result["list"][0]["vod_id"], "短剧网@https://sm3.cc/?id=123") - self.assertEqual(result["page"], 1) - - @patch.object(Spider, "_get_json") - def test_detail_jinli(self, mock_get_json): - mock_get_json.return_value = { - "data": { - "vod_name": "重生逆袭遇良人", - "vod_class": "重生", - "vod_pic": "http://pic/j1.jpg", - "vod_remarks": "88集", - "vod_blurb": "剧情简介", - "player": {"第1集": "https://player.example.com/?url=abc"}, - } - } - result = self.spider.detailContent(["锦鲤@j1"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "重生逆袭遇良人") - self.assertEqual(vod["vod_play_from"], "锦鲤短剧") - self.assertIn("第1集$https://player.example.com/?url=abc", vod["vod_play_url"]) - - @patch.object(Spider, "_get_json") - def test_detail_xingxing(self, mock_get_json): - mock_get_json.return_value = { - "data": [ - { - "chapterName": "第1集", - "shortPlayList": [ - {"chapterShortPlayVoList": [{"shortPlayUrl": "http://img.novel.wsljf.xyz/video.mp4"}]} - ], - } - ] - } - result = self.spider.detailContent(["星星@420424102051841@@穿越诡计爱上你@@简介"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "穿越诡计爱上你") - self.assertEqual(vod["vod_play_from"], "星星短剧") - self.assertIn("第1集$https://img.novel.wsljf.xyz/video.mp4", vod["vod_play_url"]) - - @patch.object(Spider, "_get_json") - def test_detail_hongguo(self, mock_get_json): - mock_get_json.return_value = { - "book_name": "甜剧1号", - "book_pic": "http://pic/t.jpg", - "desc": "剧情简介", - "duration": "60分钟", - "author": "作者", - "time": "2025-01-01", - "data": [ - {"title": "第1集", "video_id": "v1"}, - {"title": "第2集", "video_id": "v2"}, - ], - } - result = self.spider.detailContent(["红果@b1"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "甜剧1号") - self.assertEqual(vod["vod_play_from"], "红果短剧") - self.assertIn("第1集$v1", vod["vod_play_url"]) - self.assertIn("第2集$v2", vod["vod_play_url"]) - - @patch.object(Spider, "_get_json") - def test_detail_weiguan(self, mock_get_json): - mock_get_json.return_value = { - "title": "重生后我爆锤渣男", - "vertPoster": "http://pic/wg.jpg", - "description": "剧情简介", - "data": [ - { - "playOrder": "第1集", - "videoClarityList": [ - {"name": "1080P", "url": "https://media.example.com/1-1080.mp4"}, - {"name": "720P", "url": "https://media.example.com/1-720.mp4"}, - ], - }, - { - "playOrder": "第2集", - "videoClarityList": [ - {"name": "1080P", "url": "https://media.example.com/2-1080.mp4"}, - ], - }, - ], - } - result = self.spider.detailContent(["微观@wg1"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "重生后我爆锤渣男") - self.assertEqual(vod["vod_play_from"], "1080P$$$720P") - self.assertEqual( - vod["vod_play_url"], - "第1集$https://media.example.com/1-1080.mp4#第2集$https://media.example.com/2-1080.mp4" - "$$$第1集$https://media.example.com/1-720.mp4", - ) - self.assertIn("oneId=wg1", mock_get_json.call_args.args[0]) - self.assertIn("queryAll=true", mock_get_json.call_args.args[0]) - - @patch.object(Spider, "_get_text") - def test_detail_duanjuwang(self, mock_get_text): - mock_get_text.return_value = """ - -

    网盘短剧名

    - - - - """ - result = self.spider.detailContent(["短剧网@https://sm3.cc/?id=123"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "网盘短剧名") - self.assertEqual(vod["vod_content"], "此为推送网盘规则") - self.assertIn("quark", vod["vod_play_from"]) - self.assertIn("baidu", vod["vod_play_from"]) - self.assertIn("quark$https://pan.quark.cn/s/abc123", vod["vod_play_url"]) - - @patch.object(Spider, "_get_json") - def test_detail_failure_returns_error_vod(self, mock_get_json): - mock_get_json.side_effect = Exception("network error") - result = self.spider.detailContent(["红果@missing"]) - vod = result["list"][0] - self.assertIn("详情加载失败", vod["vod_name"]) - - @patch.object(Spider, "_aggregate_search") - def test_search_filters_by_keyword(self, mock_search): - mock_search.return_value = [ - {"vod_id": "红果@1", "vod_name": "逆袭人生", "vod_pic": "", "vod_remarks": ""}, - {"vod_id": "锦鲤@2", "vod_name": "霸道总裁", "vod_pic": "", "vod_remarks": ""}, - ] - result = self.spider.searchContent("逆袭", False, "1") - self.assertEqual(len(result["list"]), 1) - self.assertEqual(result["list"][0]["vod_name"], "逆袭人生") - - @patch.object(Spider, "_aggregate_search") - def test_search_empty_keyword(self, mock_search): - result = self.spider.searchContent("", False, "1") - self.assertEqual(result["list"], []) - mock_search.assert_not_called() - - def test_filter_defaults(self): - self.assertEqual(self.spider.filter_defaults["锦鲤"]["area"], "") - self.assertEqual(self.spider.filter_defaults["星星"]["area"], "1287") - self.assertEqual(self.spider.filter_defaults["微观"]["area"], "") - self.assertEqual(self.spider.filter_defaults["短剧网"]["area"], "1") - - def test_identify_disk(self): - self.assertEqual(self.spider._identify_disk("https://pan.quark.cn/s/abc"), "quark") - self.assertEqual(self.spider._identify_disk("https://pan.baidu.com/s/xyz"), "baidu") - self.assertEqual(self.spider._identify_disk("https://drive.uc.cn/s/123"), "uc") - self.assertEqual(self.spider._identify_disk("https://example.com/video.mp4"), "") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_红果短剧.py b/py/tests/test_红果短剧.py deleted file mode 100644 index e9498b4..0000000 --- a/py/tests/test_红果短剧.py +++ /dev/null @@ -1,153 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import MagicMock, patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("hongguo_spider", str(ROOT / "红果短剧.py")).load_module() -Spider = MODULE.Spider - - -class TestHongGuoDuanJuSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_categories(self): - content = self.spider.homeContent(False) - ids = [item["type_id"] for item in content["class"]] - self.assertEqual(content["class"][0]["type_id"], "逆袭") - self.assertIn("现代言情", ids) - self.assertNotIn("热播", ids) - self.assertIn("分类", content["filters"]) - - @patch.object(Spider, "_get_json") - def test_home_video_content_uses_hot_category(self, mock_json): - mock_json.return_value = { - "data": [ - {"id": 1, "title": "【热播】逆袭人生", "cover": "http://img/1.jpg", "totalChapterNum": 88} - ] - } - result = self.spider.homeVideoContent() - self.assertEqual(result["list"][0]["vod_id"], "1") - self.assertEqual(result["list"][0]["vod_name"], "逆袭人生") - self.assertEqual(result["list"][0]["vod_remarks"], "更新至88集") - self.assertIn("name=%E7%83%AD%E6%92%AD", mock_json.call_args.args[0]) - - @patch.object(Spider, "_get_json") - def test_category_content_builds_query_and_maps_items(self, mock_json): - mock_json.return_value = { - "data": [ - {"book_id": "b2", "title": "新剧回归", "cover": "http://img/2.jpg", "sub_title": "36集"} - ] - } - result = self.spider.categoryContent("穿越", "3", False, {}) - self.assertEqual(result["page"], 3) - self.assertEqual(result["list"][0]["vod_id"], "b2") - self.assertEqual(result["list"][0]["vod_remarks"], "36集") - self.assertNotIn("pagecount", result) - self.assertIn("/api/duanju/api.php?", mock_json.call_args.args[0]) - self.assertIn("classname=%E7%A9%BF%E8%B6%8A", mock_json.call_args.args[0]) - self.assertIn("offset=40", mock_json.call_args.args[0]) - self.assertIn("showRawParams=false", mock_json.call_args.args[0]) - - @patch.object(Spider, "_get_json") - def test_search_content_uses_keyword_query(self, mock_json): - mock_json.return_value = { - "data": [ - {"id": 3, "title": "霸总归来", "cover": "", "totalChapterNum": 12} - ] - } - result = self.spider.searchContent("霸总", False, "1") - self.assertEqual(result["list"][0]["vod_name"], "霸总归来") - self.assertIn("name=%E9%9C%B8%E6%80%BB&page=1", mock_json.call_args.args[0]) - - @patch.object(Spider, "_get_json") - def test_search_content_returns_empty_for_blank_keyword(self, mock_json): - result = self.spider.searchContent("", False, "1") - self.assertEqual(result["list"], []) - mock_json.assert_not_called() - - @patch.object(Spider, "fetch") - def test_category_content_returns_empty_for_invalid_json_response(self, mock_fetch): - mock_fetch.return_value = MagicMock(status_code=200, text="blocked") - result = self.spider.categoryContent("新剧", "1", False, {}) - self.assertEqual(result, {"page": 1, "limit": 0, "total": 0, "list": []}) - - def test_clean_title_removes_hot_and_new_tags(self): - self.assertEqual(self.spider._clean_title("【热播】新剧名"), "新剧名") - self.assertEqual(self.spider._clean_title("[新剧热播] 第二部"), "第二部") - - @patch.object(Spider, "_get_json") - def test_detail_content_builds_single_playlist(self, mock_json): - mock_json.return_value = { - "book_id": "100", - "book_name": "总裁有疾闪婚来袭", - "author": "树下的椰子", - "category_names": ["都市爱情", "闪婚", "总裁"], - "desc": "一段剧情简介", - "duration": "1小时43分钟", - "book_pic": "http://img/d.jpg", - "time": "2026-04-25 09:32:02", - "total": "2", - "data": [ - {"title": "第1集", "video_id": "v1", "volume_name": ""}, - {"title": "第2集", "video_id": "v2", "volume_name": ""}, - ] - } - result = self.spider.detailContent(["100"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "100") - self.assertEqual(vod["vod_name"], "总裁有疾闪婚来袭") - self.assertEqual(vod["vod_pic"], "http://img/d.jpg") - self.assertEqual(vod["vod_remarks"], "1小时43分钟") - self.assertEqual(vod["vod_actor"], "树下的椰子") - self.assertEqual(vod["vod_area"], "都市爱情,闪婚,总裁") - self.assertEqual(vod["vod_content"], "一段剧情简介") - self.assertEqual(vod["vod_play_from"], "红果短剧") - self.assertIn("第1集$v1", vod["vod_play_url"]) - self.assertIn("第2集$v2", vod["vod_play_url"]) - self.assertEqual( - mock_json.call_args.args[0], - "https://api-v2.cenguigui.cn/api/duanju/api.php?book_id=100", - ) - - @patch.object(Spider, "_get_json") - def test_detail_content_returns_empty_when_id_missing(self, mock_json): - result = self.spider.detailContent([""]) - self.assertEqual(result, {"list": []}) - mock_json.assert_not_called() - - def test_sort_qualities_prefers_1080p_then_sc_then_sd(self): - ordered = self.spider._sort_qualities( - [ - {"quality": "sd", "download_url": "sd.mp4"}, - {"quality": "1080p", "download_url": "1080.mp4"}, - {"quality": "sc", "download_url": "sc.mp4"}, - ] - ) - self.assertEqual([item["quality"] for item in ordered], ["1080p", "sc", "sd"]) - - @patch.object(Spider, "_get_json") - def test_player_content_returns_best_quality_direct_url(self, mock_json): - mock_json.return_value = {"url": "http://video/play.mp4"} - result = self.spider.playerContent("红果短剧", "v1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "http://video/play.mp4") - self.assertEqual( - mock_json.call_args.args[0], - "https://api-v2.cenguigui.cn/api/duanju/api.php?video_id=v1", - ) - - @patch.object(Spider, "_get_json") - def test_player_content_returns_empty_url_on_error(self, mock_json): - mock_json.side_effect = Exception("boom") - result = self.spider.playerContent("红果短剧", "v2", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_耐视点播.py b/py/tests/test_耐视点播.py deleted file mode 100644 index c4bf82b..0000000 --- a/py/tests/test_耐视点播.py +++ /dev/null @@ -1,263 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("nsvod_spider", str(ROOT / "耐视点播.py")).load_module() -Spider = MODULE.Spider - - -class TestNSVodSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_fixed_classes(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("1", "电影"), - ("2", "连续剧"), - ("3", "综艺"), - ("4", "动漫"), - ("37", "Netflix"), - ("40", "纪录片"), - ], - ) - - def test_build_url_and_extract_vod_id_handle_relative_paths(self): - self.assertEqual( - self.spider._build_url("/index.php/vod/detail/id/7.html"), - "https://nsvod.me/index.php/vod/detail/id/7.html", - ) - self.assertEqual( - self.spider._build_url("//img.example/poster.jpg"), - "https://img.example/poster.jpg", - ) - self.assertEqual(self.spider._extract_vod_id("/index.php/vod/detail/id/99.html"), "99") - self.assertEqual(self.spider._extract_vod_id("/bad/path"), "") - - def test_parse_cards_extracts_unique_videos(self): - html = """ - - - 更新至01集 - - - -
    HD
    -
    - - - - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "11", - "vod_name": "示例A", - "vod_pic": "https://nsvod.me/a.jpg", - "vod_remarks": "更新至01集", - }, - { - "vod_id": "12", - "vod_name": "示例B", - "vod_pic": "https://nsvod.me/b.jpg", - "vod_remarks": "HD", - }, - ], - ) - - @patch.object(Spider, "_request_html") - def test_home_video_content_reads_home_page(self, mock_request_html): - mock_request_html.return_value = """ - - - 热播 - - """ - result = self.spider.homeVideoContent() - self.assertEqual(mock_request_html.call_args.args[0], "https://nsvod.me/") - self.assertEqual( - result, - { - "list": [ - { - "vod_id": "88", - "vod_name": "首页片", - "vod_pic": "https://nsvod.me/home.jpg", - "vod_remarks": "热播", - } - ] - }, - ) - - @patch.object(Spider, "_request_html") - def test_category_content_reads_category_page(self, mock_request_html): - mock_request_html.return_value = """ - - - 更新中 - - """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://nsvod.me/index.php/vod/show/id/2.html?page=3", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_id"], "21") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_category_content_falls_back_to_home_sections(self, mock_request_html): - mock_request_html.side_effect = [ - "", - """ -
    最新综艺
    - - - 第1期 - -
    最新纪录片
    - """, - ] - result = self.spider.categoryContent("3", "1", False, {}) - self.assertEqual([item["vod_id"] for item in result["list"]], ["31"]) - - @patch.object(Spider, "_request_html") - def test_search_content_short_circuits_blank_keyword_and_parses_results(self, mock_request_html): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - mock_request_html.assert_not_called() - - mock_request_html.return_value = """ -
    - 抢先版 -
    搜索片
    -
    - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://nsvod.me/index.php/vod/search.html?wd=%E7%B9%81%E8%8A%B1", - ) - self.assertEqual(result["total"], 1) - self.assertEqual( - result["list"][0], - { - "vod_id": "45", - "vod_name": "搜索片", - "vod_pic": "https://nsvod.me/search.jpg", - "vod_remarks": "抢先版", - }, - ) - - def test_parse_detail_page_extracts_metadata_and_play_groups(self): - html = """ - 《耐视示例》在线观看 -
    -
    年份2025
    -
    地区中国香港
    -
    导演 导演甲
    -
    主演 演员甲 / 演员乙
    -

    这是一段简介。

    -
    -
    线路A 2集
    -
    线路B 1集
    -
    -
    - -
    - 正片 -
    -
    - """ - vod = self.spider._parse_detail_page("70", html) - self.assertEqual(vod["vod_name"], "耐视示例") - self.assertEqual(vod["vod_pic"], "https://nsvod.me/poster-detail.jpg") - self.assertEqual(vod["vod_year"], "2025") - self.assertEqual(vod["vod_area"], "中国香港") - self.assertEqual(vod["vod_director"], "导演甲") - self.assertEqual(vod["vod_actor"], "演员甲 / 演员乙") - self.assertEqual(vod["vod_content"], "这是一段简介。") - self.assertEqual(vod["vod_play_from"], "线路A$$$线路B") - self.assertEqual( - vod["vod_play_url"], - "第1集$/index.php/vod/play/id/70/sid/1/nid/1.html#第2集$/index.php/vod/play/id/70/sid/1/nid/2.html$$$正片$/index.php/vod/play/id/70/sid/2/nid/1.html", - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_reads_detail_url_and_returns_single_vod(self, mock_request_html): - mock_request_html.return_value = """ -
    详情标题
    -
    -
    - 正片 -
    -
    - """ - result = self.spider.detailContent(["80"]) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://nsvod.me/index.php/vod/detail/id/80.html", - ) - self.assertEqual(result["list"][0]["vod_id"], "80") - self.assertEqual(result["list"][0]["vod_name"], "详情标题") - self.assertEqual(result["list"][0]["vod_play_from"], "线路1") - self.assertEqual(result["list"][0]["vod_play_url"], "正片$/index.php/vod/play/id/80/sid/1/nid/1.html") - - @patch.object(Spider, "_request_html") - def test_player_content_returns_player_aaaa_url(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.playerContent("线路A", "/index.php/vod/play/id/70/sid/1/nid/1.html", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://cdn.example/direct.m3u8") - self.assertEqual(result["header"]["Referer"], "https://nsvod.me/index.php/vod/play/id/70/sid/1/nid/1.html") - - @patch.object(Spider, "_request_html") - def test_player_content_supports_nested_player_aaaa_object(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.playerContent("DY线路", "/index.php/vod/play/id/22663/sid/1/nid/1.html", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://vip.dytt-kan.com/20260423/13858_751d8d46/index.m3u8") - self.assertEqual(result["header"]["Referer"], "https://nsvod.me/index.php/vod/play/id/22663/sid/1/nid/1.html") - - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_to_inline_m3u8(self, mock_request_html): - mock_request_html.return_value = '' - result = self.spider.playerContent("线路A", "/index.php/vod/play/id/71/sid/1/nid/1.html", {}) - self.assertEqual(result["url"], "https://cdn.example/fallback.m3u8?token=1") - self.assertEqual(result["jx"], 0) - - @patch.object(Spider, "_request_html") - def test_player_content_returns_play_page_when_no_media_url_found(self, mock_request_html): - mock_request_html.return_value = "empty" - result = self.spider.playerContent("线路A", "/index.php/vod/play/id/72/sid/1/nid/1.html", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["playUrl"], "") - self.assertEqual(result["url"], "https://nsvod.me/index.php/vod/play/id/72/sid/1/nid/1.html") - self.assertEqual(result["header"]["Referer"], "https://nsvod.me/") - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_腾讯视频.py b/py/tests/test_腾讯视频.py deleted file mode 100644 index 91d7368..0000000 --- a/py/tests/test_腾讯视频.py +++ /dev/null @@ -1,239 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("tengxun_spider", str(ROOT / "腾讯视频.py")).load_module() -Spider = MODULE.Spider - - -HOME_HTML = """ - -
    - 熊出没 - 全52集 -
    -""" - -CATEGORY_HTML = """ - -""" - - -class TestTencentSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_parse_list_items_extracts_cards(self): - cards = self.spider._parse_list_items(HOME_HTML, with_channel=False) - self.assertEqual( - cards, - [ - { - "vod_id": "/x/cover/mzc00200abc1111.html", - "vod_name": "海底小纵队", - "vod_pic": "https://img.test/a.jpg", - "vod_remarks": "更新至10集", - }, - { - "vod_id": "/x/cover/mzc00200abc2222.html", - "vod_name": "熊出没", - "vod_pic": "https://img.test/b.jpg", - "vod_remarks": "全52集", - }, - ], - ) - - @patch.object(Spider, "fetch") - def test_home_content_returns_fixed_classes_and_top_20_cards(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(text=HOME_HTML) - result = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in result["class"]], - ["choice", "movie", "tv", "variety", "cartoon", "child", "doco"], - ) - self.assertEqual(result["list"][0]["vod_name"], "海底小纵队") - self.assertNotIn("filters", result) - - def test_player_content_passthroughs_raw_url(self): - result = self.spider.playerContent("腾讯视频", "https://v.qq.com/x/cover/demo.html", {}) - self.assertEqual( - result, - { - "parse": 1, - "jx": 1, - "url": "https://v.qq.com/x/cover/demo.html", - "header": {"User-Agent": "PC_UA"}, - }, - ) - - @patch.object(Spider, "fetch") - def test_category_content_maps_filters_and_prefixes_channel(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(text=CATEGORY_HTML) - result = self.spider.categoryContent( - "tv", - "2", - False, - { - "sort": "18", - "iyear": "2024", - "year": "2024", - "type": "17", - "feature": "4", - "area": "2", - "itrailer": "1", - "sex": "1", - }, - ) - request_url = mock_fetch.call_args.args[0] - self.assertIn("channel=tv", request_url) - self.assertIn("offset=21", request_url) - self.assertIn("sort=18", request_url) - self.assertIn("iyear=2024", request_url) - self.assertIn("year=2024", request_url) - self.assertIn("itype=17", request_url) - self.assertIn("ifeature=4", request_url) - self.assertIn("iarea=2", request_url) - self.assertIn("itrailer=1", request_url) - self.assertIn("gender=1", request_url) - self.assertEqual(result["list"][0]["vod_id"], "tv$/x/cover/cid001/vid001.html") - self.assertEqual(result["page"], 2) - self.assertEqual(result["limit"], 21) - self.assertEqual(result["pagecount"], 9999) - - @patch.object(Spider, "fetch") - def test_get_batch_video_info_parses_qzoutputjson_payload(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace( - text='QZOutputJson={"results":[{"fields":{"vid":"vid001","title":"第1集","category_map":["0","正片"]}}]};' - ) - result = self.spider._get_batch_video_info(["vid001"]) - self.assertEqual(result, [{"vid": "vid001", "title": "第1集", "type": "正片"}]) - - @patch.object(Spider, "_get_batch_video_info") - @patch.object(Spider, "fetch") - def test_detail_content_merges_main_and_trailer_into_single_line(self, mock_fetch, mock_get_batch_video_info): - mock_fetch.return_value = SimpleNamespace( - json=lambda: { - "c": { - "title": "斗罗大陆", - "year": "2024", - "description": "热血冒险", - "pic": "/cover.jpg", - "video_ids": ["vid001", "vid002"], - }, - "typ": ["热血", "冒险"], - "nam": ["配音甲", "配音乙"], - "rec": "更新中", - } - ) - mock_get_batch_video_info.return_value = [ - {"vid": "vid001", "title": "1", "type": "正片"}, - {"vid": "vid002", "title": "终极预告", "type": "预告"}, - ] - - result = self.spider.detailContent(["tv$cid001"]) - vod = result["list"][0] - - self.assertEqual(vod["vod_id"], "tv$cid001") - self.assertEqual(vod["vod_name"], "斗罗大陆") - self.assertEqual(vod["type_name"], "热血,冒险") - self.assertEqual(vod["vod_actor"], "配音甲,配音乙") - self.assertEqual(vod["vod_pic"], "https://v.qq.com/cover.jpg") - self.assertEqual(vod["vod_play_from"], "腾讯视频") - self.assertEqual( - vod["vod_play_url"], - "第1集$https://v.qq.com/x/cover/cid001/vid001.html#终极预告$https://v.qq.com/x/cover/cid001/vid002.html", - ) - - @patch.object(Spider, "_get_batch_video_info") - @patch.object(Spider, "fetch") - def test_detail_content_handles_nested_typ_and_nam_lists(self, mock_fetch, mock_get_batch_video_info): - mock_fetch.return_value = SimpleNamespace( - json=lambda: { - "c": { - "title": "示例影片", - "year": "2024", - "description": "剧情简介", - "pic": "/poster.jpg", - "video_ids": ["vid001"], - }, - "typ": [["动漫", "1"], ["冒险", "2"]], - "nam": [["演员甲", "a1"], ["演员乙", "a2"]], - "rec": "更新中", - } - ) - mock_get_batch_video_info.return_value = [{"vid": "vid001", "title": "1", "type": "正片"}] - - result = self.spider.detailContent(["cid001"]) - vod = result["list"][0] - - self.assertEqual(vod["type_name"], "动漫,冒险") - self.assertEqual(vod["vod_actor"], "演员甲,演员乙") - - @patch.object(Spider, "post") - def test_search_content_collects_normal_and_area_results(self, mock_post): - mock_post.return_value = SimpleNamespace( - json=lambda: { - "data": { - "normalList": { - "itemList": [ - { - "doc": {"id": "mzc001234567890"}, - "videoInfo": { - "title": "庆余年", - "imgUrl": "https://img.test/1.jpg", - "firstLine": "热播", - }, - } - ] - }, - "areaBoxList": [ - { - "itemList": [ - { - "doc": {"id": "mzc009876543210"}, - "videoInfo": { - "title": "雪中悍刀行", - "imgUrl": "https://img.test/2.jpg", - "secondLine": "完结", - }, - } - ] - } - ], - } - } - ) - - result = self.spider.searchContent("庆余年", False, "2") - - self.assertEqual(result["page"], 2) - self.assertEqual(result["limit"], 30) - self.assertEqual(result["list"][0]["vod_name"], "庆余年") - self.assertEqual(result["list"][1]["vod_remarks"], "完结") - payload = mock_post.call_args.kwargs["json"] - self.assertEqual(payload["query"], "庆余年") - self.assertEqual(payload["pagenum"], 1) - - @patch.object(Spider, "post") - def test_search_content_returns_empty_page_on_error(self, mock_post): - mock_post.side_effect = RuntimeError("network error") - self.assertEqual( - self.spider.searchContent("失败", False, "1"), - {"list": [], "page": 1, "pagecount": 1, "limit": 30, "total": 0}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_至臻.py b/py/tests/test_至臻.py deleted file mode 100644 index 73d403f..0000000 --- a/py/tests/test_至臻.py +++ /dev/null @@ -1,271 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("zhizhen_spider", str(ROOT / "至臻.py")).load_module() -Spider = MODULE.Spider - - -class TestZhiZhenSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_all_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("1", "至臻电影"), - ("2", "至臻剧集"), - ("3", "至臻动漫"), - ("4", "至臻综艺"), - ("5", "至臻短剧"), - ("24", "至臻老剧"), - ("26", "至臻严选"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_url_and_detect_pan_type(self): - self.assertEqual( - self.spider._build_url("/index.php/vod/detail/id/1.html"), - "http://www.miqk.cc/index.php/vod/detail/id/1.html", - ) - self.assertEqual(self.spider._detect_pan_type("https://pan.baidu.com/s/demo"), ("baidu", "百度资源")) - self.assertEqual(self.spider._detect_pan_type("https://pan.quark.cn/s/demo"), ("quark", "夸克资源")) - self.assertEqual(self.spider._detect_pan_type("https://example.com/video"), ("", "")) - - def test_parse_cards_extracts_short_path_ids(self): - html = """ -
    -
    -
    - - 示例影片 -
    -
    HD
    -
    2025
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/index.php/vod/detail/id/123.html", - "vod_name": "示例影片", - "vod_pic": "http://www.miqk.cc/poster.jpg", - "vod_remarks": "HD", - "vod_year": "2025", - } - ], - ) - - def test_parse_cards_ignores_module_items_container_and_uses_first_card_cover(self): - html = """ -
    -
    -
    -
    - - 示例影片A -
    -
    HD
    -
    2025
    -
    -
    -
    - - 示例影片B -
    -
    更新至10集
    -
    2024
    -
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/index.php/vod/detail/id/123.html", - "vod_name": "示例影片A", - "vod_pic": "http://www.miqk.cc/poster-a.jpg", - "vod_remarks": "HD", - "vod_year": "2025", - }, - { - "vod_id": "/index.php/vod/detail/id/456.html", - "vod_name": "示例影片B", - "vod_pic": "http://www.miqk.cc/poster-b.jpg", - "vod_remarks": "更新至10集", - "vod_year": "2024", - }, - ], - ) - - def test_parse_cards_normalizes_nested_absolute_cover_url(self): - html = """ -
    -
    -
    - - 嵌套封面 -
    -
    HD
    -
    2024
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/index.php/vod/detail/id/123.html", - "vod_name": "嵌套封面", - "vod_pic": "https://img.ffzy888.com/upload/vod/2024-06-13/17182715511.jpg", - "vod_remarks": "HD", - "vod_year": "2024", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_reference_url_and_returns_page_payload(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    -
    - - 分类影片 -
    -
    更新至10集
    -
    2024
    -
    -
    - """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "http://www.miqk.cc/index.php/vod/show/id/2/page/3.html", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_builds_reference_search_url_and_parses_results(self, mock_request_html): - mock_request_html.return_value = """ -
    - 抢先版 -
    - 搜索影片 -
    -
    - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "http://www.miqk.cc/index.php/vod/search/page/2/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual( - result["list"][0], - { - "vod_id": "/index.php/vod/detail/id/789.html", - "vod_name": "搜索影片", - "vod_pic": "http://www.miqk.cc/search.jpg", - "vod_remarks": "抢先版", - }, - ) - - def test_search_content_returns_empty_list_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - def test_build_pan_lines_deduplicates_and_sorts_supported_links(self): - detail = { - "pan_urls": [ - "https://pan.quark.cn/s/q1", - "https://pan.baidu.com/s/b1", - "https://pan.baidu.com/s/b1", - "https://example.com/ignored", - ] - } - self.assertEqual( - self.spider._build_pan_lines(detail), - [ - ("baidu#至臻", "百度资源$https://pan.baidu.com/s/b1"), - ("quark#至臻", "夸克资源$https://pan.quark.cn/s/q1"), - ], - ) - - def test_parse_detail_page_extracts_meta_content_and_pan_urls(self): - html = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - detail = self.spider._parse_detail_page("/index.php/vod/detail/id/123.html", html) - self.assertEqual(detail["vod_name"], "示例剧") - self.assertEqual(detail["vod_pic"], "http://www.miqk.cc/poster.jpg") - self.assertEqual(detail["vod_year"], "2024") - self.assertEqual(detail["vod_director"], "导演甲") - self.assertEqual(detail["vod_actor"], "演员甲,演员乙") - self.assertEqual(detail["vod_content"], "一段剧情简介") - self.assertEqual(detail["pan_urls"], ["https://pan.quark.cn/s/q1", "https://pan.baidu.com/s/b1"]) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_pan_play_fields(self, mock_request_html): - mock_request_html.return_value = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - result = self.spider.detailContent(["/index.php/vod/detail/id/123.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["vod_play_from"], "baidu#至臻$$$quark#至臻") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/q1", - ) - - def test_player_content_passthroughs_supported_pan_urls(self): - self.assertEqual( - self.spider.playerContent("baidu#至臻", "https://pan.baidu.com/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.baidu.com/s/demo"}, - ) - - def test_player_content_rejects_non_pan_url(self): - self.assertEqual( - self.spider.playerContent("site", "/index.php/vod/play/id/1.html", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_茶杯狐.py b/py/tests/test_茶杯狐.py deleted file mode 100644 index 7f1cadd..0000000 --- a/py/tests/test_茶杯狐.py +++ /dev/null @@ -1,360 +0,0 @@ -import base64 -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("cupfox_spider", str(ROOT / "茶杯狐.py")).load_module() -Spider = MODULE.Spider - - -class TestCupfoxSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_encode_and_decode_ids_keep_short_paths(self): - self.assertEqual( - self.spider._encode_detail_id("/movie/test-slug.html"), - "detail/test-slug", - ) - self.assertEqual( - self.spider._decode_detail_id("detail/test-slug"), - "https://www.cupfox.ai/movie/test-slug.html", - ) - self.assertEqual( - self.spider._encode_play_id("/play/abc123.html"), - "play/abc123", - ) - self.assertEqual( - self.spider._decode_play_id("play/abc123"), - "https://www.cupfox.ai/play/abc123.html", - ) - self.assertEqual( - self.spider._encode_detail_id("/video/119983.html"), - "detail/video/119983", - ) - self.assertEqual( - self.spider._decode_detail_id("detail/video/119983"), - "https://www.cupfox.ai/video/119983.html", - ) - - def test_merge_set_cookie_and_cookie_header(self): - jar = {} - self.spider._merge_set_cookie(jar, ["foo=1; Path=/", "bar=2; HttpOnly"]) - self.assertEqual(jar, {"foo": "1", "bar": "2"}) - self.assertEqual(self.spider._cookie_header(jar), "foo=1; bar=2") - - def test_extract_firewall_token(self): - html = '' - self.assertEqual(self.spider._extract_firewall_token(html), "abcXYZ") - - @patch("cupfox_spider.random.randint", side_effect=[0, 1, 2, 3]) - def test_firewall_encrypt_returns_base64_text(self, _mock_randint): - encoded = self.spider._cupfox_firewall_encrypt("PX") - self.assertEqual(base64.b64decode(encoded).decode("utf-8"), "PwXh7w") - - def test_request_with_firewall_retries_after_robot_verification(self): - calls = [] - - def fake_request(url, method="GET", body=None, headers=None): - calls.append({"url": url, "method": method, "body": body, "headers": headers or {}}) - if len(calls) == 1: - return { - "status_code": 200, - "text": '
    ', - "headers": {"set-cookie": ["session=abc; Path=/"]}, - } - if "robot.php" in url: - self.assertEqual(method, "POST") - self.assertIn("Cookie", headers) - self.assertIn("value=", body) - self.assertIn("token=", body) - return { - "status_code": 200, - "text": "ok", - "headers": {"set-cookie": ["shield=passed; Path=/"]}, - } - return { - "status_code": 200, - "text": "ok", - "headers": {}, - } - - self.spider._request_text = fake_request - html = self.spider._request_with_firewall("https://www.cupfox.ai/search/test----------1---.html") - - self.assertEqual(html, "ok") - self.assertEqual(len(calls), 3) - self.assertEqual(calls[2]["headers"]["Cookie"], "session=abc; shield=passed") - - def test_request_with_firewall_accepts_capitalized_set_cookie_header(self): - calls = [] - - def fake_request(url, method="GET", body=None, headers=None): - calls.append({"url": url, "method": method, "body": body, "headers": headers or {}}) - if len(calls) == 1: - return { - "status_code": 200, - "text": '
    ', - "headers": {"Set-Cookie": "PHPSESSID=abc123; path=/"}, - } - if "robot.php" in url: - self.assertEqual(headers["Cookie"], "PHPSESSID=abc123") - return {"status_code": 200, "text": '{"msg":"ok"}', "headers": {}} - return { - "status_code": 200, - "text": "ok", - "headers": {}, - } - - self.spider._request_text = fake_request - html = self.spider._request_with_firewall("https://www.cupfox.ai/type/1-2.html") - - self.assertEqual(html, "ok") - self.assertEqual(calls[2]["headers"]["Cookie"], "PHPSESSID=abc123") - - def test_extract_player_data_reads_embedded_json(self): - html = '' - data = self.spider._extract_player_data(html) - self.assertEqual(data["url"], "vid-1") - self.assertEqual(data["from"], "lineA") - - def test_extract_player_data_supports_nested_object_literal(self): - html = """ - - """ - data = self.spider._extract_player_data(html) - self.assertEqual(data["encrypt"], 0) - self.assertEqual(data["vod_data"]["vod_name"], "八千里路云和月") - self.assertEqual(data["from"], "rb") - - 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_category_content_uses_plain_type_path_for_first_page(self): - seen = [] - - def fake_request(url): - seen.append(url) - return """ -
    - - -
    - """ - - self.spider._request_with_firewall = fake_request - self.spider.categoryContent("1", "1", False, {}) - - self.assertEqual(seen, ["https://www.cupfox.ai/type/1.html"]) - - def test_parse_cards_supports_video_detail_links(self): - html = """ -
    - -
    - 正片 -
    - """ - items = self.spider._parse_cards(html) - - self.assertEqual( - items, - [ - { - "vod_id": "detail/video/119983", - "vod_name": "挽救计划", - "vod_pic": "https://www.cupfox.ai/poster.jpg", - "vod_remarks": "正片", - } - ], - ) - - def test_detail_content_builds_play_sources(self): - html = """ -

    详情标题

    -
    -
    这是简介展开
    - - -
    演员
    -
    -
    线路一
    -
    线路二
    -
    -
    - -
    -
    - -
    - """ - 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"]) - - def test_placeholder_url_detection(self): - self.assertTrue(self.spider._is_placeholder_url("https://a.example/404.mp4")) - self.assertTrue(self.spider._is_placeholder_url("https://a.example/v.m3u8?code=403")) - self.assertFalse(self.spider._is_placeholder_url("https://media.example/video.m3u8")) - - def test_player_content_returns_decoded_api_url(self): - play_html = '' - - def fake_request(url, method="GET", body=None, headers=None): - if "foxplay/api.php" in url: - return { - "status_code": 200, - "text": '{"data":{"url":"https://media.example/video.m3u8","urlmode":0}}', - "headers": {}, - } - raise AssertionError(url) - - self.spider._request_with_firewall = lambda url: play_html - self.spider._request_text = fake_request - result = self.spider.playerContent("线路一", "play/p99", []) - - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://media.example/video.m3u8") - self.assertIn("muiplayer.php?vid=vid-99", result["header"]["Referer"]) - - def test_player_content_falls_back_when_api_returns_placeholder(self): - play_html = '' - - def fake_request(url, method="GET", body=None, headers=None): - return { - "status_code": 200, - "text": '{"data":{"url":"https://www.cupfox.ai/404.mp4","urlmode":0}}', - "headers": {}, - } - - self.spider._request_with_firewall = lambda url: play_html - self.spider._request_text = fake_request - result = self.spider.playerContent("线路一", "play/p77", []) - - self.assertEqual(result["parse"], 1) - self.assertEqual(result["url"], "https://www.cupfox.ai/play/p77.html") - - def test_player_content_urlencodes_vid_for_api_request(self): - play_html = '' - calls = [] - - def fake_request(url, method="GET", body=None, headers=None): - calls.append({"url": url, "method": method, "body": body, "headers": headers or {}}) - if "foxplay/api.php" in url: - return { - "status_code": 200, - "text": '{"data":{"url":"https://media.example/video.m3u8","urlmode":0}}', - "headers": {}, - } - raise AssertionError(url) - - self.spider._request_with_firewall = lambda url: play_html - self.spider._request_text = fake_request - result = self.spider.playerContent("线路一", "play/p88", []) - - self.assertEqual(result["parse"], 0) - self.assertEqual(calls[0]["body"], "vid=a%2Bb%2Fc%3D%3D") - self.assertIn("vid=a%2Bb%2Fc%3D%3D", calls[0]["headers"]["Referer"]) - - def test_search_content_returns_empty_result_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - def test_player_content_falls_back_when_missing_player_data(self): - self.spider._request_with_firewall = lambda url: "" - result = self.spider.playerContent("线路一", "play/p11", []) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["url"], "https://www.cupfox.ai/play/p11.html") diff --git a/py/tests/test_蜡笔.py b/py/tests/test_蜡笔.py deleted file mode 100644 index e15d5d1..0000000 --- a/py/tests/test_蜡笔.py +++ /dev/null @@ -1,225 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("labi_spider", str(ROOT / "蜡笔.py")).load_module() -Spider = MODULE.Spider - - -class TestLaBiSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_all_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("29", "蜡笔臻彩"), - ("1", "蜡笔电影"), - ("2", "蜡笔电视剧"), - ("3", "蜡笔动漫"), - ("4", "蜡笔综艺"), - ("5", "蜡笔短剧"), - ("24", "蜡笔4K"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_url_and_detect_pan_type(self): - self.assertEqual( - self.spider._build_url("/voddetail/1.html"), - "http://xiaocgege.shop/voddetail/1.html", - ) - self.assertEqual(self.spider._detect_pan_type("https://pan.baidu.com/s/demo"), ("baidu", "百度资源")) - self.assertEqual(self.spider._detect_pan_type("https://pan.quark.cn/s/demo"), ("quark", "夸克资源")) - self.assertEqual(self.spider._detect_pan_type("https://example.com/video"), ("", "")) - - def test_parse_cards_extracts_short_path_ids(self): - html = """ -
    -
    -
    - - 示例影片 -
    -
    HD
    -
    2025
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/voddetail/123.html", - "vod_name": "示例影片", - "vod_pic": "http://xiaocgege.shop/poster.jpg", - "vod_remarks": "HD", - "vod_year": "2025", - } - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_reference_url_and_returns_page_payload(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    -
    - - 分类影片 -
    -
    更新至10集
    -
    2024
    -
    -
    - """ - result = self.spider.categoryContent("29", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "http://xiaocgege.shop/index.php/vod/type/id/29/page/3.html", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "fetch") - def test_category_content_tries_next_domain_when_primary_fails(self, mock_fetch): - html = """ -
    -
    -
    - - 分类影片 -
    -
    更新至10集
    -
    2024
    -
    -
    - """ - - def fake_fetch(url, headers=None, timeout=10): - if url.startswith("http://xiaocgege.shop") or url.startswith("http://xiaocge.fun"): - raise RuntimeError("boom") - return SimpleNamespace(status_code=200, text=html) - - mock_fetch.side_effect = fake_fetch - result = self.spider.categoryContent("29", "1", False, {}) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertEqual(self.spider.hosts[0], "http://fmao.shop") - - @patch.object(Spider, "_request_html") - def test_search_content_builds_reference_search_url_and_parses_results(self, mock_request_html): - mock_request_html.return_value = """ -
    - 抢先版 -
    - 搜索影片 -
    -
    - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "http://xiaocgege.shop/vodsearch/-------------.html?wd=%E7%B9%81%E8%8A%B1&page=2", - ) - self.assertEqual( - result["list"][0], - { - "vod_id": "/voddetail/789.html", - "vod_name": "搜索影片", - "vod_pic": "http://xiaocgege.shop/search.jpg", - "vod_remarks": "抢先版", - }, - ) - - def test_search_content_returns_empty_list_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - def test_build_pan_lines_deduplicates_and_sorts_supported_links(self): - detail = { - "pan_urls": [ - "https://pan.quark.cn/s/q1", - "https://pan.baidu.com/s/b1", - "https://pan.baidu.com/s/b1", - "https://example.com/ignored", - ] - } - self.assertEqual( - self.spider._build_pan_lines(detail), - [ - ("baidu#蜡笔", "百度资源$https://pan.baidu.com/s/b1"), - ("quark#蜡笔", "夸克资源$https://pan.quark.cn/s/q1"), - ], - ) - - def test_parse_detail_page_extracts_meta_content_and_pan_urls(self): - html = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - detail = self.spider._parse_detail_page("/voddetail/123.html", html) - self.assertEqual(detail["vod_name"], "示例剧") - self.assertEqual(detail["vod_pic"], "http://xiaocgege.shop/poster.jpg") - self.assertEqual(detail["vod_year"], "2024") - self.assertEqual(detail["vod_director"], "导演甲") - self.assertEqual(detail["vod_actor"], "演员甲,演员乙") - self.assertEqual(detail["vod_content"], "一段剧情简介") - self.assertEqual(detail["pan_urls"], ["https://pan.quark.cn/s/q1", "https://pan.baidu.com/s/b1"]) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_pan_play_fields(self, mock_request_html): - mock_request_html.return_value = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - result = self.spider.detailContent(["/voddetail/123.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["vod_play_from"], "baidu#蜡笔$$$quark#蜡笔") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/q1", - ) - - def test_player_content_passthroughs_supported_pan_urls(self): - self.assertEqual( - self.spider.playerContent("baidu#蜡笔", "https://pan.baidu.com/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.baidu.com/s/demo"}, - ) - - def test_player_content_rejects_non_pan_url(self): - self.assertEqual( - self.spider.playerContent("site", "/vodplay/1-1-1.html", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_袋鼠影视.py b/py/tests/test_袋鼠影视.py deleted file mode 100644 index f541212..0000000 --- a/py/tests/test_袋鼠影视.py +++ /dev/null @@ -1,291 +0,0 @@ -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 = """ -
    - -
    - """ - 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 = """ -
    - -
    -
    - 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/tests/test_路漫漫.py b/py/tests/test_路漫漫.py deleted file mode 100644 index 12732f6..0000000 --- a/py/tests/test_路漫漫.py +++ /dev/null @@ -1,229 +0,0 @@ -from base64 import b64encode -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - -from Crypto.Cipher import AES -from Crypto.Util.Padding import pad - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("lumman_spider", str(ROOT / "路漫漫.py")).load_module() -Spider = MODULE.Spider - -SAMPLE_LIST_HTML = """ - - - - -""" - -SAMPLE_DETAIL_HTML = """ - -

    进击的巨人

    -
    -
    状态:已完结
    -
    地区:日本
    -
    人类与巨人的战斗。
    -在线播放 -云播 - -
    - HD -
    - -""" - - -class TestLuManManSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_only_animation_classes(self): - result = self.spider.homeContent(False) - self.assertEqual( - result["class"], - [ - {"type_id": "6", "type_name": "日本动漫"}, - {"type_id": "7", "type_name": "国产动漫"}, - {"type_id": "8", "type_name": "欧美动漫"}, - {"type_id": "3", "type_name": "日本动画电影"}, - {"type_id": "4", "type_name": "国产动画电影"}, - {"type_id": "5", "type_name": "欧美动画电影"}, - ], - ) - - @patch.object(Spider, "_get_html") - def test_home_video_content_parses_cards(self, mock_html): - mock_html.return_value = SAMPLE_LIST_HTML - result = self.spider.homeVideoContent() - self.assertEqual(len(result["list"]), 2) - self.assertEqual(result["list"][0]["vod_id"], "vod/detail/1001.html") - self.assertEqual(result["list"][0]["vod_pic"], "https://www.lmm85.com/upload/1001.jpg") - self.assertEqual(result["list"][1]["vod_pic"], "https://img.example.com/1002.jpg") - - @patch.object(Spider, "_get_html") - def test_category_content_builds_filtered_url(self, mock_html): - mock_html.return_value = SAMPLE_LIST_HTML - result = self.spider.categoryContent("6", "2", False, {"年代": "/year/2024", "排序": "/by/time"}) - self.assertEqual(result["page"], 2) - self.assertEqual(len(result["list"]), 2) - mock_html.assert_called_with("https://www.lmm85.com/vod/show/id/6/year/2024/by/time/page/2.html") - - @patch.object(Spider, "_get_html") - def test_search_content_uses_search_path(self, mock_html): - mock_html.return_value = SAMPLE_LIST_HTML - result = self.spider.searchContent("海贼", False, "3") - self.assertEqual(result["page"], 3) - self.assertEqual(result["list"][0]["vod_name"], "海贼王") - mock_html.assert_called_with("https://www.lmm85.com/vod/search/page/3/wd/%E6%B5%B7%E8%B4%BC.html") - - @patch.object(Spider, "_get_html") - def test_detail_content_parses_meta_and_playlists(self, mock_html): - mock_html.return_value = SAMPLE_DETAIL_HTML - result = self.spider.detailContent(["vod/detail/1001.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "进击的巨人") - self.assertEqual(vod["vod_pic"], "https://www.lmm85.com/upload/jjdr.jpg") - self.assertEqual(vod["vod_content"], "人类与巨人的战斗。") - self.assertEqual(vod["vod_remarks"], "状态:已完结 / 地区:日本") - self.assertEqual(vod["vod_play_from"], "在线播放$$$云播") - self.assertIn("第1集$vod/play/1001-1-1.html#第2集$vod/play/1001-1-2.html", vod["vod_play_url"]) - self.assertIn("HD$vod/play/1001-2-1.html", vod["vod_play_url"]) - - @patch.object(Spider, "_get_html") - def test_detail_content_falls_back_to_direct_playlist_scan(self, mock_html): - mock_html.return_value = """ - -

    测试

    -
    - 正片 -
    - - """ - result = self.spider.detailContent(["vod/detail/1.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_play_from"], "播放列表") - self.assertEqual(vod["vod_play_url"], "正片$vod/play/1-1-1.html") - - def test_decrypt_token_returns_plaintext(self): - key = b"ejjooopppqqqrwww" - iv = b"1348987635684651" - cipher = AES.new(key, AES.MODE_CBC, iv) - token = b64encode(cipher.encrypt(pad(b"plain-token", AES.block_size))).decode("utf-8") - self.assertEqual(self.spider._decrypt_token(token), "plain-token") - - def test_decrypt_token_handles_bad_ciphertext(self): - self.assertEqual(self.spider._decrypt_token("not-valid"), "") - - @patch.object(Spider, "_resolve_player_url") - def test_player_content_returns_direct_media_without_parse(self, mock_resolve): - result = self.spider.playerContent("在线播放", "https://cdn.example.com/video.m3u8", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://cdn.example.com/video.m3u8") - mock_resolve.assert_not_called() - - @patch.object(Spider, "_resolve_player_url") - @patch.object(Spider, "_get_html") - def test_player_content_decodes_encrypt_1_and_encrypt_2(self, mock_html, mock_resolve): - encoded = b64encode("https://cdn.example.com/e2.m3u8".encode("utf-8")).decode("utf-8") - mock_html.side_effect = [ - '', - f'', - ] - mock_resolve.side_effect = ["https://cdn.example.com/e1.m3u8", "https://cdn.example.com/e2.m3u8"] - first = self.spider.playerContent("在线播放", "vod/play/1001-1-1.html", {}) - second = self.spider.playerContent("在线播放", "vod/play/1001-1-2.html", {}) - self.assertEqual(first["url"], "https://cdn.example.com/e1.m3u8") - self.assertEqual(second["url"], "https://cdn.example.com/e2.m3u8") - - @patch.object(Spider, "_resolve_player_url", return_value="") - @patch.object(Spider, "_get_html", return_value="missing player") - def test_player_content_falls_back_to_parse_when_player_missing(self, mock_html, mock_resolve): - result = self.spider.playerContent("在线播放", "vod/play/1001-1-1.html", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["url"], "https://www.lmm85.com/vod/play/1001-1-1.html") - - @patch.object(Spider, "_post_json") - @patch.object(Spider, "_get_html") - def test_resolve_player_url_requests_parser_api_with_decrypted_token(self, mock_html, mock_post_json): - player = {"url": "source-id", "encrypt": "0", "from": "lineA"} - mock_html.side_effect = [ - 'document.querySelector("iframe").src = MacPlayer.Parse + MacPlayer.PlayUrl + "&type=m3u8";', - '', - ] - mock_post_json.return_value = {"url": "https://cdn.example.com/final.m3u8"} - with patch.object(self.spider, "_decrypt_token", return_value="decoded-token") as mock_decrypt: - result = self.spider._resolve_player_url(player, "https://www.lmm85.com/vod/play/1001-1-1.html") - self.assertEqual(result, "https://cdn.example.com/final.m3u8") - mock_decrypt.assert_called_once_with("YxF6M7yw8U7OQcW9t6Qx4w==") - mock_post_json.assert_called_once_with( - "https://www.lmm85.com/api.php", - {"vid": "vid-1", "t": "123", "token": "decoded-token", "act": "play", "play": "1"}, - referer="https://www.lmm85.com", - ) - - @patch.object(Spider, "_post_json", return_value={}) - @patch.object(Spider, "_get_html", return_value='document.querySelector("iframe").src = "";') - def test_resolve_player_url_returns_empty_when_chain_cannot_be_built(self, mock_html, mock_post_json): - result = self.spider._resolve_player_url( - {"url": "vid", "encrypt": "0", "from": "lineA"}, - "https://www.lmm85.com/vod/play/1.html", - ) - self.assertEqual(result, "") - - @patch.object(Spider, "_post_json") - @patch.object(Spider, "_get_html") - def test_resolve_player_url_supports_tudou_yunbox_flow(self, mock_html, mock_post_json): - player = {"url": "DU_token_value&t=DU", "encrypt": "0", "from": "tudou"} - mock_html.side_effect = [ - """ - MacPlayer.Html = ''; - document.getElementById('playerCnt').src = 'https://yun.92cj.com/acfun58.php?id=' + MacPlayer.Parse + MacPlayer.PlayUrl + '&referer='+ window.location.href; - """, - """ - - """, - ] - mock_post_json.return_value = {"url": "https://video.example.com/final.m3u8"} - result = self.spider._resolve_player_url(player, "https://www.lmm85.com/play/7541_2_1.html") - self.assertEqual(result, "https://video.example.com/final.m3u8") - mock_post_json.assert_called_once_with( - "https://yun.92cj.com/yunbox/ducloud.php", - { - "vid": "DU_token_value", - "t": "1777023867", - "token": "659d24991db8fb7e2750e30f7afb3107", - "act": "0", - "play": "1", - }, - referer="https://yun.92cj.com/yunbox/?type=DU&vid=DU_token_value&referer=https://www.lmm85.com/play/7541_2_1.html", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_酷我听书.py b/py/tests/test_酷我听书.py deleted file mode 100644 index 950af84..0000000 --- a/py/tests/test_酷我听书.py +++ /dev/null @@ -1,215 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("kuwo_tingshu_spider", str(ROOT / "酷我听书.py")).load_module() -Spider = MODULE.Spider - - -class TestKuWoTingShuSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_fixed_classes_and_filters(self): - content = self.spider.homeContent(False) - self.assertEqual( - [item["type_id"] for item in content["class"]], - ["2", "37", "5", "62"], - ) - self.assertEqual([item["key"] for item in content["filters"]["2"]], ["class", "vip", "sort"]) - self.assertEqual(content["filters"]["2"][0]["value"][0], {"n": "都市传说", "v": "42"}) - self.assertEqual(content["filters"]["37"][1]["value"][2], {"n": "会员权限", "v": "1"}) - - def test_format_play_count_and_paid_track_helpers(self): - self.assertEqual(self.spider._format_play_count(1234), "1234") - self.assertEqual(self.spider._format_play_count(12000), "1.2万") - self.assertEqual(self.spider._format_play_count(230000000), "2.3亿") - self.assertTrue(self.spider._is_paid_track({"payInfo": {"feeType": {"bookvip": "1"}}})) - self.assertFalse(self.spider._is_paid_track({"payInfo": {"feeType": {"bookvip": "0"}}})) - - def test_parse_search_payload_accepts_single_quotes_json(self): - payload = self.spider._parse_search_payload("{'albumlist':[{'name':'示例'}],'TOTAL':1}") - self.assertEqual(payload["albumlist"][0]["name"], "示例") - self.assertEqual(payload["TOTAL"], 1) - - @patch.object(Spider, "fetch") - def test_api_get_uses_fetch_and_parses_json(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text='{"data":{"data":[{"albumId":1}]}}') - result = self.spider._api_get("/v2/api/search/filter/albums", {"categoryId": "2"}) - self.assertEqual(result["data"]["data"][0]["albumId"], 1) - self.assertEqual(mock_fetch.call_args.args[0], "http://tingshu.kuwo.cn/v2/api/search/filter/albums") - self.assertEqual(mock_fetch.call_args.kwargs["params"]["categoryId"], "2") - self.assertEqual(mock_fetch.call_args.kwargs["headers"]["User-Agent"], "kwplayer_ar_9.1.8.1_tvivo.apk") - - @patch.object(Spider, "_api_get") - def test_home_video_content_aggregates_default_categories(self, mock_api_get): - mock_api_get.side_effect = [ - {"data": {"data": [{"albumId": 1, "albumName": "小说A", "coverImg": "a.jpg", "vip": 0, "playCnt": 3200}]}}, - {"data": {"data": [{"albumId": 2, "albumName": "音乐B", "coverImg": "b.jpg", "vip": 1, "playCnt": 54000}]}}, - {"data": {"data": []}}, - {"data": {"data": [{"albumId": 4, "albumName": "原声D", "coverImg": "d.jpg", "vip": 0, "playCnt": 780000000}]}}, - ] - result = self.spider.homeVideoContent() - self.assertEqual([item["vod_id"] for item in result["list"]], ["1", "2", "4"]) - self.assertEqual(result["list"][0]["vod_remarks"], "免费 | 3200次播放 | 有声小说") - self.assertEqual(result["list"][1]["vod_remarks"], "会员 | 5.4万次播放 | 音乐金曲") - self.assertEqual(result["list"][2]["vod_remarks"], "免费 | 7.8亿次播放 | 影视原声") - first_call_params = mock_api_get.call_args_list[0].args[1] - self.assertEqual(first_call_params["classifyId"], "42") - self.assertEqual(first_call_params["sortType"], "tsScore") - self.assertEqual(first_call_params["rn"], 12) - - @patch.object(Spider, "_api_get") - def test_category_content_skips_empty_pages_and_filters_vip(self, mock_api_get): - mock_api_get.side_effect = [ - {"data": {"data": []}}, - {"data": {"data": [ - {"albumId": 10, "albumName": "免费书", "coverImg": "a.jpg", "vip": 0, "playCnt": 1}, - {"albumId": 11, "albumName": "会员书", "coverImg": "b.jpg", "vip": 1, "playCnt": 2}, - ]}}, - ] - result = self.spider.categoryContent("2", "3", False, {"class": "42", "vip": "0", "sort": "pubDate"}) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 21) - self.assertEqual([item["vod_id"] for item in result["list"]], ["10"]) - self.assertGreaterEqual(result["total"], 1) - self.assertNotIn("pagecount", result) - params = mock_api_get.call_args_list[1].args[1] - self.assertEqual(params["categoryId"], "2") - self.assertEqual(params["classifyId"], "42") - self.assertEqual(params["sortType"], "pubDate") - self.assertEqual(params["pn"], 4) - - @patch.object(Spider, "_api_get") - def test_category_content_returns_empty_result_for_unknown_tid(self, mock_api_get): - result = self.spider.categoryContent("999", "1", False, {}) - self.assertEqual(result, {"page": 1, "limit": 21, "total": 0, "list": []}) - mock_api_get.assert_not_called() - - @patch.object(Spider, "_search_get") - def test_search_content_maps_search_results(self, mock_search_get): - mock_search_get.return_value = { - "albumlist": [ - {"DC_TARGETID": "100", "name": "三体", "img": "a.jpg", "vip": "1", "artist": "播音甲"}, - {"DC_TARGETID": "101", "name": "凡人修仙传", "img": "b.jpg", "vip": "0", "artist": "播音乙"}, - ], - "TOTAL": 25, - } - result = self.spider.searchContent("修仙", False, "2") - self.assertEqual(result["page"], 2) - self.assertEqual(result["limit"], 21) - self.assertEqual(result["total"], 25) - self.assertEqual([item["vod_id"] for item in result["list"]], ["100", "101"]) - self.assertEqual(result["list"][0]["vod_remarks"], "会员 | 播音甲") - - def test_search_content_returns_empty_result_for_blank_keyword(self): - self.assertEqual( - self.spider.searchContent("", False, "1"), - {"page": 1, "limit": 0, "total": 0, "list": []}, - ) - - @patch.object(Spider, "_search_get") - def test_detail_content_maps_album_and_tracks(self, mock_search_get): - mock_search_get.return_value = { - "name": "鬼吹灯", - "img": "20260307/abc.jpg", - "info": "摸金冒险故事", - "artist": "艾宝良", - "company": "酷我出品", - "pub": "2026-03-07", - "lang": "普通话", - "finished": "1", - "vip": "1", - "songnum": "2", - "musiclist": [ - {"name": "第一集", "musicrid": "MUSIC_1", "playcnt": "300", "payInfo": {"feeType": {"bookvip": "0"}}}, - {"name": "第二集", "musicrid": "MUSIC_2", "playcnt": "500", "payInfo": {"feeType": {"bookvip": "1"}}}, - ], - } - result = self.spider.detailContent(["123"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "123") - self.assertEqual(vod["vod_name"], "鬼吹灯") - self.assertEqual(vod["vod_pic"], "http://img3.sycdn.kuwo.cn/star/albumcover/240/20260307/abc.jpg") - self.assertEqual(vod["vod_year"], "2026年") - self.assertEqual(vod["vod_play_from"], "酷我听书") - self.assertEqual(vod["vod_play_url"], "1.第一集$free|MUSIC_1#2.💎第二集$vip|MUSIC_2") - self.assertEqual(vod["vod_remarks"], "会员 | 已完结 | 共2集 | 800播放") - - @patch.object(Spider, "_search_get") - def test_detail_content_paginates_tracks_beyond_2000(self, mock_search_get): - first_page_tracks = [ - {"name": f"第{i}集", "musicrid": f"MUSIC_{i}", "playcnt": "1", "payInfo": {"feeType": {"bookvip": "0"}}} - for i in range(1, 2001) - ] - second_page_tracks = [ - {"name": f"第{i}集", "musicrid": f"MUSIC_{i}", "playcnt": "1", "payInfo": {"feeType": {"bookvip": "0"}}} - for i in range(2001, 2006) - ] - mock_search_get.side_effect = [ - { - "name": "长篇评书", - "img": "cover.jpg", - "info": "超长连载", - "artist": "播音员", - "company": "酷我出品", - "pub": "2026-04-24", - "lang": "普通话", - "finished": "0", - "vip": "0", - "songnum": "2005", - "musiclist": first_page_tracks, - }, - { - "name": "长篇评书", - "songnum": "2005", - "musiclist": second_page_tracks, - }, - ] - - result = self.spider.detailContent(["999"]) - - vod = result["list"][0] - play_items = vod["vod_play_url"].split("#") - self.assertEqual(len(play_items), 2005) - self.assertEqual(play_items[0], "1.第1集$free|MUSIC_1") - self.assertEqual(play_items[-1], "2005.第2005集$free|MUSIC_2005") - self.assertEqual(vod["vod_remarks"], "免费 | 连载中 | 共2005集 | 2005播放") - self.assertEqual(mock_search_get.call_args_list[0].args[1]["pn"], 0) - self.assertEqual(mock_search_get.call_args_list[1].args[1]["pn"], 1) - self.assertEqual(mock_search_get.call_args_list[0].args[1]["rn"], 2000) - self.assertEqual(mock_search_get.call_args_list[1].args[1]["rn"], 2000) - - @patch.object(Spider, "_api_get") - def test_player_content_resolves_free_track(self, mock_api_get): - mock_api_get.return_value = {"data": {"url": "https://audio.example/free.mp3"}} - result = self.spider.playerContent("酷我听书", "free|MUSIC_1", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://audio.example/free.mp3") - self.assertEqual(result["header"]["Referer"], self.spider.api_host) - - @patch.object(Spider, "_api_get") - def test_player_content_resolves_vip_track(self, mock_api_get): - mock_api_get.return_value = {"url": "https://audio.example/vip.mp3"} - result = self.spider.playerContent("酷我听书", "vip|MUSIC_2", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["url"], "https://audio.example/vip.mp3") - self.assertEqual(result["header"]["Referer"], "https://music-api.gdstudio.xyz") - - @patch.object(Spider, "_api_get") - def test_player_content_falls_back_to_generated_urls(self, mock_api_get): - mock_api_get.return_value = {} - free_result = self.spider.playerContent("酷我听书", "free|MUSIC_9", {}) - vip_result = self.spider.playerContent("酷我听书", "vip|MUSIC_8", {}) - self.assertIn("rid=MUSIC_9", free_result["url"]) - self.assertIn("source=kuwo", vip_result["url"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_金牌.py b/py/tests/test_金牌.py deleted file mode 100644 index d9ececa..0000000 --- a/py/tests/test_金牌.py +++ /dev/null @@ -1,214 +0,0 @@ -import hashlib -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("jinpai_spider", str(ROOT / "金牌.py")).load_module() -Spider = MODULE.Spider - - -class TestJinPaiSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - @patch("jinpai_spider.time.time", return_value=1713952212.345) - def test_signed_headers_generates_expected_hashes(self, _mock_time): - headers = self.spider._signed_headers({"pageNum": "1", "type1": "2"}) - expected_t = "1713952212345" - expected_obj = "pageNum=1&type1=2&key=cb808529bae6b6be45ecfab29a4889bc&t=1713952212345" - expected_sign = hashlib.sha1( - hashlib.md5(expected_obj.encode("utf-8")).hexdigest().encode("utf-8") - ).hexdigest() - self.assertEqual(headers["t"], expected_t) - self.assertEqual(headers["sign"], expected_sign) - self.assertEqual(headers["Referer"], "https://m.jiabaide.cn/") - - @patch.object(Spider, "_fetch_json") - def test_home_content_returns_class_filters_and_hot_list(self, mock_fetch_json): - mock_fetch_json.side_effect = [ - {"data": [{"typeId": 1, "typeName": "电影"}, {"typeId": 2, "typeName": "剧集"}]}, - { - "data": { - "1": { - "typeList": [{"itemText": "动作", "itemValue": "dongzuo"}], - "plotList": [{"itemText": "剧情"}], - "districtList": [{"itemText": "中国香港"}], - "languageList": [{"itemText": "粤语"}], - "yearList": [{"itemText": "2026"}], - } - } - }, - { - "data": [ - { - "vodId": 9, - "vodName": "热播片", - "vodPic": "https://img/9.jpg", - "vodRemarks": "更新至10集", - "vodDoubanScore": "8.6", - "vodPubdate": "2026-01-01", - "typeId": 1, - "typeName": "电影", - } - ] - }, - ] - result = self.spider.homeContent(False) - self.assertEqual( - result["class"], - [{"type_id": "1", "type_name": "电影"}, {"type_id": "2", "type_name": "剧集"}], - ) - self.assertEqual(result["filters"]["1"][0]["key"], "type") - self.assertEqual(result["filters"]["1"][0]["value"][0], {"n": "全部", "v": ""}) - self.assertEqual(result["filters"]["1"][0]["value"][1], {"n": "动作", "v": "dongzuo"}) - self.assertEqual(result["filters"]["1"][-1]["key"], "by") - self.assertEqual(result["list"][0]["vod_remarks"], "更新至10集_8.6") - self.assertEqual(result["list"][0]["vod_year"], "2026") - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - @patch.object(Spider, "_fetch_json") - def test_category_content_maps_filters_to_api_payload(self, mock_fetch_json): - mock_fetch_json.return_value = { - "data": { - "list": [ - { - "vodId": 11, - "vodName": "分类片", - "vodPic": "https://img/11.jpg", - "vodRemarks": "完结", - "vodDoubanScore": "7.1", - "vodPubdate": "2025-09-09", - "typeId": 1, - "typeName": "电影", - } - ] - } - } - result = self.spider.categoryContent( - "1", - "2", - False, - {"area": "中国香港", "lang": "粤语", "year": "2025", "by": "4", "type": "dongzuo", "class": "剧情"}, - ) - self.assertEqual( - mock_fetch_json.call_args.args, - ( - "/api/mw-movie/anonymous/video/list", - { - "area": "中国香港", - "lang": "粤语", - "pageNum": "2", - "pageSize": "30", - "sort": "4", - "sortBy": "1", - "type": "dongzuo", - "type1": "1", - "v_class": "剧情", - "year": "2025", - }, - ), - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["limit"], 30) - self.assertEqual(result["list"][0]["vod_id"], "11") - self.assertNotIn("pagecount", result) - - def test_search_content_returns_empty_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "limit": 30, "total": 0, "list": []}) - - @patch.object(Spider, "_fetch_json") - def test_search_content_maps_keyword_and_page(self, mock_fetch_json): - mock_fetch_json.return_value = { - "data": { - "list": [ - { - "vodId": 21, - "vodName": "搜索片", - "vodPic": "https://img/21.jpg", - "vodRemarks": "更新", - "vodDoubanScore": "8.0", - "vodPubdate": "2024-01-01", - "typeId": 2, - "typeName": "剧集", - } - ] - } - } - result = self.spider.searchContent("繁花", False, "3") - self.assertEqual( - mock_fetch_json.call_args.args, - ( - "/api/mw-movie/anonymous/video/searchByWordPageable", - {"keyword": "繁花", "pageNum": "3", "pageSize": "30"}, - ), - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["list"][0]["vod_name"], "搜索片") - self.assertEqual(result["list"][0]["vod_year"], "2024") - - @patch.object(Spider, "_fetch_json") - def test_detail_content_maps_metadata_and_episode_ids(self, mock_fetch_json): - mock_fetch_json.return_value = { - "data": { - "vodId": 88, - "vodName": "详情片", - "vodPic": "https://img/88.jpg", - "vodClass": "动作", - "vodRemarks": "更新至12集", - "vodYear": "2026", - "vodArea": "中国香港", - "vodLang": "粤语", - "vodDirector": "导演甲", - "vodActor": "演员甲/演员乙", - "vodContent": "一段简介", - "episodeList": [{"name": "第1集", "nid": 9001}, {"name": "第2集", "nid": 9002}], - } - } - result = self.spider.detailContent(["88"]) - vod = result["list"][0] - self.assertEqual(mock_fetch_json.call_args.args, ("/api/mw-movie/anonymous/video/detail", {"id": "88"})) - self.assertEqual(vod["vod_id"], "88") - self.assertEqual(vod["vod_name"], "详情片") - self.assertEqual(vod["vod_play_from"], "金牌线路") - self.assertEqual(vod["vod_play_url"], "第1集$88@9001#第2集$88@9002") - - def test_detail_content_returns_empty_for_blank_id(self): - self.assertEqual(self.spider.detailContent([""]), {"list": []}) - - @patch.object(Spider, "_fetch_json") - def test_player_content_returns_first_episode_url_with_headers(self, mock_fetch_json): - mock_fetch_json.return_value = { - "data": { - "list": [ - {"name": "1080P", "url": "https://cdn.example/1080.m3u8"}, - {"name": "720P", "url": "https://cdn.example/720.m3u8"}, - ] - } - } - result = self.spider.playerContent("金牌线路", "88@9001", {}) - self.assertEqual( - mock_fetch_json.call_args.args, - ("/api/mw-movie/anonymous/v2/video/episode/url", {"clientType": "3", "id": "88", "nid": "9001"}), - ) - self.assertEqual( - result, - { - "parse": 0, - "playUrl": "", - "url": "https://cdn.example/1080.m3u8", - "header": {"User-Agent": self.spider.mobile_ua}, - }, - ) - - def test_player_content_rejects_broken_play_id(self): - self.assertEqual( - self.spider.playerContent("金牌线路", "broken", {}), - {"parse": 0, "playUrl": "", "url": "", "header": {}}, - ) diff --git a/py/tests/test_闪电.py b/py/tests/test_闪电.py deleted file mode 100644 index 5cc2b79..0000000 --- a/py/tests/test_闪电.py +++ /dev/null @@ -1,240 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("shandian_spider", str(ROOT / "闪电.py")).load_module() -Spider = MODULE.Spider - - -class TestShanDianSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_exposes_all_categories(self): - content = self.spider.homeContent(False) - self.assertEqual( - [(item["type_id"], item["type_name"]) for item in content["class"]], - [ - ("1", "闪电电影"), - ("2", "闪电剧集"), - ("3", "闪电综艺"), - ("4", "闪电动漫"), - ("30", "闪电短剧"), - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_url_and_detect_pan_type(self): - self.assertEqual( - self.spider._build_url("/index.php/vod/detail/id/1.html"), - "https://sd.sduc.site/index.php/vod/detail/id/1.html", - ) - self.assertEqual(self.spider._detect_pan_type("https://pan.baidu.com/s/demo"), ("baidu", "百度资源")) - self.assertEqual(self.spider._detect_pan_type("https://pan.quark.cn/s/demo"), ("quark", "夸克资源")) - self.assertEqual(self.spider._detect_pan_type("https://example.com/video"), ("", "")) - - def test_parse_cards_extracts_short_path_ids(self): - html = """ -
    -
    -
    - - 示例影片 -
    -
    HD
    -
    2025
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/index.php/vod/detail/id/123.html", - "vod_name": "示例影片", - "vod_pic": "https://sd.sduc.site/poster.jpg", - "vod_remarks": "HD", - "vod_year": "2025", - } - ], - ) - - def test_parse_cards_ignores_module_items_container_and_uses_first_card_cover(self): - html = """ -
    -
    -
    -
    - - 示例影片A -
    -
    HD
    -
    2025
    -
    -
    -
    - - 示例影片B -
    -
    更新至10集
    -
    2024
    -
    -
    -
    - """ - self.assertEqual( - self.spider._parse_cards(html), - [ - { - "vod_id": "/index.php/vod/detail/id/123.html", - "vod_name": "示例影片A", - "vod_pic": "https://sd.sduc.site/poster-a.jpg", - "vod_remarks": "HD", - "vod_year": "2025", - }, - { - "vod_id": "/index.php/vod/detail/id/456.html", - "vod_name": "示例影片B", - "vod_pic": "https://sd.sduc.site/poster-b.jpg", - "vod_remarks": "更新至10集", - "vod_year": "2024", - }, - ], - ) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_reference_url_and_returns_page_payload(self, mock_request_html): - mock_request_html.return_value = """ -
    -
    -
    - - 分类影片 -
    -
    更新至10集
    -
    2024
    -
    -
    - """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://sd.sduc.site/index.php/vod/show/id/2/page/3.html", - ) - self.assertEqual(result["page"], 3) - self.assertEqual(result["limit"], 1) - self.assertEqual(result["list"][0]["vod_name"], "分类影片") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_builds_reference_search_url_and_parses_results(self, mock_request_html): - mock_request_html.return_value = """ -
    - 抢先版 -
    - 搜索影片 -
    -
    - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://sd.sduc.site/index.php/vod/search/page/2/wd/%E7%B9%81%E8%8A%B1.html", - ) - self.assertEqual( - result["list"][0], - { - "vod_id": "/index.php/vod/detail/id/789.html", - "vod_name": "搜索影片", - "vod_pic": "https://sd.sduc.site/search.jpg", - "vod_remarks": "抢先版", - }, - ) - - def test_search_content_returns_empty_list_for_blank_keyword(self): - self.assertEqual(self.spider.searchContent("", False, "1"), {"page": 1, "total": 0, "list": []}) - - def test_build_pan_lines_deduplicates_and_sorts_supported_links(self): - detail = { - "pan_urls": [ - "https://pan.quark.cn/s/q1", - "https://pan.baidu.com/s/b1", - "https://pan.baidu.com/s/b1", - "https://example.com/ignored", - ] - } - self.assertEqual( - self.spider._build_pan_lines(detail), - [ - ("baidu#闪电", "百度资源$https://pan.baidu.com/s/b1"), - ("quark#闪电", "夸克资源$https://pan.quark.cn/s/q1"), - ], - ) - - def test_parse_detail_page_extracts_meta_content_and_pan_urls(self): - html = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - detail = self.spider._parse_detail_page("/index.php/vod/detail/id/123.html", html) - self.assertEqual(detail["vod_name"], "示例剧") - self.assertEqual(detail["vod_pic"], "https://sd.sduc.site/poster.jpg") - self.assertEqual(detail["vod_year"], "2024") - self.assertEqual(detail["vod_director"], "导演甲") - self.assertEqual(detail["vod_actor"], "演员甲,演员乙") - self.assertEqual(detail["vod_content"], "一段剧情简介") - self.assertEqual(detail["pan_urls"], ["https://pan.quark.cn/s/q1", "https://pan.baidu.com/s/b1"]) - - @patch.object(Spider, "_request_html") - def test_detail_content_builds_pan_play_fields(self, mock_request_html): - mock_request_html.return_value = """ -
    示例剧
    -
    -
    年代
    -
    导演
    -
    主演
    -
    剧情

    一段剧情简介

    -
    -

    https://pan.quark.cn/s/q1

    -

    https://pan.baidu.com/s/b1

    -
    - """ - result = self.spider.detailContent(["/index.php/vod/detail/id/123.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_name"], "示例剧") - self.assertEqual(vod["vod_play_from"], "baidu#闪电$$$quark#闪电") - self.assertEqual( - vod["vod_play_url"], - "百度资源$https://pan.baidu.com/s/b1$$$夸克资源$https://pan.quark.cn/s/q1", - ) - - def test_player_content_passthroughs_supported_pan_urls(self): - self.assertEqual( - self.spider.playerContent("baidu#闪电", "https://pan.baidu.com/s/demo", {}), - {"parse": 0, "playUrl": "", "url": "https://pan.baidu.com/s/demo"}, - ) - - def test_player_content_rejects_non_pan_url(self): - self.assertEqual( - self.spider.playerContent("site", "/index.php/vod/play/id/1.html", {}), - {"parse": 0, "playUrl": "", "url": ""}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/py/tests/test_飞快TV.py b/py/tests/test_飞快TV.py deleted file mode 100644 index 6fc2869..0000000 --- a/py/tests/test_飞快TV.py +++ /dev/null @@ -1,162 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("feikuai_spider", str(ROOT / "飞快TV.py")).load_module() -Spider = MODULE.Spider - - -class TestFeikuaiSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_expected_classes(self): - result = self.spider.homeContent(False) - self.assertEqual( - result["class"], - [ - {"type_id": "1", "type_name": "电影"}, - {"type_id": "2", "type_name": "剧集"}, - {"type_id": "3", "type_name": "综艺"}, - {"type_id": "4", "type_name": "动漫"}, - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - @patch.object(Spider, "_request_html") - def test_category_content_parses_short_vod_id(self, mock_request_html): - mock_request_html.return_value = """ - - -
    更新至10集
    -
    - """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://feikuai.tv/vodshow/2--------3---.html", - ) - self.assertEqual( - result["list"], - [ - { - "vod_id": "/voddetail/12345.html", - "vod_name": "分类影片", - "vod_pic": "https://feikuai.tv/cover.jpg", - "vod_remarks": "更新至10集", - } - ], - ) - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_parses_cards_and_blank_keyword(self, mock_request_html): - blank = self.spider.searchContent("", False, "1") - self.assertEqual(blank, {"page": 1, "limit": 0, "total": 0, "list": []}) - mock_request_html.assert_not_called() - - mock_request_html.return_value = """ -
    - -
    -
    搜索命中
    -
    HD
    -
    - """ - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual( - mock_request_html.call_args.args[0], - "https://feikuai.tv/label/search_ajax.html?wd=%E7%B9%81%E8%8A%B1&by=time&order=desc&page=2", - ) - self.assertEqual(result["list"][0]["vod_id"], "/voddetail/67890.html") - - @patch.object(Spider, "_request_html") - def test_detail_content_merges_online_and_pan_groups(self, mock_request_html): - mock_request_html.return_value = """ -

    示例影片

    -
    -
    这里是简介
    -
    -
    线路A
    -
    线路B
    -
    - -
    - 第1集 -
    -
    -
    -

    夸克资源@分享一

    -

    https://pan.quark.cn/s/demo1

    -
    -
    -

    百度合集@分享二

    -

    https://pan.baidu.com/s/demo2

    -
    -
    - """ - result = self.spider.detailContent(["/voddetail/1.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "/voddetail/1.html") - self.assertEqual(vod["vod_name"], "示例影片") - self.assertEqual(vod["vod_pic"], "https://feikuai.tv/detail.jpg") - self.assertEqual(vod["vod_content"], "这里是简介") - self.assertEqual(vod["vod_play_from"], "线路A$$$线路B$$$quark$$$baidu") - self.assertEqual( - vod["vod_play_url"], - "第1集$/vodplay/1-1-1.html#第2集$/vodplay/1-1-2.html$$$第1集$/vodplay/1-2-1.html$$$夸克资源$https://pan.quark.cn/s/demo1$$$百度合集$https://pan.baidu.com/s/demo2", - ) - - def test_base64decode_decodes_fixture(self): - self.assertEqual( - self.spider._base64decode("aHR0cHM6Ly9jZG4uZXhhbXBsZS5jb20vdjIubTN1OA=="), - "https://cdn.example.com/v2.m3u8", - ) - - @patch.object(Spider, "_request_html") - def test_player_content_supports_encrypt_1_and_encrypt_2(self, mock_request_html): - mock_request_html.side_effect = [ - '', - '', - ] - direct = self.spider.playerContent("feikuai", "/vodplay/1-1-1.html", {}) - encoded = self.spider.playerContent("feikuai", "/vodplay/1-1-2.html", {}) - self.assertEqual(direct["parse"], 0) - self.assertEqual(direct["url"], "https://cdn.example.com/v1.m3u8") - self.assertEqual(encoded["parse"], 0) - self.assertEqual(encoded["url"], "https://cdn.example.com/v2.m3u8") - - @patch.object(Spider, "_request_html") - def test_player_content_handles_live_nested_player_payload(self, mock_request_html): - mock_request_html.return_value = """ - - """ - result = self.spider.playerContent("feikuai", "/vodplay/236443-1-1.html", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://cdn.yzzyvip-29.com/20260425/23291_45967c73/index.m3u8") - - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_when_script_missing(self, mock_request_html): - mock_request_html.return_value = "empty" - result = self.spider.playerContent("feikuai", "/vodplay/1-1-3.html", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://feikuai.tv/vodplay/1-1-3.html") diff --git a/py/tests/test_魔方APP.py b/py/tests/test_魔方APP.py deleted file mode 100644 index 4533b60..0000000 --- a/py/tests/test_魔方APP.py +++ /dev/null @@ -1,270 +0,0 @@ -import unittest -from datetime import datetime -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("mofang_spider", str(ROOT / "魔方APP.py")).load_module() -Spider = MODULE.Spider - - -class TestMoFangAppSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_api_url_uses_default_getappapi_path(self): - self.assertEqual( - self.spider._build_api_url("initV119"), - "http://www.613mf4k12.top/api.php/getappapi.index/initV119", - ) - - def test_aes_encrypt_and_decrypt_round_trip(self): - encrypted = self.spider._aes_encrypt('{"msg":"ok"}') - self.assertNotEqual(encrypted, '{"msg":"ok"}') - self.assertEqual(self.spider._aes_decrypt(encrypted), '{"msg":"ok"}') - - def test_replace_code_normalizes_common_ocr_misreads(self): - self.assertEqual(self.spider._replace_code("5y6口"), "5960") - - @patch.object(Spider, "post") - def test_api_post_returns_none_when_upstream_payload_is_not_json(self, mock_post): - mock_post.return_value = SimpleNamespace(text="blocked") - self.assertIsNone(self.spider._api_post("initV119")) - - @patch.object(Spider, "_api_post") - def test_home_content_filters_categories_merges_area_and_inserts_current_year(self, mock_api_post): - mock_api_post.return_value = { - "type_list": [ - { - "type_id": "0", - "type_name": "全部", - "recommend_list": [], - "filter_type_list": [], - }, - { - "type_id": "3", - "type_name": "综艺", - "recommend_list": [{"vod_id": "z1"}], - "filter_type_list": [ - {"name": "area", "list": ["全部", "中国大陆", "香港"]}, - {"name": "year", "list": ["全部", "2025", "2024"]}, - {"name": "sort", "list": ["最新", "最热"]}, - ], - }, - { - "type_id": "1", - "type_name": "电影", - "recommend_list": [{"vod_id": "m1"}], - "filter_type_list": [], - }, - ], - "config": {}, - } - content = self.spider.homeContent(False) - self.assertEqual([item["type_name"] for item in content["class"]], ["电影", "综艺"]) - self.assertEqual(content["list"], [{"vod_id": "z1"}, {"vod_id": "m1"}]) - area_values = content["filters"]["3"][0]["value"] - year_values = content["filters"]["3"][1]["value"] - self.assertEqual(area_values[1], {"n": "大陆", "v": "大陆"}) - self.assertIn({"n": str(datetime.now().year), "v": str(datetime.now().year)}, year_values) - self.assertEqual(content["filters"]["3"][2]["key"], "by") - - @patch.object(Spider, "_api_post") - def test_category_content_maps_by_to_sort_and_omits_pagecount(self, mock_api_post): - mock_api_post.return_value = {"recommend_list": [{"vod_id": "movie-1", "vod_name": "分类片"}]} - result = self.spider.categoryContent( - "1", - "2", - False, - {"area": "香港", "year": "2025", "by": "最热", "lang": "粤语", "class": "动作"}, - ) - self.assertEqual( - mock_api_post.call_args.args, - ( - "typeFilterVodList", - { - "type_id": "1", - "page": "2", - "area": "香港", - "year": "2025", - "sort": "最热", - "lang": "粤语", - "class": "动作", - }, - ), - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "movie-1") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_api_post") - def test_category_content_merges_mainland_results_and_deduplicates(self, mock_api_post): - mock_api_post.side_effect = [ - {"recommend_list": [{"vod_id": "a"}, {"vod_id": "b"}]}, - {"recommend_list": [{"vod_id": "b"}, {"vod_id": "c"}]}, - {"recommend_list": [{"vod_id": "c"}, {"vod_id": "d"}]}, - ] - result = self.spider.categoryContent("1", "1", False, {"area": "大陆"}) - self.assertEqual([item["vod_id"] for item in result["list"]], ["a", "b", "c", "d"]) - self.assertEqual( - [call.args[1]["area"] for call in mock_api_post.call_args_list], - ["中国大陆", "大陆", "内地"], - ) - - @patch.object(Spider, "_api_post") - def test_search_content_filters_reserved_category_and_non_matching_items(self, mock_api_post): - mock_api_post.return_value = { - "search_list": [ - {"vod_id": "1", "vod_name": "繁花", "vod_pic": "p1", "vod_year": "2024", "vod_class": "剧情"}, - {"vod_id": "2", "vod_name": "别的片", "vod_pic": "p2", "vod_year": "2024", "vod_class": "屏蔽预留"}, - {"vod_id": "3", "vod_name": "完全无关", "vod_pic": "p3", "vod_year": "2023", "vod_class": "动作"}, - ] - } - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual( - result["list"], - [{"vod_id": "1", "vod_name": "繁花", "vod_pic": "p1", "vod_remarks": "2024 剧情"}], - ) - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_get_verification_code") - @patch.object(Spider, "_api_post") - def test_search_content_returns_empty_list_when_verify_required_but_ocr_fails(self, mock_api_post, mock_verify): - mock_api_post.return_value = {"config": {"system_search_verify_status": 1}, "type_list": []} - mock_verify.return_value = None - self.spider.search_verify = True - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual(result["list"], []) - self.assertEqual(result.get("msg"), "验证码获取失败") - - @patch.object(Spider, "_api_post") - def test_detail_content_falls_back_to_second_endpoint_sorts_lines_and_blocks_disabled_lines(self, mock_api_post): - mock_api_post.side_effect = [ - None, - { - "vod": { - "vod_name": "示例影片", - "vod_pic": "poster.jpg", - "vod_remarks": "更新至10集", - "vod_content": "一段剧情", - "vod_actor": "演员甲/演员乙", - "vod_director": "导演甲", - "vod_year": "2025", - "vod_area": "中国大陆", - }, - "vod_play_list": [ - { - "player_info": { - "show": "腾讯视频", - "parse": "https://parser-a/?url=", - "player_parse_type": "1", - "parse_type": "1", - }, - "urls": [{"name": "正片", "url": "https%3A%2F%2Fplay-a", "token": "tk-a"}], - }, - { - "player_info": { - "show": "防走丢加群", - "parse": "https://parser-b/?url=", - "player_parse_type": "1", - "parse_type": "0", - }, - "urls": [{"name": "备用", "url": "https%3A%2F%2Fplay-b", "token": "tk-b"}], - }, - { - "player_info": { - "show": "YY", - "parse": "https://parser-c/?url=", - "player_parse_type": "2", - "parse_type": "2", - }, - "urls": [{"name": "蓝光", "url": "https%3A%2F%2Fplay-c", "token": "tk-c"}], - }, - ], - }, - ] - result = self.spider.detailContent(["123"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "123") - self.assertEqual(vod["vod_name"], "示例影片") - self.assertEqual(vod["vod_year"], "2025年") - self.assertEqual(vod["vod_play_from"], "⭐️腾讯视频$$$1线") - self.assertEqual( - vod["vod_play_url"], - "正片$腾讯视频@@direct@@https://parser-a/?url=,https%3A%2F%2Fplay-a,token+tk-a,1,1" - "$$$备用$1线@@direct@@https://parser-b/?url=,https%3A%2F%2Fplay-b,token+tk-b,1,0", - ) - - @patch.object(Spider, "fetch") - def test_get_verification_code_fetches_image_calls_ocr_and_returns_digits(self, mock_fetch): - image_response = SimpleNamespace(content=b"fake-image") - ocr_response = SimpleNamespace(text="5y6口") - mock_fetch.side_effect = [image_response, ocr_response] - result = self.spider._get_verification_code() - self.assertEqual(result["code"], "5960") - self.assertIn("uuid", result) - - def test_player_content_returns_direct_url_for_parse_type_zero(self): - result = self.spider.playerContent( - "⭐️腾讯视频", - "腾讯视频@@direct@@https://parser/?url=,https%3A%2F%2Fvideo.example%2Fraw.m3u8,token+abc,1,0", - "", - ) - self.assertEqual( - result, - { - "parse": 0, - "jx": 0, - "url": "https://video.example/raw.m3u8", - "header": {"User-Agent": "Dalvik/2.1.0 (Linux; Android 14)"}, - }, - ) - - def test_player_content_returns_parser_url_for_parse_type_two(self): - result = self.spider.playerContent( - "⭐️腾讯视频", - "腾讯视频@@direct@@https://parser/?url=,https%3A%2F%2Fvideo.example%2Fneed-jx.m3u8,token+abc,1,2", - "", - ) - self.assertEqual( - result, - { - "parse": 1, - "jx": 1, - "url": "https://parser/?url=https://video.example/need-jx.m3u8", - "header": {"User-Agent": "Dalvik/2.1.0 (Linux; Android 14)"}, - }, - ) - - @patch.object(Spider, "fetch") - def test_player_content_uses_player_parse_type_two_direct_json(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(text='{"url":"https://video.example/direct.m3u8"}') - result = self.spider.playerContent( - "⭐️腾讯视频", - "腾讯视频@@direct@@https://parser/?url=,https%3A%2F%2Fvideo.example%2Fwrapped,token+abc,2,1", - "", - ) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://video.example/direct.m3u8") - - @patch.object(Spider, "_api_post") - def test_player_content_falls_back_to_vod_parse(self, mock_api_post): - mock_api_post.return_value = {"json": '{"url":"https://video.example/final.m3u8"}'} - result = self.spider.playerContent( - "⭐️腾讯视频", - "腾讯视频@@direct@@https://parser/?url=,https%3A%2F%2Fvideo.example%2Fencrypted,token+abc,1,1", - "", - ) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://video.example/final.m3u8") - self.assertEqual(mock_api_post.call_args.args[0], "vodParse") diff --git a/py/tests/test_麦田影院.py b/py/tests/test_麦田影院.py deleted file mode 100644 index b3cb370..0000000 --- a/py/tests/test_麦田影院.py +++ /dev/null @@ -1,191 +0,0 @@ -import unittest -from importlib.machinery import SourceFileLoader -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("maitian_spider", str(ROOT / "麦田影院.py")).load_module() -Spider = MODULE.Spider - - -class TestMaiTianSpider(unittest.TestCase): - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_content_returns_expected_classes(self): - result = self.spider.homeContent(False) - self.assertEqual( - result["class"], - [ - {"type_id": "1", "type_name": "电影"}, - {"type_id": "2", "type_name": "电视剧"}, - {"type_id": "4", "type_name": "动漫"}, - {"type_id": "3", "type_name": "综艺"}, - {"type_id": "26", "type_name": "短剧"}, - {"type_id": "25", "type_name": "少儿"}, - ], - ) - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - @patch.object(Spider, "_request_html") - def test_category_content_builds_url_and_parses_cards(self, mock_request_html): - mock_request_html.return_value = """ -
    - - - - 更新至10集 -
    - """ - result = self.spider.categoryContent("2", "3", False, {}) - self.assertEqual( - mock_request_html.call_args.args[0], - "https://www.mtyy5.com/vodshow/2--------3---.html", - ) - self.assertEqual( - result["list"], - [ - { - "vod_id": "/voddetail/123.html", - "vod_name": "分类影片", - "vod_pic": "https://www.mtyy5.com/cover.jpg", - "vod_remarks": "更新至10集", - } - ], - ) - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_request_html") - def test_search_content_returns_empty_result_without_network(self, mock_request_html): - result = self.spider.searchContent("繁花", False, "2") - self.assertEqual(result, {"page": 2, "limit": 0, "total": 0, "list": []}) - mock_request_html.assert_not_called() - - @patch.object(Spider, "_request_html") - def test_detail_content_parses_meta_and_play_groups(self, mock_request_html): - mock_request_html.return_value = """ - -

    示例影片

    -
    -
    这里是简介
    - 线路1 - 线路2 -
    - -
    -
    - -
    - - """ - result = self.spider.detailContent(["/voddetail/1.html"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "/voddetail/1.html") - self.assertEqual(vod["vod_name"], "示例影片") - self.assertEqual(vod["vod_pic"], "https://www.mtyy5.com/detail.jpg") - self.assertEqual(vod["vod_content"], "这里是简介") - self.assertEqual(vod["vod_play_from"], "线路$$$线路") - self.assertEqual( - vod["vod_play_url"], - "第1集$/vodplay/1-1-1.html#第2集$/vodplay/1-1-2.html$$$正片$/vodplay/1-2-1.html", - ) - - @patch.object(Spider, "_request_html") - def test_detail_content_extracts_meta_and_keeps_intro_clean(self, mock_request_html): - mock_request_html.return_value = """ - -

    示例影片

    -
    -
      -
    • 名称: 示例影片
    • -
    • 类型: 剧情
    • -
    • 年代: 2026
    • -
    • 地区: 大陆
    • -
    • 语言: 国语
    • -
    • 评分: 8.8
    • -
    • 导演: 导演甲
    • -
    • 演员: 演员甲 / 演员乙
    • -
    • 豆瓣ID: 1234567
    • -
    -
    -
    这里是正确简介
    -
    - MT源6 -
    -
    -
    - -
    -
    - -
    - - """ - result = self.spider.detailContent(["/voddetail/2.html"]) - vod = result["list"][0] - self.assertEqual(vod["type_name"], "剧情") - self.assertEqual(vod["vod_year"], "2026") - self.assertEqual(vod["vod_area"], "大陆") - self.assertEqual(vod["vod_lang"], "国语") - self.assertEqual(vod["vod_douban_score"], "8.8") - self.assertEqual(vod["vod_director"], "导演甲") - self.assertEqual(vod["vod_actor"], "演员甲 / 演员乙") - self.assertEqual(vod["vod_douban_id"], "1234567") - self.assertEqual(vod["vod_content"], "这里是正确简介") - - @patch.object(Spider, "_request_json") - @patch.object(Spider, "_request_html") - def test_player_content_returns_direct_url(self, mock_request_html, mock_request_json): - mock_request_html.return_value = """ - - """ - result = self.spider.playerContent("线路", "/vodplay/1-1-1.html", {}) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://cdn.example.com/direct.m3u8") - mock_request_json.assert_not_called() - - @patch.object(Spider, "_request_json") - @patch.object(Spider, "_request_html") - def test_player_content_resolves_signed_art_url(self, mock_request_html, mock_request_json): - mock_request_html.return_value = """ - - """ - mock_request_json.side_effect = [ - {"signed_url": "?url=/signed/abc"}, - {"jmurl": "https://cdn.example.com/final.m3u8"}, - ] - result = self.spider.playerContent("线路", "/vodplay/1-1-2.html", {}) - self.assertEqual(result["url"], "https://cdn.example.com/final.m3u8") - self.assertEqual( - mock_request_json.call_args_list[0].args[0], - "https://www.mtyy5.com/static/player/art.php?get_signed_url=1&url=/api.php?id=1", - ) - self.assertEqual( - mock_request_json.call_args_list[1].args[0], - "https://www.mtyy5.com/static/player/art.php?url=/signed/abc", - ) - - @patch.object(Spider, "_request_json") - @patch.object(Spider, "_request_html") - def test_player_content_falls_back_when_player_data_missing(self, mock_request_html, mock_request_json): - mock_request_html.return_value = "empty" - result = self.spider.playerContent("线路", "/vodplay/1-1-3.html", {}) - self.assertEqual(result["parse"], 1) - self.assertEqual(result["jx"], 1) - self.assertEqual(result["url"], "https://www.mtyy5.com/vodplay/1-1-3.html") - mock_request_json.assert_not_called() diff --git a/py/tests/test_黑猫APP.py b/py/tests/test_黑猫APP.py deleted file mode 100644 index 9ce30b0..0000000 --- a/py/tests/test_黑猫APP.py +++ /dev/null @@ -1,261 +0,0 @@ -import unittest -from datetime import datetime -from importlib.machinery import SourceFileLoader -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = SourceFileLoader("heimao_spider", str(ROOT / "黑猫APP.py")).load_module() -Spider = MODULE.Spider - - -class TestHeiMaoAppSpider(unittest.TestCase): - def test_module_uses_crypto_aes(self): - source = (ROOT / "黑猫APP.py").read_text(encoding="utf-8") - self.assertIn("from Crypto.Cipher import AES", source) - self.assertIn("from Crypto.Util.Padding import pad, unpad", source) - self.assertNotIn("from cryptography", source) - - def setUp(self): - Spider._instance = None - self.spider = Spider() - self.spider.init() - - def test_home_video_content_returns_empty_list(self): - self.assertEqual(self.spider.homeVideoContent(), {"list": []}) - - def test_build_api_url_uses_default_getappapi_path(self): - self.assertEqual( - self.spider._build_api_url("initV119"), - "http://app1-0-0.87333.cc/api.php/getappapi.index/initV119", - ) - - def test_aes_encrypt_and_decrypt_round_trip(self): - encrypted = self.spider._aes_encrypt('{"msg":"ok"}') - self.assertNotEqual(encrypted, '{"msg":"ok"}') - self.assertEqual(self.spider._aes_decrypt(encrypted), '{"msg":"ok"}') - - def test_replace_code_normalizes_common_ocr_misreads(self): - self.assertEqual(self.spider._replace_code("5y6口"), "5960") - - @patch.object(Spider, "_api_post") - def test_home_content_filters_categories_merges_area_and_inserts_current_year(self, mock_api_post): - mock_api_post.return_value = { - "type_list": [ - { - "type_id": "6", - "type_name": "伦理", - "recommend_list": [], - "filter_type_list": [], - }, - { - "type_id": "3", - "type_name": "综艺", - "recommend_list": [{"vod_id": "z1"}], - "filter_type_list": [ - {"name": "area", "list": ["全部", "中国大陆", "香港"]}, - {"name": "year", "list": ["全部", "2025", "2024"]}, - {"name": "sort", "list": ["最新", "最热"]}, - ], - }, - { - "type_id": "1", - "type_name": "电影", - "recommend_list": [{"vod_id": "m1"}], - "filter_type_list": [], - }, - ], - "config": {}, - } - content = self.spider.homeContent(False) - self.assertEqual([item["type_name"] for item in content["class"]], ["电影", "综艺"]) - self.assertEqual(content["list"], [{"vod_id": "z1"}, {"vod_id": "m1"}]) - area_values = content["filters"]["3"][0]["value"] - year_values = content["filters"]["3"][1]["value"] - self.assertEqual(area_values[1], {"n": "大陆", "v": "大陆"}) - self.assertIn({"n": str(datetime.now().year), "v": str(datetime.now().year)}, year_values) - self.assertEqual(content["filters"]["3"][2]["key"], "by") - - @patch.object(Spider, "_api_post") - def test_category_content_maps_by_to_sort_and_omits_pagecount(self, mock_api_post): - mock_api_post.return_value = { - "recommend_list": [{"vod_id": "movie-1", "vod_name": "分类片"}] - } - result = self.spider.categoryContent( - "1", - "2", - False, - {"area": "香港", "year": "2025", "by": "最热", "lang": "粤语", "class": "动作"}, - ) - self.assertEqual( - mock_api_post.call_args.args, - ( - "typeFilterVodList", - { - "type_id": "1", - "page": "2", - "area": "香港", - "year": "2025", - "sort": "最热", - "lang": "粤语", - "class": "动作", - }, - ), - ) - self.assertEqual(result["page"], 2) - self.assertEqual(result["list"][0]["vod_id"], "movie-1") - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_api_post") - def test_category_content_merges_mainland_results_and_deduplicates(self, mock_api_post): - mock_api_post.side_effect = [ - {"recommend_list": [{"vod_id": "a"}, {"vod_id": "b"}]}, - {"recommend_list": [{"vod_id": "b"}, {"vod_id": "c"}]}, - {"recommend_list": [{"vod_id": "c"}, {"vod_id": "d"}]}, - ] - result = self.spider.categoryContent("1", "1", False, {"area": "大陆"}) - self.assertEqual([item["vod_id"] for item in result["list"]], ["a", "b", "c", "d"]) - self.assertEqual( - [call.args[1]["area"] for call in mock_api_post.call_args_list], - ["中国大陆", "大陆", "内地"], - ) - - @patch.object(Spider, "_api_post") - def test_search_content_filters_ethics_and_non_matching_items(self, mock_api_post): - mock_api_post.return_value = { - "search_list": [ - {"vod_id": "1", "vod_name": "繁花", "vod_pic": "p1", "vod_year": "2024", "vod_class": "剧情"}, - {"vod_id": "2", "vod_name": "别的片", "vod_pic": "p2", "vod_year": "2024", "vod_class": "伦理"}, - {"vod_id": "3", "vod_name": "完全无关", "vod_pic": "p3", "vod_year": "2023", "vod_class": "动作"}, - ] - } - result = self.spider.searchContent("繁花", False, "1") - self.assertEqual( - result["list"], - [{"vod_id": "1", "vod_name": "繁花", "vod_pic": "p1", "vod_remarks": "2024 剧情"}], - ) - self.assertNotIn("pagecount", result) - - @patch.object(Spider, "_api_post") - def test_detail_content_falls_back_to_second_endpoint_and_sorts_lines(self, mock_api_post): - mock_api_post.side_effect = [ - None, - { - "vod": { - "vod_name": "示例影片", - "vod_pic": "poster.jpg", - "vod_remarks": "更新至10集", - "vod_content": "一段剧情", - "vod_actor": "演员甲/演员乙", - "vod_director": "导演甲", - "vod_year": "2025", - "vod_area": "中国大陆", - }, - "vod_play_list": [ - { - "player_info": { - "show": "高清线路1", - "parse": "https://parser-a/?url=", - "player_parse_type": "1", - "parse_type": "1", - }, - "urls": [{"name": "正片", "url": "https%3A%2F%2Fplay-a", "token": "tk-a"}], - }, - { - "player_info": { - "show": "防走丢加群", - "parse": "https://parser-b/?url=", - "player_parse_type": "1", - "parse_type": "0", - }, - "urls": [{"name": "备用", "url": "https%3A%2F%2Fplay-b", "token": "tk-b"}], - }, - { - "player_info": { - "show": "高清线路1", - "parse": "https://parser-c/?url=", - "player_parse_type": "2", - "parse_type": "2", - }, - "urls": [{"name": "蓝光", "url": "https%3A%2F%2Fplay-c", "token": "tk-c"}], - }, - ], - }, - ] - result = self.spider.detailContent(["123"]) - vod = result["list"][0] - self.assertEqual(vod["vod_id"], "123") - self.assertEqual(vod["vod_name"], "示例影片") - self.assertEqual(vod["vod_year"], "2025年") - self.assertEqual(vod["vod_play_from"], "🐈‍⬛高清线路$$$1线$$$高清线路12") - self.assertEqual( - vod["vod_play_url"], - "正片$高清线路1@@direct@@https://parser-a/?url=,https%3A%2F%2Fplay-a,token+tk-a,1,1" - "$$$备用$1线@@direct@@https://parser-b/?url=,https%3A%2F%2Fplay-b,token+tk-b,1,0" - "$$$蓝光$高清线路12@@direct@@https://parser-c/?url=,https%3A%2F%2Fplay-c,token+tk-c,2,2", - ) - - def test_player_content_returns_direct_url_for_parse_type_zero(self): - result = self.spider.playerContent( - "🐈‍⬛高清线路", - "高清线路1@@direct@@https://parser/?url=,https%3A%2F%2Fvideo.example%2Fraw.m3u8,token+abc,1,0", - "", - ) - self.assertEqual( - result, - { - "parse": 0, - "jx": 0, - "url": "https://video.example/raw.m3u8", - "header": {"User-Agent": "Dalvik/2.1.0 (Linux; Android 14)"}, - }, - ) - - def test_player_content_returns_parser_url_for_parse_type_two(self): - result = self.spider.playerContent( - "🐈‍⬛高清线路", - "高清线路1@@direct@@https://parser/?url=,https%3A%2F%2Fvideo.example%2Fneed-jx.m3u8,token+abc,1,2", - "", - ) - self.assertEqual( - result, - { - "parse": 1, - "jx": 1, - "url": "https://parser/?url=https://video.example/need-jx.m3u8", - "header": {"User-Agent": "Dalvik/2.1.0 (Linux; Android 14)"}, - }, - ) - - @patch.object(Spider, "fetch") - def test_player_content_uses_player_parse_type_two_direct_json(self, mock_fetch): - mock_fetch.return_value = SimpleNamespace(status_code=200, text='{"url":"https://video.example/direct.m3u8"}') - result = self.spider.playerContent( - "🐈‍⬛高清线路", - "高清线路1@@direct@@https://parser/?url=,https%3A%2F%2Fvideo.example%2Fwrapped,token+abc,2,1", - "", - ) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://video.example/direct.m3u8") - - @patch.object(Spider, "_api_post") - def test_player_content_falls_back_to_vod_parse(self, mock_api_post): - mock_api_post.return_value = {"json": '{"url":"https://video.example/final.m3u8"}'} - result = self.spider.playerContent( - "🐈‍⬛高清线路", - "高清线路1@@direct@@https://parser/?url=,https%3A%2F%2Fvideo.example%2Fencrypted,token+abc,1,1", - "", - ) - self.assertEqual(result["parse"], 0) - self.assertEqual(result["jx"], 0) - self.assertEqual(result["url"], "https://video.example/final.m3u8") - self.assertEqual(mock_api_post.call_args.args[0], "vodParse") - self.assertEqual(mock_api_post.call_args.args[1]["parse_api"], "https://parser/?url=") - self.assertEqual(mock_api_post.call_args.args[1]["token"], "abc") - - -if __name__ == "__main__": - unittest.main()