From b4e26651e4a2ad211403b153ad16272d5b545ca7 Mon Sep 17 00:00:00 2001 From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:47:12 +0000 Subject: [PATCH] =?UTF-8?q?Fix=20#43:=20=E5=BB=BA=E8=AE=AE=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E9=9F=B3=E9=A2=91=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com> --- src/tchmaterial_parser/api.py | 38 ++++++++++++ tests/test_audio_parse.py | 110 ++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 tests/test_audio_parse.py diff --git a/src/tchmaterial_parser/api.py b/src/tchmaterial_parser/api.py index bb13214..fa3670a 100644 --- a/src/tchmaterial_parser/api.py +++ b/src/tchmaterial_parser/api.py @@ -85,6 +85,33 @@ def parse(url: str, bookmarks: bool) -> list[tuple[str, str, str, list[dict]]] | data: dict = response.json() # 3. 获取资源标题、下载链接及章节目录 + def get_audio_info(audio_data: dict, root_title: str | None = None) -> tuple[str, str, str, list[dict]] | None: # 解析教材关联的音频资源(如英语教材听力) + # 音频资源的标题存放在 global_title 字典中(键为语言代码,如 zh-CN) + title_data = audio_data.get("global_title") + audio_title = title_data.get("zh-CN") or title_data.get("en") if isinstance(title_data, dict) else title_data or audio_data.get("title") or audio_data.get("id") + title: str = f"{root_title} - {audio_title}" if root_title else audio_title + resource_url: str | None = None + resource_format = "mp3" + + # 优先选择转码后的 MP3 文件(ti_file_flag 为 href),否则回退到源文件 + for item in audio_data["ti_items"]: + if item.get("ti_file_flag") not in ("href", "source") or item.get("ti_format") != "mp3": + continue + + resource_url = item.get("ti_storage") # 获取并构造资源的 URL + if resource_url: + resource_url = resource_url.replace("cs_path:${ref-path}", "https://r1-ndr-private.ykt.cbern.com.cn") + else: + resource_url = next((url for url in item.get("ti_storages") or [] if url), None) + if resource_url: + resource_format = item.get("ti_format") or "mp3" + break + + if not resource_url: + return None + + return title, resource_url, resource_format, [] + def get_resource_info(resource_data: dict, root_title: str | None = None) -> tuple[str, str, str, list[dict]] | None: title: str = f"{root_title} - {resource_data.get('title') or resource_data.get('id')}" if root_title else resource_data.get("title") or resource_data.get("id") resource_url: str | None = None @@ -221,6 +248,17 @@ def parse(url: str, bookmarks: bool) -> list[tuple[str, str, str, list[dict]]] | if resource_info: resources_info.append(resource_info) + if content_type == "assets_document": # 教材可能带有配套的音频资源(如英语教材听力) + try: + audios_resp = session.get(f"https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/resources/{content_id}/relation_audios.json") + audios_data: list[dict] = audios_resp.json() + for audio in audios_data: + audio_info = get_audio_info(audio, data.get("title")) + if audio_info: + resources_info.append(audio_info) + except Exception: # 音频资源不是必需的,获取失败时直接跳过 + pass + return resources_info except Exception as e: diff --git a/tests/test_audio_parse.py b/tests/test_audio_parse.py new file mode 100644 index 0000000..476df0f --- /dev/null +++ b/tests/test_audio_parse.py @@ -0,0 +1,110 @@ +import unittest + +from src.tchmaterial_parser import api + + +class FakeResponse: + def __init__(self, json_data: object) -> None: + self._json = json_data + + def json(self) -> object: + return self._json + + +class FakeSession: + def __init__(self, details: dict, audios: list[dict]) -> None: + self.details = details + self.audios = audios + + def get(self, url: str, *args: tuple, **kwargs: dict) -> FakeResponse: + if "relation_audios.json" in url: + return FakeResponse(self.audios) + return FakeResponse(self.details) + + +DETAILS = { + "id": "book-1", + "title": "英语七年级上册", + "ti_items": [ + { + "ti_is_source_file": True, + "ti_format": "pdf", + "ti_storage": "cs_path:${ref-path}/edu_product/esp/assets/book-1.pkg/英语七年级上册.pdf", + }, + ], +} + +AUDIOS = [ + { + "id": "audio-1", + "global_title": {"zh-CN": "1 Starter Section 2 Activity 2"}, + "ti_items": [ + { + "ti_file_flag": "href", + "ti_format": "mp3", + "ti_storage": "cs_path:${ref-path}/edu_product/esp/assets/audio-1.t/1.mp3", + }, + { + "ti_file_flag": "source", + "ti_format": "wav", + "ti_storage": "cs_path:${ref-path}/edu_product/esp/assets/audio-1.t/1.wav", + }, + ], + }, + { + "id": "audio-2", + "global_title": {"zh-CN": "2 Starter Section 3 Activity 1"}, + "ti_items": [ + { + "ti_file_flag": "href", + "ti_format": "mp3", + "ti_storage": "cs_path:${ref-path}/edu_product/esp/assets/audio-2.t/2.mp3", + }, + ], + }, +] + + +class AudioParseTest(unittest.TestCase): + def setUp(self) -> None: + self.addCleanup(setattr, api, "session", api.session) + + def parse_book(self) -> list[tuple[str, str, str, list[dict]]] | None: + api.session = FakeSession(DETAILS, AUDIOS) + url = "https://basic.smartedu.cn/tchMaterial/detail?contentType=assets_document&contentId=book-1&catalogType=tchMaterial&subCatalog=tchMaterial" + return api.parse(url, False) + + def test_textbook_with_audio_returns_pdf_and_mp3s(self) -> None: + results = self.parse_book() + self.assertIsNotNone(results) + self.assertEqual(len(results), 3) + self.assertEqual(results[0][2], "pdf") + self.assertEqual(results[1][1], "https://r1-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/audio-1.t/1.mp3") + self.assertEqual(results[1][2], "mp3") + self.assertEqual(results[1][0], "英语七年级上册 - 1 Starter Section 2 Activity 2") + self.assertEqual(results[2][0], "英语七年级上册 - 2 Starter Section 3 Activity 1") + + def test_textbook_without_audio_returns_only_pdf(self) -> None: + api.session = FakeSession(DETAILS, []) + url = "https://basic.smartedu.cn/tchMaterial/detail?contentType=assets_document&contentId=book-1&catalogType=tchMaterial&subCatalog=tchMaterial" + results = api.parse(url, False) + self.assertIsNotNone(results) + self.assertEqual(len(results), 1) + self.assertEqual(results[0][2], "pdf") + + def test_audio_fetch_failure_is_ignored(self) -> None: + class FailingAudiosSession(FakeSession): + def get(self, url: str, *args: tuple, **kwargs: dict) -> FakeResponse: + if "relation_audios.json" in url: + raise RuntimeError("network error") + return super().get(url, *args, **kwargs) + + api.session = FailingAudiosSession(DETAILS, []) + url = "https://basic.smartedu.cn/tchMaterial/detail?contentType=assets_document&contentId=book-1&catalogType=tchMaterial&subCatalog=tchMaterial" + results = api.parse(url, False) + self.assertIsNotNone(results) + self.assertEqual(len(results), 1) + + +if __name__ == "__main__": + unittest.main()