Skip to content

异步使用指南

Tip

关于async和sync版本的性能对比可以查看 → actions-benchmark

简单来说,下载场景async快10%↑,纯查询无下载场景async快30%↑

本章节介绍项目中提供的异步接口。章节结构与 0_common_usage.md 基本对应,可作为从同步迁移到异步代码的对照参考。


1. 异步下载本子/章节

你可以直接使用以下方法来进行异步下载:

import asyncio
import jmcomic

async def main():
    # 异步下载单个本子
    album, downloader = await jmcomic.download_album_async('438696')

    # 返回的 downloader 已释放网络连接和线程池,只用于读取下载结果
    print(downloader.download_failed_image)

    # 异步下载单章节
    await jmcomic.download_photo_async('438696')

    # 批量异步下载(直接传递包含 ID 的列表或迭代器即可,内部会自动同时下载)
    await jmcomic.download_album_async(['123', '456'])

if __name__ == '__main__':
    asyncio.run(main())

2. 使用 Option 定制化异步下载

和同步版本一样,你可以配合 option 对象来定制网络请求、代理、下载路径等:

import asyncio
from jmcomic import create_option_by_file, download_album_async

async def main():
    # 通过配置文件来创建option对象
    option = create_option_by_file('op.yml')

    # 调用异步下载 api,把 option 作为参数传递 
    await download_album_async(123, option)

asyncio.run(main())

3. 异步获取实体类,并发请求

💡 关于 async with 和自动初始化

当你使用异步client时,推荐直接搭配 async with 上下文管理器来使用:

# 推荐用法,离开代码块时会自动清理并断开连接
async with JmOption.default().new_jm_async_client() as cl:
    album = await cl.get_album_detail(123)

# 另外一种用法,单独使用client
cl = JmOption.default().new_jm_async_client()
try:
    album = await cl.get_album_detail(123)
finally:
    await cl.close()

async with 和单独使用client都会自动初始化,区别主要在初始化时机和生命周期管理:

对比项 使用 async with 单独使用client
初始化时机 进入 async with 作用域时自动初始化 第一次发起真实请求前自动初始化
初始化内容 自动完成域名解析、联通性检查等必要工作 同左
初始化次数 只初始化一次 同左
连接清理 离开作用域时自动安全释放连接 使用完成后必须显式调用 await cl.close()
推荐场景 推荐用于一般请求和下载任务 需要自行控制客户端生命周期时使用

两种写法都不需要手动调用初始化方法;如果单独使用客户端,请通过 try/finally 确保连接一定会被关闭。


并发请求示例

使用 asyncio.gather 可以并发执行网络请求:

import asyncio
from jmcomic import JmOption, AsyncJmApiClient

async def main():
    op = JmOption.default()

    # 同步获取客户端对象,并通过上下文管理器自动管理生命周期
    async with op.new_jm_async_client() as cl:
        # 示例:使用 async 并发获取本子详情
        album_id_list = [123, 456]
        album_list = await asyncio.gather(
            *(cl.get_album_detail(aid) for aid in album_id_list)
        )

        # 打印结果
        for aid, album in zip(album_id_list, album_list):
            print(f'[JM{aid}] 本子详情: {album}')

asyncio.run(main())

4. 异步异常处理示例

异步调用的异常机制与同步完全一致,同样可以通过捕获 JmcomicException 及各类派生异常进行处理:

import asyncio
from jmcomic import JmOption, MissingAlbumPhotoException, JsonResolveFailException, RequestRetryAllFailException, JmcomicException

async def main():
    async with JmOption.default().new_jm_async_client() as cl:
        try:
            album = await cl.get_album_detail('99999999')
        except MissingAlbumPhotoException as e:
            print(f'id={e.error_jmid}的本子不存在')
        except JsonResolveFailException as e:
            print(f'解析json失败: {e.resp.status_code}')
        except RequestRetryAllFailException:
            print(f'请求失败,重试次数耗尽')
        except JmcomicException as e:
            print(f'遇到异常: {e}')

asyncio.run(main())

5. 异步搜索本子

由于搜索结果通常有多页,推荐使用 search_gen 异步生成器。配合 async for,客户端会自动处理翻页逻辑并逐页获取数据:

import asyncio
from jmcomic import JmOption

async def main():
    async with JmOption.default().new_jm_async_client() as cl:
        # async for 会帮你自动加载下一页,一页一页往下搜
        async for page in cl.search_gen('+MANA +无修正'):
            print(f'当前获取到了第 {page.page_number} 页,本页数据量: {len(page)},总数量: {page.total}')

            for album_id, title in page.iter_id_title():
                print(f'[{album_id}]: {title}')

asyncio.run(main())

如果只需要第一页的数据,依然可以直接调用基础的 await cl.search(...) 方法。

6. 异步获取收藏夹

获取收藏夹的用法和搜索非常像,同样支持使用异步生成器 favorite_folder_gen 自动翻页获取整个收藏夹的内容:

import asyncio
from jmcomic import JmOption

async def main():
    async with JmOption.default().new_jm_async_client() as cl:
        # 先登录
        await cl.login('你的用户名', '你的密码')

        # 使用 async for 遍历整个收藏夹的所有页
        async for page in cl.favorite_folder_gen(folder_id='0'):
            # 遍历本页的所有本子
            for aid, atitle in page.iter_id_title():
                print(aid, atitle)

            # 同时支持获取当前账号下的所有收藏夹目录信息
            for folder_id, folder_name in page.iter_folder_id_name():
                print(f'收藏夹id: {folder_id}, 名称: {folder_name}')

        # 异步添加或取消收藏
        await cl.add_favorite_album('438696')
        await cl.delete_favorite_album('438696')

asyncio.run(main())

7. 异步每日签到与打卡日历

登录后可以查询打卡日历,并执行今日签到:

import asyncio
from jmcomic import JmOption, JmcomicException

async def main():
    async with JmOption.default().new_jm_async_client() as cl:
        await cl.login('你的用户名', '你的密码')
        daily = await cl.get_daily()
        print(daily.res_data)  # 移动端返回的打卡日历数据

        try:
            result = await cl.daily_checkin()
            if result.code == 0:
                print(f'签到成功: {result.msg}')
            elif result.code == 1:
                print(f'今日已签到: {result.msg}')
        except JmcomicException as e:
            print(f'签到失败: {e}')

asyncio.run(main())

8. 异步分类 / 排行榜

分类和排行榜本质上都是过滤请求,可以使用 categories_filter 获取单页,或使用 categories_filter_gen 异步生成器自动翻页。

import asyncio
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=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=JmMagicConstants.TIME_ALL,
            category=JmMagicConstants.CATEGORY_ALL,
            order_by=JmMagicConstants.ORDER_BY_VIEW,
        ):
            print(f'当前获取到了第 {page.page_number} 页,本页数据量: {len(page)}')

asyncio.run(main())

9. 关于 async_impl 配置

注意:仅仅在 option.yml 中增加配置并不能让代码自动变成异步,你必须要在代码中改为调用 _async 相关方法(如上文所示)。

async_impl目前可以不配置,因为配置的作用仅仅是指定底层使用哪种API实现。目前的唯一实现是 async_api:

# myoption.yml
client:
  impl: html
  # 指定异步客户端的底层实现类 (目前仅有: async_api)
  async_impl: async_api

10. 下载返回值

用法与同步版本完全一致,返回的 DownloadResult 包含了本子实体、保存路径、耗时以及下载清单(manifest)等专属字段:

import asyncio
import jmcomic


async def main():
    result = await jmcomic.download_album_async('438696')
    album = result.detail

    # 1. 实体与保存路径
    print(f'本子: JM{album.id} - {album.title}')
    print(f'保存目录: {album.save_path}')

    # 2. 耗时统计(单位:秒)
    print(f'总耗时: {result.duration:.3f} 秒, 本子处理耗时: {album.duration:.3f} 秒')

    # 3. 下载清单 manifest
    manifest = result.manifest
    print(f'全部本子图片的磁盘路径: {manifest.image_filepath_list}')


asyncio.run(main())

11. 取消下载

下面示例代码模拟了一个 GUI 场景中,有两个按钮,一个开始下载,一个取消下载,基于 DownloadControl 实现。

import asyncio
from jmcomic import DownloadCancelledException, DownloadControl, download_album_async, jm_task_context

# 界面持有的当前下载控制器与任务
current_control = None
current_task = None


# 按钮 1:点击【开始下载】
def on_btn_download_click(album_id: str) -> asyncio.Task:
    global current_control, current_task
    current_control = DownloadControl()

    async def _run():
        try:
            with jm_task_context(control=current_control):
                await download_album_async(album_id)
        except DownloadCancelledException as e:
            print(f'[UI 提示] 下载已取消: {e.reason}')

    current_task = asyncio.create_task(_run())
    return current_task


# 按钮 2:点击【取消下载】
def on_btn_cancel_click():
    if current_control:
        current_control.cancel("用户点击了界面的取消按钮")


async def main():
    # 模拟用户点击【开始下载】
    task = on_btn_download_click('123456')

    # 模拟用户在界面等待一段时间后,点击了【取消下载】
    await asyncio.sleep(2)
    on_btn_cancel_click()

    # 等待下载任务响应取消并安全收尾
    await task


asyncio.run(main())