From d757d51f6733906e059ee4865c47457eff340772 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:57:16 +0800 Subject: [PATCH 1/6] feat: improve async lifecycle and domain handling --- .../sources/tutorial/12_domain_strategy.md | 24 +--- .../docs/sources/tutorial/14_async_usage.md | 71 ++++++++++- assets/docs/sources/tutorial/8_pick_domain.md | 24 +--- src/jmcomic/__init__.py | 2 +- src/jmcomic/api.py | 29 ++++- src/jmcomic/jm_async_client.py | 68 ++++++++--- src/jmcomic/jm_async_downloader.py | 13 +- src/jmcomic/jm_client_interface.py | 25 ++++ src/jmcomic/jm_config.py | 38 ++---- src/jmcomic/jm_option.py | 15 ++- tests/test_jmcomic/test_jm_api.py | 10 +- tests/test_jmcomic/test_jm_async_api.py | 35 +++++- tests/test_jmcomic/test_jm_async_client.py | 17 +++ tests/test_jmcomic/test_jm_async_custom.py | 114 ++++++++++++++++++ 14 files changed, 381 insertions(+), 104 deletions(-) diff --git a/assets/docs/sources/tutorial/12_domain_strategy.md b/assets/docs/sources/tutorial/12_domain_strategy.md index 48f944176..5da3d52a9 100644 --- a/assets/docs/sources/tutorial/12_domain_strategy.md +++ b/assets/docs/sources/tutorial/12_domain_strategy.md @@ -55,28 +55,10 @@ op = create_option('option.yml') cl = op.new_jm_client() ``` -### 2.2 通过 GitHub 兜底获取域名 +> `get_html_domain_all_via_github` 已废弃。原 GitHub 仓库不再提供禁漫域名,兼容入口目前会转发到 +> `get_html_domain_all`,并将在未来版本移除。 -如果连禁漫的发布页本身都被墙了无法访问,可以请求禁漫官方放在 GitHub 的仓库来解析最新域名。 - -```python -from jmcomic import * - -# 该请求发往 github.com,在大多数常规网络中均能保持连通 -domains = JmModuleConfig.get_html_domain_all_via_github() - -op = JmOption.default() -# 可以结合重试机制,允许失败时轮换多次 -op.client.retry_times = 3 - -# 应用域名池新建包含该域名的 Client (记得指定 impl='html') -# 将新建的 client 赋值回 op,使其在后续的下载中生效 -op.client = op.new_jm_client(domain_list=domains, impl='html') - -download_album('438696', op) -``` - -### 2.3 获取单个跳转域名 +### 2.2 获取单个跳转域名 除了获取全部域名,也可以通过访问永久跳转页获取单个重定向用的新域名。 diff --git a/assets/docs/sources/tutorial/14_async_usage.md b/assets/docs/sources/tutorial/14_async_usage.md index 0ec7f619c..1a6491f0f 100644 --- a/assets/docs/sources/tutorial/14_async_usage.md +++ b/assets/docs/sources/tutorial/14_async_usage.md @@ -14,7 +14,10 @@ import jmcomic async def main(): # 异步下载单个本子 - await jmcomic.download_album_async('438696') + album, downloader = await jmcomic.download_album_async('438696') + + # 返回的 downloader 已释放网络连接和线程池,只用于读取下载结果 + print(downloader.download_failed_image) # 异步下载单章节 await jmcomic.download_photo_async('438696') @@ -63,6 +66,16 @@ async with JmOption.default().new_jm_async_client() as cl: 无论哪种写法都只会初始化一次,你不需要自己去调用任何初始化代码,直接用就行。 +如果不使用 `async with`,使用完成后需要显式关闭客户端: + +```python +cl = JmOption.default().new_jm_async_client() +try: + album = await cl.get_album_detail(123) +finally: + await cl.close() +``` + --- ### 并发请求示例 @@ -169,7 +182,8 @@ asyncio.run(main()) ## 7. 异步分类 / 排行榜 -分类和排行榜本质上都是过滤请求,可以使用 `categories_filter` 异步方法获取分页。 +分类和排行榜本质上都是过滤请求,可以使用 `categories_filter` 获取单页,或使用 +`categories_filter_gen` 异步生成器自动翻页。 ```python import asyncio @@ -188,10 +202,61 @@ async def main(): for aid, atitle in page: print(aid, atitle) + async for page in cl.categories_filter_gen( + time='a', + category='all', + order_by='mv', + ): + print(page.page) + asyncio.run(main()) ``` -## 8. 关于 `async_impl` 配置 +## 8. 指定异步 API 域名 + +`new_jm_async_client` 支持通过 `domain_list` 显式覆盖 API 域名。参数可以是字符串序列或多行字符串: + +```python +async with JmOption.default().new_jm_async_client( + domain_list=['domain-a.example', 'domain-b.example'], +) as cl: + album = await cl.get_album_detail(123) +``` + +异步客户端不支持同步客户端的 `domain_retry_strategy` 参数。异步请求使用客户端内置的域名遍历与重试机制。 + +## 9. 下载完成回调与 downloader 生命周期 + +异步下载 API 的 `callback` 同时支持同步函数和异步函数: + +```python +def sync_callback(album, downloader): + print(album.id) + +async def async_callback(album, downloader): + await notify_server(album.id) + +await jmcomic.download_album_async(123, callback=sync_callback) +await jmcomic.download_album_async(456, callback=async_callback) +``` + +`download_album_async` 和 `download_photo_async` 是一次性便利 API。返回的 downloader 对象仍可用于读取 +`download_success_dict`、`download_failed_image` 和 `download_failed_photo`,但它持有的客户端和线程池已经关闭, +不能继续下载。 + +如果需要使用同一个 downloader 连续下载多个本子或章节,请显式管理 downloader 生命周期: + +```python +from jmcomic import JmOption, new_async_downloader + +option = JmOption.default() +async with new_async_downloader(option) as downloader: + await downloader.download_album(123) + await downloader.download_album(456) + await downloader.download_photo(789) +``` + +## 10. 关于 `async_impl` 配置 注意:仅仅在 `option.yml` 中增加配置**并不能**让代码自动变成异步,你必须要在代码中改为调用 `_async` 相关方法(如上文所示)。 diff --git a/assets/docs/sources/tutorial/8_pick_domain.md b/assets/docs/sources/tutorial/8_pick_domain.md index 686ebb97f..fda1a8a29 100644 --- a/assets/docs/sources/tutorial/8_pick_domain.md +++ b/assets/docs/sources/tutorial/8_pick_domain.md @@ -17,26 +17,8 @@ disable_jm_log() def get_all_domain(): - template = 'https://jmcmomic.github.io/go/{}.html' - url_ls = [ - template.format(i) - for i in range(300, 309) - ] - domain_set: Set[str] = set() - - def fetch_domain(url): - from curl_cffi import requests as postman - text = postman.get(url, allow_redirects=False, **meta_data).text - for domain in JmcomicText.analyse_jm_pub_html(text): - if domain.startswith('jm365.work'): - continue - domain_set.add(domain) - - multi_thread_launcher( - iter_objs=url_ls, - apply_each_obj_func=fetch_domain, - ) - return domain_set + postman = JmModuleConfig.new_postman(**meta_data) + return set(JmModuleConfig.get_html_domain_all(postman)) domain_set = get_all_domain() @@ -78,4 +60,4 @@ for domain, status in domain_status_dict.items(): jmcomic1.me: ok jmcomic.me: ok 18comic-palworld.club: ok -``` \ No newline at end of file +``` diff --git a/src/jmcomic/__init__.py b/src/jmcomic/__init__.py index 4d0b420bb..121313b69 100644 --- a/src/jmcomic/__init__.py +++ b/src/jmcomic/__init__.py @@ -2,7 +2,7 @@ # 被依赖方 <--- 使用方 # config <--- entity <--- toolkit <--- client <--- option <--- downloader -__version__ = '2.7.1' +__version__ = '2.7.2' from .api import * from .jm_plugin import * diff --git a/src/jmcomic/api.py b/src/jmcomic/api.py index 13aea8012..722d47834 100644 --- a/src/jmcomic/api.py +++ b/src/jmcomic/api.py @@ -1,10 +1,28 @@ import asyncio +import inspect from .jm_downloader import * __DOWNLOAD_API_RET = DownloadResult +async def _invoke_async_callback(callback, entity, downloader): + if callback is None: + return None + + is_async_callback = ( + inspect.iscoroutinefunction(callback) + or inspect.iscoroutinefunction(getattr(callback, '__call__', None)) + ) + if is_async_callback: + return await callback(entity, downloader) + + result = await asyncio.to_thread(callback, entity, downloader) + if inspect.isawaitable(result): + return await result + return result + + def download_batch(download_api, jm_id_iter: Union[Iterable, Generator], option=None, @@ -167,7 +185,8 @@ async def download_album_async(jm_album_id, 异步下载一个本子(album),包含其所有的章节(photo)。 - 支持批量下载(当 jm_album_id 为可迭代对象时) - - 返回 (album, downloader) 元组 + - callback 支持同步函数和异步函数 + - 返回 (album, downloader) 元组,其中 downloader 的网络和线程池资源已关闭,仅用于读取下载结果 """ if not isinstance(jm_album_id, (str, int)): return await download_batch_async(download_album_async, @@ -181,8 +200,7 @@ async def download_album_async(jm_album_id, dler.add_features(extra, 'download_album') album = await dler.download_album(jm_album_id) - if callback is not None: - callback(album, dler) + await _invoke_async_callback(callback, album, dler) if check_exception: dler.raise_if_has_exception() @@ -198,6 +216,8 @@ async def download_photo_async(jm_photo_id, ): """ 异步下载一个章节(photo)。 + callback 支持同步函数和异步函数。 + 返回的 downloader 已关闭网络和线程池资源,仅用于读取下载结果。 """ if not isinstance(jm_photo_id, (str, int)): return await download_batch_async(download_photo_async, @@ -211,8 +231,7 @@ async def download_photo_async(jm_photo_id, dler.add_features(extra, 'download_photo') photo = await dler.download_photo(jm_photo_id) - if callback is not None: - callback(photo, dler) + await _invoke_async_callback(callback, photo, dler) if check_exception: dler.raise_if_has_exception() diff --git a/src/jmcomic/jm_async_client.py b/src/jmcomic/jm_async_client.py index e7bee2434..38510b398 100644 --- a/src/jmcomic/jm_async_client.py +++ b/src/jmcomic/jm_async_client.py @@ -7,6 +7,7 @@ import asyncio import json +from typing import Sequence from urllib.parse import urlencode from curl_cffi.requests import AsyncSession @@ -51,12 +52,15 @@ class AsyncJmApiClient(AsyncJmcomicClient): _SENTINEL = object() # 类级别初始化标记与锁,防止并发更新域名 - _has_setup_domain_and_cookies = False + _has_setup_domain = False _setup_lock = asyncio.Lock() - def __init__(self, option: JmOption, max_clients=None, **kwargs): + def __init__(self, option: JmOption, max_clients=None, domain_list=None, **kwargs): + if 'domain_retry_strategy' in kwargs: + raise TypeError('Async client does not support domain_retry_strategy') + self.option = option - self._domain_list = self._resolve_domain_list() + self._domain_list = self._resolve_domain_list(domain_list) retry_times = option.client.get('retry_times') self._retry_times = retry_times if retry_times is not None else 5 self._timeout = option.client.get('timeout', 30) or 30 @@ -84,22 +88,44 @@ def __init__(self, option: JmOption, max_clients=None, **kwargs): # 域名管理 # ====================================================================== - def _resolve_domain_list(self) -> list[str]: + @staticmethod + def _normalize_domain_list(domain_list) -> list[str]: + if isinstance(domain_list, str): + return [domain.strip() for domain in domain_list.splitlines() if domain.strip()] + + if isinstance(domain_list, Sequence): + result = [] + for domain in domain_list: + if not isinstance(domain, str): + raise TypeError(f'domain must be str, got {type(domain)}') + domain = domain.strip() + if domain: + result.append(domain) + return result + + raise TypeError('domain_list must be str, Sequence[str], or None') + + def _resolve_domain_list(self, domain_list=None) -> list[str]: """解析并返回可用的 API 域名列表""" + if domain_list is not None: + resolved = self._normalize_domain_list(domain_list) + if resolved: + return resolved + updated = JmModuleConfig.DOMAIN_API_UPDATED_LIST if updated: return list(updated) domain = self.option.client.domain if hasattr(domain, 'get'): domain_list = domain.get('api', []) - elif isinstance(domain, list): - domain_list = domain - elif isinstance(domain, str): - domain_list = [d.strip() for d in domain.split('\n') if d.strip()] else: - domain_list = [] - if domain_list: - return domain_list + domain_list = domain + + if domain_list is not None: + resolved = self._normalize_domain_list(domain_list) + if resolved: + return resolved + return list(JmModuleConfig.DOMAIN_API_LIST) def get_domain_list(self) -> list[str]: @@ -708,20 +734,22 @@ async def setup(self): cls = self.__class__ async with cls._setup_lock: - if not cls._has_setup_domain_and_cookies: + if self._has_setup: + return + + if not cls._has_setup_domain: await self.auto_update_domain() - if JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES: - await self.ensure_have_cookies() - cls._has_setup_domain_and_cookies = True + cls._has_setup_domain = True else: - # 即使已经初始化过域名和 cookie,也需要将已保存的全局 DOMAIN 和 COOKIES 赋值到当前 client + # 即使已经初始化过域名,也需要将已保存的全局 DOMAIN 赋值到当前 client if JmModuleConfig.DOMAIN_API_UPDATED_LIST: self._domain_list = list(JmModuleConfig.DOMAIN_API_UPDATED_LIST) - if JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES and JmModuleConfig.APP_COOKIES: - # noinspection PyUnresolvedReferences - self._session.cookies.update(JmModuleConfig.APP_COOKIES) - self._has_setup = True + # Cookie 属于 session 状态,每个 client 都需要独立确认。 + if JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES: + await self.ensure_have_cookies() + + self._has_setup = True # ====================================================================== # 生命周期 diff --git a/src/jmcomic/jm_async_downloader.py b/src/jmcomic/jm_async_downloader.py index 4eb671eb4..b2ff905bb 100644 --- a/src/jmcomic/jm_async_downloader.py +++ b/src/jmcomic/jm_async_downloader.py @@ -257,7 +257,18 @@ def shutdown(self): async def __aenter__(self): # 创建并独占一个 async client(含 AsyncSession)。 self.client = self.option.new_jm_async_client(max_clients=self._image_concurrency) - await self.client.setup() + try: + await self.client.setup() + except BaseException: + try: + await self.client.close() + except BaseException as cleanup_error: + jm_log('dler.cleanup.exception', + f'初始化失败后的资源清理也发生异常: {cleanup_error}', cleanup_error) + finally: + self.client = None + self.shutdown() + raise return self async def __aexit__(self, exc_type, exc_val, exc_tb): diff --git a/src/jmcomic/jm_client_interface.py b/src/jmcomic/jm_client_interface.py index 96b778a14..387f159d1 100644 --- a/src/jmcomic/jm_client_interface.py +++ b/src/jmcomic/jm_client_interface.py @@ -514,6 +514,7 @@ def get_html_domain_all(self): return JmModuleConfig.get_html_domain_all(self.get_root_postman()) def get_html_domain_all_via_github(self): + """已废弃,请使用 get_html_domain_all。""" return JmModuleConfig.get_html_domain_all_via_github(self.get_root_postman()) # noinspection PyMethodMayBeStatic @@ -815,6 +816,30 @@ async def categories_filter(self, ) -> JmCategoryPage: raise NotImplementedError + async def categories_filter_gen(self, + page: int = 1, + time: str = JmMagicConstants.TIME_ALL, + category: str = JmMagicConstants.CATEGORY_ALL, + order_by: str = JmMagicConstants.ORDER_BY_LATEST, + sub_category: Optional[str] = None, + ): + """异步分类结果生成器,支持通过 asend 动态更新分页参数。""" + params = { + 'time': time, + 'category': category, + 'order_by': order_by, + 'sub_category': sub_category, + } + + aiter = self.do_page_iter(params, page, self.categories_filter) + value = None + while True: + try: + page_content = await aiter.asend(value) + value = yield page_content + except StopAsyncIteration: + break + async def month_ranking(self, page: int = 1, category: str = JmMagicConstants.CATEGORY_ALL, diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py index f6fea7eb5..a76981147 100644 --- a/src/jmcomic/jm_config.py +++ b/src/jmcomic/jm_config.py @@ -370,37 +370,21 @@ def get_html_domain_all(cls, postman=None): @classmethod def get_html_domain_all_via_github(cls, postman=None, - template='https://jmcmomic.github.io/go/{}.html', - index_range=(300, 309) + *deprecated_args, + **deprecated_kwargs, ): """ - 通过禁漫官方的github号的repo获取最新的禁漫域名 - https://github.com/jmcmomic/jmcmomic.github.io + 已废弃:原 GitHub 仓库不再提供禁漫域名。 + 为保持兼容,当前转发到 get_html_domain_all;该方法将在未来版本移除。 """ - postman = postman or cls.new_postman(headers={ - 'authority': 'github.com', - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 ' - 'Safari/537.36' - }) - domain_set = set() - - def fetch_domain(url): - resp = postman.get(url, allow_redirects=False) - text = resp.text - from .jm_toolkit import JmcomicText - for domain in JmcomicText.analyse_jm_pub_html(text): - if domain.startswith('jm365'): - continue - domain_set.add(domain) - - from common import multi_thread_launcher - - multi_thread_launcher( - iter_objs=[template.format(i) for i in range(*index_range)], - apply_each_obj_func=fetch_domain, + import warnings + warnings.warn( + 'get_html_domain_all_via_github is deprecated because the GitHub repository no longer provides ' + 'JMComic domains; use get_html_domain_all instead.', + DeprecationWarning, + stacklevel=2, ) - - return domain_set + return cls.get_html_domain_all(postman) @classmethod def new_html_headers(cls, domain='18comic.vip'): diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py index 0a5933176..701e198ab 100644 --- a/src/jmcomic/jm_option.py +++ b/src/jmcomic/jm_option.py @@ -1,4 +1,5 @@ from .jm_client_impl import * +from typing import Optional, Sequence, Union class CacheRegistry: @@ -594,7 +595,11 @@ def download_photo(self, from .api import download_photo return download_photo(photo_id, self, *args, **kwargs) - def new_jm_async_client(self, cache=None, **kwargs) -> AsyncJmcomicClient: + def new_jm_async_client(self, + cache=None, + domain_list: Optional[Union[str, Sequence[str]]] = None, + **kwargs, + ) -> AsyncJmcomicClient: """ 通过 Option 配置创建异步客户端。 从 REGISTRY_ASYNC_CLIENT 注册表查找实现类(配置项: client.async_impl), @@ -603,9 +608,15 @@ def new_jm_async_client(self, cache=None, **kwargs) -> AsyncJmcomicClient: 缓存:与同步版本 new_jm_client 相同,依据 client.cache 配置决定是否启用缓存 (默认 None 即为不缓存),并通过 CacheRegistry.enable_client_cache_on_condition 下发配置。 """ + if 'domain_retry_strategy' in kwargs: + raise TypeError('Async client does not support domain_retry_strategy') + async_impl = self.client.get('async_impl', 'async_api') or 'async_api' clazz = JmModuleConfig.async_client_impl_class(async_impl) - client = clazz(self, **kwargs) + if domain_list is None: + client = clazz(self, **kwargs) + else: + client = clazz(self, domain_list=domain_list, **kwargs) # 启用缓存(与同步版本保持一致):默认不缓存,由 client.cache 配置决定 cache = cache if cache is not None else self.client.cache diff --git a/tests/test_jmcomic/test_jm_api.py b/tests/test_jmcomic/test_jm_api.py index c2905e2a4..f47e1beef 100644 --- a/tests/test_jmcomic/test_jm_api.py +++ b/tests/test_jmcomic/test_jm_api.py @@ -47,7 +47,6 @@ def test_get_jmcomic_domain(self): func_list = { self.client.get_html_domain, self.client.get_html_domain_all, - self.client.get_html_domain_all_via_github, # JmModuleConfig.get_jmcomic_url, # JmModuleConfig.get_jmcomic_domain_all, } @@ -66,6 +65,15 @@ def run_func_async(func): apply_each_obj_func=run_func_async, ) + def test_get_html_domain_all_via_github_deprecated(self): + """废弃入口应发出警告,并转发到官方发布页域名获取逻辑。""" + with self.assertWarns(DeprecationWarning): + deprecated_result = self.client.get_html_domain_all_via_github() + + current_result = self.client.get_html_domain_all() + self.assertEqual(deprecated_result, current_result) + self.assertGreater(len(deprecated_result), 0) + if len(exception_list) == 0: return diff --git a/tests/test_jmcomic/test_jm_async_api.py b/tests/test_jmcomic/test_jm_async_api.py index aa2021bdc..47aa774a1 100644 --- a/tests/test_jmcomic/test_jm_async_api.py +++ b/tests/test_jmcomic/test_jm_async_api.py @@ -6,6 +6,7 @@ """ import asyncio +import threading from test_jmcomic import * from jmcomic import ( download_album_async, download_photo_async, download_batch_async, @@ -21,26 +22,56 @@ class Test_Async_Api(JmAsyncTestConfigurable): def test_async_download_photo_by_id(self): """测试 download_photo_async:验证返回值与同步版本保持一致""" photo_id = '438516' + callback_result = {} + caller_thread = threading.get_ident() + + def callback(photo, downloader): + callback_result['photo'] = photo + callback_result['downloader'] = downloader + callback_result['thread'] = threading.get_ident() + # sync sync_photo, sync_dler = download_photo(photo_id, self.option) # async - async_photo, async_dler = asyncio.run(download_photo_async(photo_id, self.option)) + async_photo, async_dler = asyncio.run(download_photo_async( + photo_id, + self.option, + callback=callback, + )) self.assertIsInstance(async_dler, JmAsyncDownloader, 'downloader 必须是异步版本') self.assertIsInstance(async_photo, JmPhotoDetail, '返回值必须包含 photo') self.assert_sync_async_equal(sync_photo.photo_id, async_photo.photo_id, 'photo.photo_id') + self.assertIs(callback_result['photo'], async_photo) + self.assertIs(callback_result['downloader'], async_dler) + self.assertNotEqual(callback_result['thread'], caller_thread, '同步 callback 不应阻塞事件循环线程') + self.assertIsNone(async_dler.client, '顶层异步下载结束后 downloader client 应已关闭') def test_async_download_album_by_id(self): """测试 download_album_async:验证返回值与同步版本保持一致""" album_id = '438516' + callback_result = {} + + async def callback(album, downloader): + await asyncio.sleep(0) + callback_result['album'] = album + callback_result['downloader'] = downloader + # sync sync_album, sync_dler = download_album(album_id, self.option) # async - async_album, async_dler = asyncio.run(download_album_async(album_id, self.option)) + async_album, async_dler = asyncio.run(download_album_async( + album_id, + self.option, + callback=callback, + )) self.assertIsInstance(async_dler, JmAsyncDownloader, 'downloader 必须是异步版本') self.assertIsInstance(async_album, JmAlbumDetail, '返回值必须包含 album') self.assert_album_equal(sync_album, async_album) + self.assertIs(callback_result['album'], async_album) + self.assertIs(callback_result['downloader'], async_dler) + self.assertIsNone(async_dler.client, '顶层异步下载结束后 downloader client 应已关闭') def test_async_batch(self): """测试 download_batch_async:验证返回集合大小与同步版本保持一致""" diff --git a/tests/test_jmcomic/test_jm_async_client.py b/tests/test_jmcomic/test_jm_async_client.py index ea9080db7..a7682ab26 100644 --- a/tests/test_jmcomic/test_jm_async_client.py +++ b/tests/test_jmcomic/test_jm_async_client.py @@ -278,6 +278,23 @@ async def run(): self.run_async(run()) + def test_async_categories_filter_generator(self): + """测试异步分类生成器 categories_filter_gen(包含 asend)""" + async def run(): + gen = self.async_client.categories_filter_gen( + category=JmMagicConstants.CATEGORY_ALL, + order_by=JmMagicConstants.ORDER_BY_LATEST, + ) + + page1 = await gen.asend(None) + self.assertGreater(len(page1), 0) + + page2 = await gen.asend({'page': 2}) + self.assertGreater(len(page2), 0) + await gen.aclose() + + self.run_async(run()) + def test_async_download_cover_not_supported(self): """diff 标记:async client 无独立 download_album_cover""" self.assertFalse( diff --git a/tests/test_jmcomic/test_jm_async_custom.py b/tests/test_jmcomic/test_jm_async_custom.py index 5fb0122d3..1f016fd05 100644 --- a/tests/test_jmcomic/test_jm_async_custom.py +++ b/tests/test_jmcomic/test_jm_async_custom.py @@ -5,6 +5,7 @@ """ from test_jmcomic import * from jmcomic.jm_async_client import AsyncJmApiClient +from jmcomic.jm_async_downloader import JmAsyncDownloader from jmcomic.jm_client_interface import AsyncJmcomicClient import asyncio @@ -111,3 +112,116 @@ class MyAsyncFallback(AsyncJmApiClient): if client is not None: loop.run_until_complete(client.close()) loop.close() + + def test_async_explicit_domain_list(self): + """异步 client 支持显式字符串、列表和元组域名配置""" + opt = self.new_option() + expected = ['domain-a.example', 'domain-b.example'] + + clients = [ + opt.new_jm_async_client(domain_list=expected), + opt.new_jm_async_client(domain_list=tuple(expected)), + opt.new_jm_async_client(domain_list='domain-a.example\ndomain-b.example'), + ] + try: + for client in clients: + self.assertListEqual(client.get_domain_list(), expected) + finally: + loop = asyncio.new_event_loop() + try: + for client in clients: + loop.run_until_complete(client.close()) + finally: + loop.close() + + self.assertRaises( + TypeError, + opt.new_jm_async_client, + domain_list=['domain.example', 1], + ) + self.assertRaises( + TypeError, + opt.new_jm_async_client, + domain_retry_strategy=lambda client: client, + ) + + def test_async_setup_checks_cookies_for_each_session(self): + """首个 client 自带 Cookie 时,后续 client 仍应独立初始化 Cookie""" + old_auto_update = JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN + old_require_cookies = JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES + old_app_cookies = JmModuleConfig.APP_COOKIES + old_setup_domain = AsyncJmApiClient._has_setup_domain + + first = None + second = None + loop = asyncio.new_event_loop() + try: + JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = False + JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = True + JmModuleConfig.APP_COOKIES = None + AsyncJmApiClient._has_setup_domain = False + + first_option = self.new_option() + first_option.client.postman.meta_data.src_dict['cookies'] = {'custom': 'cookie'} + second_option = self.new_option() + second_option.client.postman.meta_data.src_dict.pop('cookies', None) + + first = first_option.new_jm_async_client() + second = second_option.new_jm_async_client() + loop.run_until_complete(first.setup()) + loop.run_until_complete(second.setup()) + + self.assertEqual(first._session.cookies.get('custom'), 'cookie') + self.assertTrue(second._session.cookies) + self.assertTrue(JmModuleConfig.APP_COOKIES) + finally: + if first is not None: + loop.run_until_complete(first.close()) + if second is not None: + loop.run_until_complete(second.close()) + loop.close() + JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = old_auto_update + JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = old_require_cookies + JmModuleConfig.APP_COOKIES = old_app_cookies + AsyncJmApiClient._has_setup_domain = old_setup_domain + + def test_async_downloader_cleanup_when_setup_fails(self): + """真实 AsyncSession 初始化失败时,downloader 应回收 client 和线程池""" + old_auto_update = JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN + old_require_cookies = JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES + old_app_cookies = JmModuleConfig.APP_COOKIES + old_setup_domain = AsyncJmApiClient._has_setup_domain + + loop = asyncio.new_event_loop() + downloader = None + try: + JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = False + JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = True + JmModuleConfig.APP_COOKIES = None + AsyncJmApiClient._has_setup_domain = False + + option = JmOption.default() + option.client.src_dict['domain'] = {'api': ['127.0.0.1:1']} + option.client.src_dict['retry_times'] = 0 + option.client.src_dict['timeout'] = 1 + option.client.postman.meta_data.src_dict['proxies'] = None + + downloader = JmAsyncDownloader( + option, + image_concurrency=1, + photo_concurrency=1, + decode_worker=1, + ) + with self.assertRaises(RequestRetryAllFailException): + loop.run_until_complete(downloader.__aenter__()) + + self.assertIsNone(downloader.client) + self.assertTrue(downloader._decode_pool._shutdown) + finally: + if downloader is not None and not downloader._decode_pool._shutdown: + downloader.shutdown() + loop.close() + JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = old_auto_update + JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = old_require_cookies + JmModuleConfig.APP_COOKIES = old_app_cookies + AsyncJmApiClient._has_setup_domain = old_setup_domain From 23f5235867cce2f25194f3ef3e329c9aa6e1e19d Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:16:11 +0800 Subject: [PATCH 2/6] fix: isolate async domain regression tests --- tests/test_jmcomic/test_jm_api.py | 18 +++++++++--------- tests/test_jmcomic/test_jm_async_custom.py | 6 ++++++ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/test_jmcomic/test_jm_api.py b/tests/test_jmcomic/test_jm_api.py index f47e1beef..d9af3553a 100644 --- a/tests/test_jmcomic/test_jm_api.py +++ b/tests/test_jmcomic/test_jm_api.py @@ -65,15 +65,6 @@ def run_func_async(func): apply_each_obj_func=run_func_async, ) - def test_get_html_domain_all_via_github_deprecated(self): - """废弃入口应发出警告,并转发到官方发布页域名获取逻辑。""" - with self.assertWarns(DeprecationWarning): - deprecated_result = self.client.get_html_domain_all_via_github() - - current_result = self.client.get_html_domain_all() - self.assertEqual(deprecated_result, current_result) - self.assertGreater(len(deprecated_result), 0) - if len(exception_list) == 0: return @@ -85,6 +76,15 @@ def test_get_html_domain_all_via_github_deprecated(self): raise AssertionError(exception_list) + def test_get_html_domain_all_via_github_deprecated(self): + """废弃入口应发出警告,并转发到官方发布页域名获取逻辑。""" + with self.assertWarns(DeprecationWarning): + deprecated_result = self.client.get_html_domain_all_via_github() + + current_result = self.client.get_html_domain_all() + self.assertEqual(deprecated_result, current_result) + self.assertGreater(len(deprecated_result), 0) + def test_partial_exception(self): class TestDownloader(JmDownloader): def do_filter(self, detail: DetailEntity): diff --git a/tests/test_jmcomic/test_jm_async_custom.py b/tests/test_jmcomic/test_jm_async_custom.py index 1f016fd05..1e6bc60a2 100644 --- a/tests/test_jmcomic/test_jm_async_custom.py +++ b/tests/test_jmcomic/test_jm_async_custom.py @@ -150,6 +150,7 @@ def test_async_setup_checks_cookies_for_each_session(self): old_auto_update = JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN old_require_cookies = JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES old_app_cookies = JmModuleConfig.APP_COOKIES + old_updated_domains = JmModuleConfig.DOMAIN_API_UPDATED_LIST old_setup_domain = AsyncJmApiClient._has_setup_domain first = None @@ -159,6 +160,7 @@ def test_async_setup_checks_cookies_for_each_session(self): JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = False JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = True JmModuleConfig.APP_COOKIES = None + JmModuleConfig.DOMAIN_API_UPDATED_LIST = [] AsyncJmApiClient._has_setup_domain = False first_option = self.new_option() @@ -183,6 +185,7 @@ def test_async_setup_checks_cookies_for_each_session(self): JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = old_auto_update JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = old_require_cookies JmModuleConfig.APP_COOKIES = old_app_cookies + JmModuleConfig.DOMAIN_API_UPDATED_LIST = old_updated_domains AsyncJmApiClient._has_setup_domain = old_setup_domain def test_async_downloader_cleanup_when_setup_fails(self): @@ -190,6 +193,7 @@ def test_async_downloader_cleanup_when_setup_fails(self): old_auto_update = JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN old_require_cookies = JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES old_app_cookies = JmModuleConfig.APP_COOKIES + old_updated_domains = JmModuleConfig.DOMAIN_API_UPDATED_LIST old_setup_domain = AsyncJmApiClient._has_setup_domain loop = asyncio.new_event_loop() @@ -198,6 +202,7 @@ def test_async_downloader_cleanup_when_setup_fails(self): JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = False JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = True JmModuleConfig.APP_COOKIES = None + JmModuleConfig.DOMAIN_API_UPDATED_LIST = [] AsyncJmApiClient._has_setup_domain = False option = JmOption.default() @@ -224,4 +229,5 @@ def test_async_downloader_cleanup_when_setup_fails(self): JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = old_auto_update JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = old_require_cookies JmModuleConfig.APP_COOKIES = old_app_cookies + JmModuleConfig.DOMAIN_API_UPDATED_LIST = old_updated_domains AsyncJmApiClient._has_setup_domain = old_setup_domain From 0c9845d67f53d9280e65d2414cc59ac1761cd828 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:28:14 +0800 Subject: [PATCH 3/6] fix: preserve updated domains in cookie test --- tests/test_jmcomic/test_jm_async_custom.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_jmcomic/test_jm_async_custom.py b/tests/test_jmcomic/test_jm_async_custom.py index 1e6bc60a2..1bf3a2909 100644 --- a/tests/test_jmcomic/test_jm_async_custom.py +++ b/tests/test_jmcomic/test_jm_async_custom.py @@ -150,7 +150,6 @@ def test_async_setup_checks_cookies_for_each_session(self): old_auto_update = JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN old_require_cookies = JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES old_app_cookies = JmModuleConfig.APP_COOKIES - old_updated_domains = JmModuleConfig.DOMAIN_API_UPDATED_LIST old_setup_domain = AsyncJmApiClient._has_setup_domain first = None @@ -160,7 +159,6 @@ def test_async_setup_checks_cookies_for_each_session(self): JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = False JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = True JmModuleConfig.APP_COOKIES = None - JmModuleConfig.DOMAIN_API_UPDATED_LIST = [] AsyncJmApiClient._has_setup_domain = False first_option = self.new_option() @@ -185,7 +183,6 @@ def test_async_setup_checks_cookies_for_each_session(self): JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = old_auto_update JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = old_require_cookies JmModuleConfig.APP_COOKIES = old_app_cookies - JmModuleConfig.DOMAIN_API_UPDATED_LIST = old_updated_domains AsyncJmApiClient._has_setup_domain = old_setup_domain def test_async_downloader_cleanup_when_setup_fails(self): From 77a867eff822759068a889863529636b8e2776b2 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:50:45 +0800 Subject: [PATCH 4/6] fix: align async domains and improve benchmark report --- .github/workflows/benchmark.yml | 2 +- .gitignore | 2 +- .../docs/sources/tutorial/14_async_usage.md | 60 ++------- src/jmcomic/jm_async_client.py | 27 ++-- tests/test_jmcomic/test_jm_async_custom.py | 64 ++++++--- usage/benchmark_async_vs_sync.py | 124 ++++++++++++++---- 6 files changed, 175 insertions(+), 104 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index c0bbe3d41..b2cbc42f6 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -38,7 +38,7 @@ jobs: no_proxy: '*' run: python usage/benchmark_async_vs_sync.py - - name: Publish Report to Step Summary + - name: Publish Conclusion-first Report if: always() run: | if [ -f PERFORMANCE_REPORT.md ]; then diff --git a/.gitignore b/.gitignore index 7c03ef374..65e54d52d 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,4 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ .agent -gemini.md \ No newline at end of file +AGENTS.md \ No newline at end of file diff --git a/assets/docs/sources/tutorial/14_async_usage.md b/assets/docs/sources/tutorial/14_async_usage.md index 1a6491f0f..d76edc6f9 100644 --- a/assets/docs/sources/tutorial/14_async_usage.md +++ b/assets/docs/sources/tutorial/14_async_usage.md @@ -187,76 +187,32 @@ asyncio.run(main()) ```python import asyncio -from jmcomic import JmOption +from jmcomic import JmMagicConstants, JmOption async def main(): async with JmOption.default().new_jm_async_client() as cl: # 获取全部时间、全部分类下,按观看数排序的第一页本子 page = await cl.categories_filter( page=1, - time='a', # JmMagicConstants.TIME_ALL - category='all', # JmMagicConstants.CATEGORY_ALL - order_by='mv', # JmMagicConstants.ORDER_BY_VIEW + time=JmMagicConstants.TIME_ALL, + category=JmMagicConstants.CATEGORY_ALL, + order_by=JmMagicConstants.ORDER_BY_VIEW, ) for aid, atitle in page: print(aid, atitle) async for page in cl.categories_filter_gen( - time='a', - category='all', - order_by='mv', + time=JmMagicConstants.TIME_ALL, + category=JmMagicConstants.CATEGORY_ALL, + order_by=JmMagicConstants.ORDER_BY_VIEW, ): print(page.page) asyncio.run(main()) ``` -## 8. 指定异步 API 域名 - -`new_jm_async_client` 支持通过 `domain_list` 显式覆盖 API 域名。参数可以是字符串序列或多行字符串: - -```python -async with JmOption.default().new_jm_async_client( - domain_list=['domain-a.example', 'domain-b.example'], -) as cl: - album = await cl.get_album_detail(123) -``` - -异步客户端不支持同步客户端的 `domain_retry_strategy` 参数。异步请求使用客户端内置的域名遍历与重试机制。 - -## 9. 下载完成回调与 downloader 生命周期 - -异步下载 API 的 `callback` 同时支持同步函数和异步函数: - -```python -def sync_callback(album, downloader): - print(album.id) - -async def async_callback(album, downloader): - await notify_server(album.id) - -await jmcomic.download_album_async(123, callback=sync_callback) -await jmcomic.download_album_async(456, callback=async_callback) -``` - -`download_album_async` 和 `download_photo_async` 是一次性便利 API。返回的 downloader 对象仍可用于读取 -`download_success_dict`、`download_failed_image` 和 `download_failed_photo`,但它持有的客户端和线程池已经关闭, -不能继续下载。 - -如果需要使用同一个 downloader 连续下载多个本子或章节,请显式管理 downloader 生命周期: - -```python -from jmcomic import JmOption, new_async_downloader - -option = JmOption.default() -async with new_async_downloader(option) as downloader: - await downloader.download_album(123) - await downloader.download_album(456) - await downloader.download_photo(789) -``` - -## 10. 关于 `async_impl` 配置 +## 8. 关于 `async_impl` 配置 注意:仅仅在 `option.yml` 中增加配置**并不能**让代码自动变成异步,你必须要在代码中改为调用 `_async` 相关方法(如上文所示)。 diff --git a/src/jmcomic/jm_async_client.py b/src/jmcomic/jm_async_client.py index 38510b398..902e47626 100644 --- a/src/jmcomic/jm_async_client.py +++ b/src/jmcomic/jm_async_client.py @@ -112,9 +112,6 @@ def _resolve_domain_list(self, domain_list=None) -> list[str]: if resolved: return resolved - updated = JmModuleConfig.DOMAIN_API_UPDATED_LIST - if updated: - return list(updated) domain = self.option.client.domain if hasattr(domain, 'get'): domain_list = domain.get('api', []) @@ -134,6 +131,20 @@ def get_domain_list(self) -> list[str]: def set_domain_list(self, domain_list: list[str]): self._domain_list = domain_list + def update_old_api_domain(self, new_server_list: list[str]): + if not new_server_list: + return + + if sorted(self._domain_list) != sorted(JmModuleConfig.DOMAIN_API_LIST): + return + + old_server_list = self._domain_list + self._domain_list = list(new_server_list) + jm_log( + 'api.update_domain.replace', + f'替换异步客户端的内置API域名:(old){old_server_list} ---→ (new){self._domain_list}', + ) + # ====================================================================== # 缓存 # ====================================================================== @@ -685,8 +696,7 @@ async def auto_update_domain(self): return if JmModuleConfig.DOMAIN_API_UPDATED_LIST is not None: - if JmModuleConfig.DOMAIN_API_UPDATED_LIST: - self._domain_list = list(JmModuleConfig.DOMAIN_API_UPDATED_LIST) + self.update_old_api_domain(JmModuleConfig.DOMAIN_API_UPDATED_LIST) return # 尝试从域名服务器获取最新域名 @@ -709,8 +719,7 @@ async def auto_update_domain(self): jm_log('api.update_domain.success', f'获取到最新的API域名: {new_server_list}') JmModuleConfig.DOMAIN_API_UPDATED_LIST = new_server_list - if sorted(self._domain_list) == sorted(JmModuleConfig.DOMAIN_API_LIST): - self._domain_list = new_server_list + self.update_old_api_domain(new_server_list) return except Exception as e: jm_log('api.update_domain.error', f'通过[{url}]自动更新API域名失败: {e}') @@ -742,8 +751,8 @@ async def setup(self): cls._has_setup_domain = True else: # 即使已经初始化过域名,也需要将已保存的全局 DOMAIN 赋值到当前 client - if JmModuleConfig.DOMAIN_API_UPDATED_LIST: - self._domain_list = list(JmModuleConfig.DOMAIN_API_UPDATED_LIST) + if JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN: + self.update_old_api_domain(JmModuleConfig.DOMAIN_API_UPDATED_LIST) # Cookie 属于 session 状态,每个 client 都需要独立确认。 if JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES: diff --git a/tests/test_jmcomic/test_jm_async_custom.py b/tests/test_jmcomic/test_jm_async_custom.py index 1bf3a2909..69623b991 100644 --- a/tests/test_jmcomic/test_jm_async_custom.py +++ b/tests/test_jmcomic/test_jm_async_custom.py @@ -29,8 +29,8 @@ class MyAsyncClient(AsyncJmApiClient): try: client = opt.new_jm_async_client() self.assertIsInstance(client, MyAsyncClient) - # 域名应回退到默认 API 域名列表(与 sync 行为一致) - expected = JmModuleConfig.DOMAIN_API_UPDATED_LIST or JmModuleConfig.DOMAIN_API_LIST + # setup 前应回退到默认 API 域名列表(与 sync 构造行为一致) + expected = JmModuleConfig.DOMAIN_API_LIST self.assertListEqual(client.get_domain_list(), list(expected)) finally: if client is not None: @@ -105,8 +105,8 @@ class MyAsyncFallback(AsyncJmApiClient): client = None try: client = opt.new_jm_async_client() - # 应回退到 DOMAIN_API_UPDATED_LIST 或 DOMAIN_API_LIST(与 sync 行为一致) - expected = JmModuleConfig.DOMAIN_API_UPDATED_LIST or JmModuleConfig.DOMAIN_API_LIST + # setup 前应回退到默认 API 域名列表(与 sync 构造行为一致) + expected = JmModuleConfig.DOMAIN_API_LIST self.assertListEqual(client.get_domain_list(), list(expected)) finally: if client is not None: @@ -114,25 +114,53 @@ class MyAsyncFallback(AsyncJmApiClient): loop.close() def test_async_explicit_domain_list(self): - """异步 client 支持显式字符串、列表和元组域名配置""" - opt = self.new_option() - expected = ['domain-a.example', 'domain-b.example'] + """setup 仅替换内置域名,保留显式参数和 Option 自定义域名""" + old_auto_update = JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN + old_require_cookies = JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES + old_updated_domains = JmModuleConfig.DOMAIN_API_UPDATED_LIST + old_setup_domain = AsyncJmApiClient._has_setup_domain - clients = [ - opt.new_jm_async_client(domain_list=expected), - opt.new_jm_async_client(domain_list=tuple(expected)), - opt.new_jm_async_client(domain_list='domain-a.example\ndomain-b.example'), - ] + expected = ['domain-a.example', 'domain-b.example'] + updated = ['updated-domain.example'] + clients = [] + default_client = None + loop = asyncio.new_event_loop() try: + JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = True + JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = False + JmModuleConfig.DOMAIN_API_UPDATED_LIST = updated + AsyncJmApiClient._has_setup_domain = False + + opt = self.new_option() + configured_opt = self.new_option() + configured_opt.client.src_dict['domain'] = {'api': list(expected)} + default_opt = self.new_option() + default_opt.client.src_dict['domain'] = {'api': list(JmModuleConfig.DOMAIN_API_LIST)} + + clients = [ + opt.new_jm_async_client(domain_list=expected), + opt.new_jm_async_client(domain_list=tuple(expected)), + opt.new_jm_async_client(domain_list='domain-a.example\ndomain-b.example'), + configured_opt.new_jm_async_client(), + ] + default_client = default_opt.new_jm_async_client() + for client in clients: + loop.run_until_complete(client.setup()) self.assertListEqual(client.get_domain_list(), expected) + + loop.run_until_complete(default_client.setup()) + self.assertListEqual(default_client.get_domain_list(), updated) finally: - loop = asyncio.new_event_loop() - try: - for client in clients: - loop.run_until_complete(client.close()) - finally: - loop.close() + for client in clients: + loop.run_until_complete(client.close()) + if default_client is not None: + loop.run_until_complete(default_client.close()) + loop.close() + JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = old_auto_update + JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = old_require_cookies + JmModuleConfig.DOMAIN_API_UPDATED_LIST = old_updated_domains + AsyncJmApiClient._has_setup_domain = old_setup_domain self.assertRaises( TypeError, diff --git a/usage/benchmark_async_vs_sync.py b/usage/benchmark_async_vs_sync.py index b816c2e24..4c2ea92a2 100644 --- a/usage/benchmark_async_vs_sync.py +++ b/usage/benchmark_async_vs_sync.py @@ -321,17 +321,108 @@ def avg(lst): 'mad': avg(mem_download['Async']) / (1024 * 1024), } - # ── 生成 Markdown 报告 ── - def perf_line(sync_val, async_val, unit='s', desc='性能'): - if sync_val > 0 and async_val > 0: - diff = sync_val - async_val - pct = abs(diff / sync_val) * 100 - word = '提升' if diff > 0 else '下降' - return f'🏆 结论: **{desc}{word} {pct:.2f}%**(Async {"优于" if diff > 0 else "劣于"} Sync {abs(diff):.4f}{unit})\n' - return '⚠️ 数据不足,无法计算\n' + # ── 生成结论优先的 Markdown 报告 ── + neutral_threshold = 3.0 + + def compare(sync_val, async_val): + if sync_val <= 0 or async_val <= 0: + return None + return (sync_val - async_val) / sync_val * 100 + + def time_conclusion(sync_val, async_val): + pct = compare(sync_val, async_val) + if pct is None: + return '⚠️ 数据不足' + if abs(pct) < neutral_threshold: + return f'➖ 基本持平(差异 {abs(pct):.1f}%)' + if pct > 0: + return f'🚀 **Async 快 {pct:.1f}%**(约 {sync_val / async_val:.2f}×)' + return f'🐢 **Async 慢 {abs(pct):.1f}%**(耗时约为 Sync 的 {async_val / sync_val:.2f}×)' + + def memory_conclusion(sync_val, async_val): + pct = compare(sync_val, async_val) + if pct is None: + return '⚠️ 数据不足' + if abs(pct) < neutral_threshold: + return f'➖ 基本持平(差异 {abs(pct):.1f}%)' + if pct > 0: + return f'🧠 **Async 节省 {pct:.1f}% 内存**' + return f'📈 **Async 多占 {abs(pct):.1f}% 内存**' + + query_time_pct = compare(avgs['sq'], avgs['aq']) + download_time_pct = compare(avgs['sd'], avgs['ad']) + time_results = [pct for pct in (query_time_pct, download_time_pct) if pct is not None] + wins = sum(pct >= neutral_threshold for pct in time_results) + losses = sum(pct <= -neutral_threshold for pct in time_results) + + if len(time_results) < 2: + overall = '⚠️ **数据不完整,暂时无法判断 Async 是否具备整体性能优势。**' + recommendation = '先检查失败轮次和网络状态,补齐查询与下载两组数据后再做决策。' + elif wins == 2: + overall = '✅ **Async 在查询和下载两个核心场景均领先,建议优先使用 Async。**' + recommendation = '并发查询和批量图片下载均可优先选择 Async;具体资源开销见后置数据。' + elif losses == 2: + overall = '❌ **Async 在本轮查询和下载中均未体现性能优势。**' + recommendation = '暂不建议仅为性能切换到 Async,应结合网络波动和逐轮数据继续观察。' + elif wins == 1 and losses == 1: + overall = '⚖️ **Async 优势取决于使用场景:查询与下载结果方向相反。**' + recommendation = '根据实际主要负载选择实现,不建议只看单项数据做全局替换。' + elif wins == 1: + overall = '✅ **Async 在一个核心场景明确领先,另一场景与 Sync 基本持平。**' + recommendation = 'Async 已具备实际使用价值,可优先用于明确领先的场景。' + elif losses == 1: + overall = '⚠️ **Async 在一个核心场景落后,另一场景与 Sync 基本持平。**' + recommendation = '切换 Async 前应确认主要负载不集中在性能落后的场景。' + else: + overall = '➖ **Async 与 Sync 的核心耗时基本持平。**' + recommendation = '性能不是主要决策因素,可根据调用模型和代码维护成本选择。' + + def value_at(values, index, unit, divisor=1): + if index >= len(values): + return 'N/A' + return f'{values[index] / divisor:.4f}{unit}' + + def round_table(sync_time, async_time, sync_mem, async_mem): + count = max(len(sync_time), len(async_time), len(sync_mem), len(async_mem)) + rows = [ + '| 轮次 | Sync 耗时 | Async 耗时 | Sync 峰值内存 | Async 峰值内存 |', + '| :---: | ---: | ---: | ---: | ---: |', + ] + for index in range(count): + rows.append( + f'| {index + 1} ' + f'| {value_at(sync_time, index, "s")} ' + f'| {value_at(async_time, index, "s")} ' + f'| {value_at(sync_mem, index, " MB", 1024 * 1024)} ' + f'| {value_at(async_mem, index, " MB", 1024 * 1024)} |' + ) + return '\n'.join(rows) report = ( - f'# 🔬 Async vs Sync 性能与内存对比报告\n\n' + f'# 🚦 Async vs Sync Benchmark\n\n' + f'## 一句话结论\n\n' + f'> {overall}\n\n' + f'## 关键结论\n\n' + f'| 维度 | 直接结论 |\n' + f'| :--- | :--- |\n' + f'| 元数据查询耗时 | {time_conclusion(avgs["sq"], avgs["aq"])} |\n' + f'| 图片下载与解密耗时 | {time_conclusion(avgs["sd"], avgs["ad"])} |\n' + f'| 查询峰值内存 | {memory_conclusion(avgs["msq"], avgs["maq"])} |\n' + f'| 下载峰值内存 | {memory_conclusion(avgs["msd"], avgs["mad"])} |\n\n' + f'## 使用建议\n\n' + f'{recommendation}\n\n' + f'
\n' + f'查看完整测试数据、逐轮结果与环境配置\n\n' + f'### 平均数据\n\n' + f'| 场景 | Sync 平均耗时 | Async 平均耗时 | Sync 峰值内存均值 | Async 峰值内存均值 |\n' + f'| :--- | ---: | ---: | ---: | ---: |\n' + f'| 元数据查询 | {avgs["sq"]:.4f}s | {avgs["aq"]:.4f}s | {avgs["msq"]:.2f} MB | {avgs["maq"]:.2f} MB |\n' + f'| 图片下载与解密 | {avgs["sd"]:.4f}s | {avgs["ad"]:.4f}s | {avgs["msd"]:.2f} MB | {avgs["mad"]:.2f} MB |\n\n' + f'### 查询逐轮数据\n\n' + f'{round_table(stats_query["Sync"], stats_query["Async"], mem_query["Sync"], mem_query["Async"])}\n\n' + f'### 下载逐轮数据\n\n' + f'{round_table(stats_download["Sync"], stats_download["Async"], mem_download["Sync"], mem_download["Async"])}\n\n' + f'### 测试环境\n\n' f'| 配置项 | 值 |\n' f'| :--- | :--- |\n' f'| 运行环境 | {"GitHub Actions (CI)" if IS_CI else "本地开发"} |\n' @@ -340,20 +431,7 @@ def perf_line(sync_val, async_val, unit='s', desc='性能'): f'| 并发配置 | {CONCURRENCY} |\n' f'| 测试轮次 | {TEST_ROUNDS} 轮 × {CI_REPEAT} 次重复 |\n' f'| 缓存策略 | 强制禁用,每轮物理清空 |\n\n' - f'## 📊 元数据查询性能(并发={CONCURRENCY})\n\n' - f'| 模式 | 平均耗时 | 物理内存峰值均值 | 状态 |\n' - f'| :--- | :--- | :--- | :--- |\n' - f'| Sync Query | {avgs["sq"]:.4f}s | {avgs["msq"]:.2f} MB | {"✅" if avgs["sq"] > 0 else "❌"} |\n' - f'| Async Query | {avgs["aq"]:.4f}s | {avgs["maq"]:.2f} MB | {"✅" if avgs["aq"] > 0 else "❌"} |\n\n' - f'{perf_line(avgs["sq"], avgs["aq"], "s", "耗时效率")}' - f'{perf_line(avgs["msq"], avgs["maq"], " MB", "内存占用")}\n' - f'## 📊 图片下载与解密性能(并发={CONCURRENCY})\n\n' - f'| 模式 | 平均耗时 | 物理内存峰值均值 | 状态 |\n' - f'| :--- | :--- | :--- | :--- |\n' - f'| Sync Download | {avgs["sd"]:.4f}s | {avgs["msd"]:.2f} MB | {"✅" if avgs["sd"] > 0 else "❌"} |\n' - f'| Async Download | {avgs["ad"]:.4f}s | {avgs["mad"]:.2f} MB | {"✅" if avgs["ad"] > 0 else "❌"} |\n\n' - f'{perf_line(avgs["sd"], avgs["ad"], "s", "耗时效率")}' - f'{perf_line(avgs["msd"], avgs["mad"], " MB", "内存占用")}' + f'
\n' ) with open('PERFORMANCE_REPORT.md', 'w', encoding='utf-8') as f: From 5d054b17f176a6ce7e1c415d2ff8ef3f2ff9164c Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:07:12 +0800 Subject: [PATCH 5/6] fix: update api domains and stabilize async cookie test --- assets/docs/sources/tutorial/14_async_usage.md | 4 ++++ src/jmcomic/jm_config.py | 9 +++++---- tests/test_jmcomic/test_jm_async_custom.py | 5 ++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/assets/docs/sources/tutorial/14_async_usage.md b/assets/docs/sources/tutorial/14_async_usage.md index d76edc6f9..7e062b542 100644 --- a/assets/docs/sources/tutorial/14_async_usage.md +++ b/assets/docs/sources/tutorial/14_async_usage.md @@ -1,5 +1,9 @@ # 异步使用指南 +> 关于`async`和`sync`版本的性能对比可以查看 → [actions-benchmark](https://github.com/hect0x7/JMComic-Crawler-Python/actions/workflows/benchmark.yml) +> +> 简单来说,下载场景`async`**快10%↑**,纯查询无下载场景`async`**快30%↑** + 本章节介绍项目中提供的异步接口。章节结构与 `0_common_usage.md` 基本对应,可作为从同步迁移到异步代码的对照参考。 --- diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py index a76981147..6e055daa5 100644 --- a/src/jmcomic/jm_config.py +++ b/src/jmcomic/jm_config.py @@ -154,10 +154,11 @@ class JmModuleConfig: # 移动端API域名 DOMAIN_API_LIST = shuffled(''' - www.cdnaspa.club - www.cdnaspa.vip - www.cdnplaystation6.cc - www.cdnplaystation6.vip + www.cdnhjk.net + www.cdngwc.cc + www.cdngwc.net + www.cdngwc.club + www.cdnutc.me ''') DOMAIN_API_UPDATED_LIST = None diff --git a/tests/test_jmcomic/test_jm_async_custom.py b/tests/test_jmcomic/test_jm_async_custom.py index 69623b991..e6cb93677 100644 --- a/tests/test_jmcomic/test_jm_async_custom.py +++ b/tests/test_jmcomic/test_jm_async_custom.py @@ -186,7 +186,7 @@ def test_async_setup_checks_cookies_for_each_session(self): try: JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = False JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = True - JmModuleConfig.APP_COOKIES = None + JmModuleConfig.APP_COOKIES = {'cached': 'cookie'} AsyncJmApiClient._has_setup_domain = False first_option = self.new_option() @@ -200,8 +200,7 @@ def test_async_setup_checks_cookies_for_each_session(self): loop.run_until_complete(second.setup()) self.assertEqual(first._session.cookies.get('custom'), 'cookie') - self.assertTrue(second._session.cookies) - self.assertTrue(JmModuleConfig.APP_COOKIES) + self.assertEqual(second._session.cookies.get('cached'), 'cookie') finally: if first is not None: loop.run_until_complete(first.close()) From fd82195a551bbbc8f9db28aa67eb04f4134f97c2 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:43:10 +0800 Subject: [PATCH 6/6] fix: update JM app version and API domains --- src/jmcomic/jm_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py index 6e055daa5..6079d8c87 100644 --- a/src/jmcomic/jm_config.py +++ b/src/jmcomic/jm_config.py @@ -103,7 +103,7 @@ class JmMagicConstants: APP_TOKEN_SECRET_2 = '18comicAPPContent' APP_DATA_SECRET = '185Hcomic3PAPP7R' API_DOMAIN_SERVER_SECRET = 'diosfjckwpqpdfjkvnqQjsik' - APP_VERSION = '2.0.26' + APP_VERSION = '2.0.28' # 模块级别共用配置 @@ -158,7 +158,6 @@ class JmModuleConfig: www.cdngwc.cc www.cdngwc.net www.cdngwc.club - www.cdnutc.me ''') DOMAIN_API_UPDATED_LIST = None