diff --git a/py/tests/test_youknow.py b/py/tests/test_youknow.py index 5ecff46..bdfa416 100644 --- a/py/tests/test_youknow.py +++ b/py/tests/test_youknow.py @@ -21,7 +21,7 @@ class TestYouKnowSpider(unittest.TestCase): def test_parse_list_cards_extracts_compact_vod_id(self): html = """ - +
更新至10集
""" @@ -38,10 +38,27 @@ class TestYouKnowSpider(unittest.TestCase): ], ) + def test_parse_list_cards_prefers_nested_lazyload_original_over_placeholder_src(self): + html = """ + +
+ 一念梦离 +
+
更新中
+
+ """ + 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 = """ - +
HD
""" @@ -52,7 +69,7 @@ class TestYouKnowSpider(unittest.TestCase): @patch.object(Spider, "_request_html") def test_category_content_builds_page_result(self, mock_request_html): mock_request_html.return_value = """ - +
完结
""" @@ -64,7 +81,7 @@ class TestYouKnowSpider(unittest.TestCase): @patch.object(Spider, "_request_html") def test_category_content_uses_page1_path_without_page_number(self, mock_request_html): mock_request_html.return_value = """ - +
HD
""" @@ -75,7 +92,7 @@ class TestYouKnowSpider(unittest.TestCase): @patch.object(Spider, "_request_html") def test_search_content_reuses_card_parser(self, mock_request_html): mock_request_html.return_value = """ - +
抢先版
""" @@ -115,7 +132,7 @@ class TestYouKnowSpider(unittest.TestCase): 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["path"], "https://www.youknow.tv/d/888/") self.assertEqual(vod["vod_name"], "示例剧") self.assertEqual(vod["type_name"], "剧情") self.assertEqual(vod["vod_area"], "大陆") @@ -137,7 +154,7 @@ class TestYouKnowSpider(unittest.TestCase): 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(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): @@ -145,6 +162,21 @@ class TestYouKnowSpider(unittest.TestCase): data = self.spider._parse_player_config(html) self.assertEqual(data["encrypt"], "1") + def test_extract_player_config_supports_nested_object_literal(self): + html = """ + + """ + 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"), @@ -177,6 +209,28 @@ class TestYouKnowSpider(unittest.TestCase): 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 = """ + + + """ + 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( diff --git a/py/youknow.py b/py/youknow.py index 61d1178..8c58918 100644 --- a/py/youknow.py +++ b/py/youknow.py @@ -1,5 +1,6 @@ # coding=utf-8 import base64 +import ast import json import re import sys @@ -62,7 +63,7 @@ class Spider(BaseSpider): def _extract_vod_id(self, href): raw = str(href or "").strip() - matched = re.search(r"/v/(\d+)\.html", raw) + matched = re.search(r"/d/(\d+)/?$", raw) or re.search(r"/v/(\d+)\.html", raw) if matched: return matched.group(1) if re.fullmatch(r"\d+", raw): @@ -81,7 +82,7 @@ class Spider(BaseSpider): } def _build_detail_request_url(self, vod_id): - return f"{self.host}/v/{self._extract_vod_id(vod_id)}.html" + return f"{self.host}/d/{self._extract_vod_id(vod_id)}/" def _encode_episode_payload(self, payload): raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") @@ -106,6 +107,10 @@ class Spider(BaseSpider): pic = ( (card.xpath("./@data-original") or [""])[0].strip() or (card.xpath("./@data-src") or [""])[0].strip() + or (card.xpath(".//img/@data-original") or [""])[0].strip() + or (card.xpath(".//img/@data-src") or [""])[0].strip() + or (card.xpath(".//@data-original") or [""])[0].strip() + or (card.xpath(".//@data-src") or [""])[0].strip() or (card.xpath(".//@src") or [""])[0].strip() ) remarks = "".join( @@ -136,6 +141,7 @@ class Spider(BaseSpider): def _page_result(self, items, pg): page = int(pg) pagecount = page + 1 if items else page + self.log(items[0]) return { "list": items, "page": page, @@ -323,13 +329,67 @@ class Spider(BaseSpider): ) and any(ext in text for ext in (".m3u8", ".mp4", ".flv")) def _parse_player_config(self, html): - matched = re.search(r"player_aaaa\s*=\s*(\{[\s\S]*?\})\s*;?", str(html or ""), re.I) + text = str(html or "") + matched = re.search(r"player_aaaa\s*=", text, re.I) if not matched: return None - try: - return json.loads(matched.group(1)) - except Exception: + object_start = text.find("{", matched.end()) + if object_start < 0: return None + raw_object = self._extract_balanced_object(text, object_start) + if not raw_object: + return None + return self._parse_object_literal(raw_object) + + def _extract_balanced_object(self, text, start_index): + raw = str(text or "") + if start_index < 0 or start_index >= len(raw) or raw[start_index] != "{": + return "" + + depth = 0 + in_string = False + quote_char = "" + escaped = False + + for index in range(start_index, len(raw)): + char = raw[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote_char: + in_string = False + continue + + if char in ('"', "'"): + in_string = True + quote_char = char + continue + + if char == "{": + depth += 1 + continue + + if char == "}": + depth -= 1 + if depth == 0: + return raw[start_index : index + 1] + + return "" + + def _parse_object_literal(self, raw): + text = str(raw or "").strip() + if not text: + return None + sanitized = re.sub(r",\s*}", "}", text) + try: + return json.loads(sanitized) + except Exception: + try: + return ast.literal_eval(sanitized) + except Exception: + return None def _collect_direct_media_urls(self, html): text = str(html or "").replace("\\/", "/")