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 = """

鬼吹灯

分类: 有声小说
""" 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"""

    鬼吹灯

    分类: 有声小说
    """ 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()