From 0a61cf566dff00e44ac6edea49d314883e8beda1 Mon Sep 17 00:00:00 2001 From: Harold <8866033@gmail.com> Date: Sat, 25 Apr 2026 09:45:03 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BA=A2=E6=9E=9C=E7=9F=AD=E5=89=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- py/tests/test_红果短剧.py | 68 ++++++++++++------ py/红果短剧.py | 140 ++++++++++++++++++++++---------------- 2 files changed, 128 insertions(+), 80 deletions(-) diff --git a/py/tests/test_红果短剧.py b/py/tests/test_红果短剧.py index cdf8301..e9498b4 100644 --- a/py/tests/test_红果短剧.py +++ b/py/tests/test_红果短剧.py @@ -1,7 +1,7 @@ import unittest from importlib.machinery import SourceFileLoader from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch ROOT = Path(__file__).resolve().parents[1] @@ -17,7 +17,10 @@ class TestHongGuoDuanJuSpider(unittest.TestCase): def test_home_content_exposes_categories(self): content = self.spider.homeContent(False) - self.assertEqual(content["class"][0]["type_id"], "热播") + 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") @@ -37,14 +40,18 @@ class TestHongGuoDuanJuSpider(unittest.TestCase): def test_category_content_builds_query_and_maps_items(self, mock_json): mock_json.return_value = { "data": [ - {"id": "2", "title": "新剧回归", "cover": "http://img/2.jpg", "totalChapterNum": 36} + {"book_id": "b2", "title": "新剧回归", "cover": "http://img/2.jpg", "sub_title": "36集"} ] } - result = self.spider.categoryContent("新剧", "3", False, {}) + result = self.spider.categoryContent("穿越", "3", False, {}) self.assertEqual(result["page"], 3) - self.assertEqual(result["list"][0]["vod_id"], "2") + self.assertEqual(result["list"][0]["vod_id"], "b2") + self.assertEqual(result["list"][0]["vod_remarks"], "36集") self.assertNotIn("pagecount", result) - self.assertIn("name=%E6%96%B0%E5%89%A7&page=3", mock_json.call_args.args[0]) + 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): @@ -63,6 +70,12 @@ class TestHongGuoDuanJuSpider(unittest.TestCase): 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("[新剧热播] 第二部"), "第二部") @@ -70,20 +83,36 @@ class TestHongGuoDuanJuSpider(unittest.TestCase): @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", "cover": "http://img/d.jpg", "video_id": "v1"}, - {"title": "【热播】逆袭人生 2", "cover": "http://img/d.jpg", "video_id": "v2"}, + {"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_name"], "总裁有疾闪婚来袭") self.assertEqual(vod["vod_pic"], "http://img/d.jpg") - self.assertEqual(vod["vod_remarks"], "更新至2集") + 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.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): @@ -103,17 +132,14 @@ class TestHongGuoDuanJuSpider(unittest.TestCase): @patch.object(Spider, "_get_json") def test_player_content_returns_best_quality_direct_url(self, mock_json): - mock_json.return_value = { - "data": { - "qualities": [ - {"quality": "sd", "title": "标清", "download_url": "http://video/sd.mp4"}, - {"quality": "1080p", "title": "超清", "download_url": "http://video/1080.mp4"}, - ] - } - } + 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/1080.mp4") + 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): diff --git a/py/红果短剧.py b/py/红果短剧.py index 3e66592..5419bf8 100644 --- a/py/红果短剧.py +++ b/py/红果短剧.py @@ -10,58 +10,61 @@ sys.path.append("..") CLASS_LIST = [ - {"type_id": "热播", "type_name": "🎬热播"}, - {"type_id": "新剧", "type_name": "🎬新剧"}, - {"type_id": "都市", "type_name": "🎬都市"}, - {"type_id": "穿越", "type_name": "🎬穿越"}, - {"type_id": "重生", "type_name": "🎬重生"}, - {"type_id": "赘婿", "type_name": "🎬赘婿"}, {"type_id": "逆袭", "type_name": "🎬逆袭"}, {"type_id": "霸总", "type_name": "🎬霸总"}, - {"type_id": "职场", "type_name": "🎬职场"}, - {"type_id": "异能", "type_name": "🎬异能"}, - {"type_id": "神医", "type_name": "🎬神医"}, - {"type_id": "系统", "type_name": "🎬系统"}, - {"type_id": "总裁", "type_name": "🎬总裁"}, - {"type_id": "豪门", "type_name": "🎬豪门"}, + {"type_id": "现代言情", "type_name": "🎬现代言情"}, + {"type_id": "打脸虐渣", "type_name": "🎬打脸虐渣"}, + {"type_id": "豪门恩怨", "type_name": "🎬豪门恩怨"}, {"type_id": "神豪", "type_name": "🎬神豪"}, - {"type_id": "校园", "type_name": "🎬校园"}, - {"type_id": "青春", "type_name": "🎬青春"}, {"type_id": "马甲", "type_name": "🎬马甲"}, - {"type_id": "年代", "type_name": "🎬年代"}, - {"type_id": "闪婚", "type_name": "🎬闪婚"}, - {"type_id": "战神", "type_name": "🎬战神"}, - {"type_id": "女主", "type_name": "🎬女主"}, - {"type_id": "修仙", "type_name": "🎬修仙"}, + {"type_id": "都市日常", "type_name": "🎬都市日常"}, + {"type_id": "战神归来", "type_name": "🎬战神归来"}, + {"type_id": "小人物", "type_name": "🎬小人物"}, + {"type_id": "女性成长", "type_name": "🎬女性成长"}, + {"type_id": "大女主", "type_name": "🎬大女主"}, + {"type_id": "穿越", "type_name": "🎬穿越"}, + {"type_id": "都市修仙", "type_name": "🎬都市修仙"}, + {"type_id": "强者回归", "type_name": "🎬强者回归"}, {"type_id": "亲情", "type_name": "🎬亲情"}, + {"type_id": "古装", "type_name": "🎬古装"}, + {"type_id": "重生", "type_name": "🎬重生"}, + {"type_id": "闪婚", "type_name": "🎬闪婚"}, + {"type_id": "赘婿逆袭", "type_name": "🎬赘婿逆袭"}, {"type_id": "虐恋", "type_name": "🎬虐恋"}, {"type_id": "追妻", "type_name": "🎬追妻"}, + {"type_id": "天下无敌", "type_name": "🎬天下无敌"}, + {"type_id": "家庭伦理", "type_name": "🎬家庭伦理"}, {"type_id": "萌宝", "type_name": "🎬萌宝"}, - {"type_id": "古风", "type_name": "🎬古风"}, - {"type_id": "传承", "type_name": "🎬传承"}, - {"type_id": "甜宠", "type_name": "🎬甜宠"}, - {"type_id": "奇幻", "type_name": "🎬奇幻"}, - {"type_id": "爱情", "type_name": "🎬爱情"}, + {"type_id": "古风权谋", "type_name": "🎬古风权谋"}, + {"type_id": "职场", "type_name": "🎬职场"}, + {"type_id": "奇幻脑洞", "type_name": "🎬奇幻脑洞"}, + {"type_id": "异能", "type_name": "🎬异能"}, + {"type_id": "无敌神医", "type_name": "🎬无敌神医"}, + {"type_id": "古风言情", "type_name": "🎬古风言情"}, + {"type_id": "传承觉醒", "type_name": "🎬传承觉醒"}, + {"type_id": "现言甜宠", "type_name": "🎬现言甜宠"}, + {"type_id": "奇幻爱情", "type_name": "🎬奇幻爱情"}, {"type_id": "乡村", "type_name": "🎬乡村"}, - {"type_id": "历史", "type_name": "🎬历史"}, + {"type_id": "历史古代", "type_name": "🎬历史古代"}, {"type_id": "王妃", "type_name": "🎬王妃"}, - {"type_id": "高手", "type_name": "🎬高手"}, - {"type_id": "娱乐", "type_name": "🎬娱乐"}, - {"type_id": "联合", "type_name": "🎬联合"}, - {"type_id": "破镜", "type_name": "🎬破镜"}, - {"type_id": "暗恋", "type_name": "🎬暗恋"}, + {"type_id": "高手下山", "type_name": "🎬高手下山"}, + {"type_id": "娱乐圈", "type_name": "🎬娱乐圈"}, + {"type_id": "强强联合", "type_name": "🎬强强联合"}, + {"type_id": "破镜重圆", "type_name": "🎬破镜重圆"}, + {"type_id": "暗恋成真", "type_name": "🎬暗恋成真"}, {"type_id": "民国", "type_name": "🎬民国"}, - {"type_id": "冤家", "type_name": "🎬冤家"}, - {"type_id": "真假", "type_name": "🎬真假"}, + {"type_id": "欢喜冤家", "type_name": "🎬欢喜冤家"}, + {"type_id": "系统", "type_name": "🎬系统"}, + {"type_id": "真假千金", "type_name": "🎬真假千金"}, {"type_id": "龙王", "type_name": "🎬龙王"}, + {"type_id": "校园", "type_name": "🎬校园"}, {"type_id": "穿书", "type_name": "🎬穿书"}, {"type_id": "女帝", "type_name": "🎬女帝"}, {"type_id": "团宠", "type_name": "🎬团宠"}, - {"type_id": "玄幻", "type_name": "🎬玄幻"}, - {"type_id": "仙侠", "type_name": "🎬仙侠"}, - {"type_id": "青梅", "type_name": "🎬青梅"}, - {"type_id": "悬疑", "type_name": "🎬悬疑"}, - {"type_id": "推理", "type_name": "🎬推理"}, + {"type_id": "年代爱情", "type_name": "🎬年代爱情"}, + {"type_id": "玄幻仙侠", "type_name": "🎬玄幻仙侠"}, + {"type_id": "青梅竹马", "type_name": "🎬青梅竹马"}, + {"type_id": "悬疑推理", "type_name": "🎬悬疑推理"}, {"type_id": "皇后", "type_name": "🎬皇后"}, {"type_id": "替身", "type_name": "🎬替身"}, {"type_id": "大叔", "type_name": "🎬大叔"}, @@ -74,7 +77,7 @@ class Spider(BaseSpider): def __init__(self): self.name = "红果短剧" self.host = "https://api-v2.cenguigui.cn" - self.api = self.host + "/api/duanju.html" + self.api = self.host + "/api/duanju/api.php" self.headers = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " @@ -109,8 +112,8 @@ class Spider(BaseSpider): def categoryContent(self, tid, pg, filter, extend): page = int(pg) - items = self._list_items(tid, page) - return {"page": page, "limit": len(items), "total": len(items), "list": items} + items = self._list_category_items(tid, page) + return {"page": page, "limit": len(items), "total": page * 30 + len(items), "list": items} def searchContent(self, key, quick, pg="1"): page = int(pg) @@ -126,27 +129,30 @@ class Spider(BaseSpider): vod_id = str(raw_id or "").strip() if not vod_id: continue - payload = self._get_json(f"{self.api}?id={quote(vod_id)}") + payload = self._get_json(f"{self.api}?book_id={quote(vod_id)}") data = payload.get("data", []) if isinstance(payload, dict) else [] if not data: continue - first = data[0] - base_name = self._clean_title(re.sub(r"\s+\d+\s*$", "", str(first.get("title", "")))) episodes = [] for index, item in enumerate(data, start=1): play_id = str(item.get("video_id", "")).strip() if not play_id: continue - episode_name = self._clean_title(str(item.get("title", f"第{index}集"))) + episode_name = self._clean_title( + str(item.get("title") or item.get("volume_name") or f"第{index}集") + ) episodes.append(f"{episode_name}${play_id}") + tags = payload.get("category_names") or [] result["list"].append( { "vod_id": vod_id, - "vod_name": base_name, - "vod_pic": str(first.get("cover", "")), - "vod_area": "中国", - "vod_remarks": f"更新至{len(episodes)}集", - "vod_content": f"{base_name} 短剧,共 {len(episodes)} 集", + "vod_name": self._clean_title(payload.get("book_name", "")), + "vod_pic": str(payload.get("book_pic", "")), + "vod_area": ",".join(tags) if isinstance(tags, list) else str(tags or ""), + "vod_remarks": str(payload.get("duration", "")).strip() or f"更新至{len(episodes)}集", + "vod_year": f"更新时间:{payload.get('time', '')}" if payload.get("time") else "", + "vod_actor": str(payload.get("author", "")), + "vod_content": str(payload.get("desc", "")), "vod_play_from": "红果短剧", "vod_play_url": "#".join(episodes), } @@ -155,17 +161,13 @@ class Spider(BaseSpider): def playerContent(self, flag, id, vipFlags): try: - payload = self._get_json(f"{self.api}?video_id={quote(str(id or '').strip())}") - data = payload.get("data", {}) if isinstance(payload, dict) else {} - qualities = self._sort_qualities(data.get("qualities", [])) - best = next( - (item for item in qualities if str(item.get("download_url", "")).strip()), - {}, + payload = self._get_json( + f"{self.api}?video_id={quote(str(id or '').strip())}" ) return { "parse": 0, "jx": 0, - "url": str(best.get("download_url", "")).strip(), + "url": str((payload or {}).get("url", "")).strip(), "header": {}, } except Exception: @@ -176,13 +178,30 @@ class Spider(BaseSpider): data = payload.get("data", []) if isinstance(payload, dict) else [] return [self._map_vod(item) for item in data] + def _list_category_items(self, classname, page): + offset = self._category_offset(page) + url = ( + f"{self.api}?classname={quote(str(classname))}" + f"&offset={offset}&showRawParams=false" + ) + payload = self._get_json(url) + data = payload.get("data", []) if isinstance(payload, dict) else [] + return [self._map_vod(item) for item in data] + + def _category_offset(self, page): + page_num = max(1, int(page)) + if page_num <= 1: + return 1 + return (page_num - 1) * 20 + def _map_vod(self, item): total = item.get("totalChapterNum") + remarks = str(item.get("sub_title", "")).strip() return { - "vod_id": str(item.get("id", "")), + "vod_id": str(item.get("book_id") or item.get("id", "")), "vod_name": self._clean_title(item.get("title", "")), "vod_pic": str(item.get("cover", "")), - "vod_remarks": f"更新至{total}集" if total else "", + "vod_remarks": remarks or (f"更新至{total}集" if total else ""), } def _clean_title(self, text): @@ -203,4 +222,7 @@ class Spider(BaseSpider): response = self.fetch(url, headers=dict(self.headers), timeout=10, verify=False) if response.status_code != 200: return {} - return json.loads(response.text or "{}") + try: + return json.loads(response.text or "{}") + except (json.JSONDecodeError, TypeError, ValueError): + return {}