diff --git a/py/tests/test_剧圈圈.py b/py/tests/test_剧圈圈.py index e18f13b..61cd942 100644 --- a/py/tests/test_剧圈圈.py +++ b/py/tests/test_剧圈圈.py @@ -1,6 +1,7 @@ import unittest from importlib.machinery import SourceFileLoader from pathlib import Path +from requests.exceptions import ConnectionError from unittest.mock import patch @@ -171,6 +172,34 @@ class TestJuQuanQuanSpider(unittest.TestCase): 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 = { @@ -201,10 +230,21 @@ class TestJuQuanQuanSpider(unittest.TestCase): "status_code": 200, }, ] - self.spider._decode_url = lambda value: "https://video.example/fallback.m3u8" + 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): diff --git a/py/剧圈圈.py b/py/剧圈圈.py index abd7047..3dda724 100644 --- a/py/剧圈圈.py +++ b/py/剧圈圈.py @@ -3,6 +3,7 @@ import base64 import hashlib import json import re +import subprocess import sys from urllib.parse import quote, unquote, urljoin @@ -252,15 +253,57 @@ class Spider(BaseSpider): request_headers = dict(self.headers) if headers: request_headers.update(headers) - if data is None: - response = self.fetch(target, headers=request_headers, timeout=10) - else: - response = self.post(target, data=data, headers=request_headers, timeout=10) - return { - "body": response.text or "", - "headers": getattr(response, "headers", {}) or {}, - "status_code": response.status_code, - } + try: + if data is None: + response = self.fetch(target, headers=request_headers, timeout=10) + else: + response = self.post(target, data=data, headers=request_headers, timeout=10) + return { + "body": response.text or "", + "headers": getattr(response, "headers", {}) or {}, + "status_code": response.status_code, + } + except Exception: + return self._curl_request(target, headers=request_headers, data=data) + + def _curl_request(self, url, headers=None, data=None): + command = ["curl", "-L", "--silent", "--show-error", "-D", "-", url] + for key, value in (headers or {}).items(): + command.extend(["-H", f"{key}: {value}"]) + if data is not None: + command.extend(["-X", "POST", "--data", data]) + completed = subprocess.run(command, capture_output=True, text=True, check=True, timeout=20) + raw = completed.stdout or "" + marker = "\r\n\r\n" if "\r\n\r\n" in raw else "\n\n" + chunks = [chunk for chunk in raw.split(marker) if chunk.strip()] + header_block = "" + body = "" + for index, chunk in enumerate(chunks): + if chunk.lstrip().startswith("HTTP/"): + header_block = chunk + body = marker.join(chunks[index + 1:]) if index + 1 < len(chunks) else "" + if not header_block and chunks: + header_block = chunks[0] + body = marker.join(chunks[1:]) if len(chunks) > 1 else "" + header_lines = header_block.splitlines() + status_line = header_lines[0] if header_lines else "" + matched = re.search(r"HTTP/\S+\s+(\d+)", status_line) + status_code = int(matched.group(1)) if matched else 200 + parsed_headers = {} + for line in header_lines[1:]: + if ":" not in line: + continue + key, value = line.split(":", 1) + low_key = key.strip().lower() + clean_value = value.strip() + existing = parsed_headers.get(low_key) + if existing is None: + parsed_headers[low_key] = clean_value + elif isinstance(existing, list): + existing.append(clean_value) + else: + parsed_headers[low_key] = [existing, clean_value] + return {"body": body, "headers": parsed_headers, "status_code": status_code} def _get_set_cookies(self, headers): raw = headers.get("set-cookie") or headers.get("Set-Cookie") or [] @@ -354,6 +397,8 @@ class Spider(BaseSpider): api_res = self._request_with_headers( self._build_url("/jx/api.php"), headers={ + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Accept": "*/*", "Referer": player_page, "Origin": self.host, "X-Requested-With": "XMLHttpRequest",