## File: README.md

> 本项目封装了一套可用于爬取JM的Python API.
> 
> 你可以通过简单的几行Python代码，实现下载JM上的本子到本地，并且是处理好的图片。
> 
> **🧭 快速指路**
> - [教程：使用 GitHub Actions 下载禁漫本子](./assets/docs/sources/tutorial/1_github_actions.md)
> - [教程：导出并下载你的禁漫收藏夹数据](./assets/docs/sources/tutorial/10_export_favorites.md)
> - [教程：下载后转为 PDF / ZIP / 长图](./assets/docs/sources/tutorial/13_export_and_feature.md)
> - [塔台广播：欢迎各位机长加入并贡献代码](./.github/CONTRIBUTING.md)
> 
> **友情提示：珍爱JM，为了减轻JM的服务器压力，请不要一次性爬取太多本子，西门🙏🙏🙏**.
> 





## 项目介绍

本项目的核心功能是下载本子。

基于此，设计了一套方便使用、便于扩展，能满足一些特殊下载需求的框架。

目前核心功能实现较为稳定，项目也处于维护阶段。

除了下载功能以外，也实现了其他的一些禁漫接口，按需实现。目前已有功能：

- 登录
- 搜索本子（支持所有搜索项）
- 获取本子评论（包括回评、剧透标识）
- 分类/排行榜
- 获取本子/章节详情
- 图片下载解码
- 个人收藏夹
- 接口加解密（APP的接口）

## 安装教程

> ⚠如果你没有安装过 Python，需要先前往 [Python 官网下载](https://www.python.org/downloads/) 再执行以下步骤。
>**推荐使用 Python 3.12及以上版本**

* 通过pip官方源安装（推荐，并且更新也是这个命令）

  ```shell
  pip install jmcomic -U
  ```
* 通过源代码安装

  ```shell
  pip install git+https://github.com/hect0x7/JMComic-Crawler-Python
  ```

## 快速上手

### 1. 下载本子方法

只需要使用如下代码，就可以下载本子`JM123`的所有章节的图片：

```python
import jmcomic  # 导入此模块，需要先安装.
jmcomic.download_album('123')  # 传入要下载的album的id，即可下载整个album到本地.

# 也可以使用 Async API (详见教程: https://jmcomic.readthedocs.io/zh-cn/latest/tutorial/14_async_usage/)
import asyncio
asyncio.run(jmcomic.download_album_async('123'))
```

上面的 `download_album`方法还有一个参数`option`，可用于控制下载配置，配置包括禁漫域名、网络代理、图片格式转换、插件等等。

你可能需要这些配置项。推荐使用配置文件创建option，用option下载本子，见下章：

### 2. 使用option配置来下载本子

1. 首先，创建一个配置文件，假设文件名为 `option.yml`

   该文件有特定的写法，你需要参考这个文档 → [配置文件指南](./assets/docs/sources/option_file_syntax.md)

   下面做一个演示，假设你需要把下载的图片转为png格式，你应该把以下内容写进`option.yml`

```yml
download:
  image:
    suffix: .png # 该配置用于把下载的图片转为png格式
```

2. 第二步，运行下面的python代码

```python
import jmcomic

# 创建配置对象
option = jmcomic.create_option_by_file('你的配置文件路径，例如 D:/option.yml')
# 使用option对象来下载本子
jmcomic.download_album(123, option)
# 等价写法: option.download_album(123)
```

### 3. 使用命令行
> [!TIP]
> 如果只想下载本子，使用命令行会比上述方式更加简单直接
> 
> 例如，在windows上，直接按下 win+R 键，输入`jmcomic xxx`就可以下载本子。

示例：下载本子123的命令

```sh
jmcomic 123
```
同时下载本子123, 章节456的命令
```sh
jmcomic 123 p456
```

命令行模式也支持自定义option，你可以使用环境变量或者命令行参数：

a. 通过命令行 --option 参数指定option文件路径

```sh
jmcomic 123 --option="D:/a.yml"
```

b. 命令行不改，而是配置环境变量 `JM_OPTION_PATH` 为option文件路径（推荐）

> [!TIP]
> 请自行google配置环境变量的方式，或使用powershell命令:  `setx JM_OPTION_PATH "D:/a.yml"` 重启后生效


另外，`jmcomic` 还提供了一个美观的进度条能力，默认是开启的，但是需要你手动安装一个额外依赖 `pip install rich`。效果如下：



详细开启方法参考文档：[启用美观的下载进度条](assets/docs/sources/tutorial/15_download_progress.md)


### 4. 查看本子详情（jmv 命令）

> [!NOTE]
> `jmv` 命令用于快速查看本子详情，不做下载。
> 
> **适用场景**：在某些网站上看到一串*神秘车号*，想快速看看具体是啥本子。此时只需copy原文本，按下 win+R，输入`jmv [粘贴内容]`即可
>
> 支持从任意文本中提取数字作为车号，方便直接粘贴各种格式的车号。

示例：

```sh
# 直接输入车号
jmv 350234

# 从混合文本中提取数字（提取出 350234）
jmv 350谁还没看过234

# 指定option文件（也支持环境变量，用法同上）
jmv 350234 --option="D:/a.yml"

# -y 参数：执行完毕后直接退出，无需按回车确认
jmv 350234 -y
```

输出效果：

```text
🔍 正在查询 禁漫车号 - [350234] 的详情...

──────────────────────────────────────────────────
  📖 标题:  xxx
  🆔 ID:    JM350234
  🔗 链接:  https://18comic.vip/album/350234/
  ✍️ 作者:  Author1, Author2
──────────────────────────────────────────────────
  📅 发布日期:  2022-06-15
  📅 更新日期:  2023-01-01
  📄 总页数:    50
  👀 观看:      2M
  ❤️ 点赞:     77K
  💬 评论:      9801
──────────────────────────────────────────────────
  🏷️ 标签:  标签1, 标签2, ...
  🎭 人物:  角色A, 角色B, ...
  📚 作品:  作品1, 作品2, ...
──────────────────────────────────────────────────
  📑 章节 (2):
     第1話  上  (id: 350234)
     第2話  下  (id: 350235)
──────────────────────────────────────────────────

[运行结束] 请按回车键关闭窗口... (下次运行可附加 -y 参数跳过确认)
```



## 进阶使用

请查阅文档首页 → [jmcomic.readthedocs.io](https://jmcomic.readthedocs.io/zh-cn/latest)

或者查看github仓库的文档 → [github-repo-docs](https://github.com/hect0x7/JMComic-Crawler-Python/blob/master/assets/docs/sources/tutorial/0_common_usage.md)

（提示：jmcomic提供了很多下载配置项，大部分的下载需求你都可以尝试寻找相关配置项或插件来实现。）

## 项目特点

- **绕过Cloudflare的反爬虫**
- **实现禁漫APP接口最新的加解密算法 (1.6.3)**
- 支持**Async**和**Sync**两套 API
- 用法多样：

  - GitHub
    Actions：网页上直接输入本子id就能下载（[教程：使用GitHub Actions下载禁漫本子](./assets/docs/sources/tutorial/1_github_actions.md)）
  - 命令行：无需写Python代码，简单易用（[教程：使用命令行下载禁漫本子](./assets/docs/sources/tutorial/2_command_line.md)）
  - Python代码：最本质、最强大的使用方式，需要你有一定的python编程基础
- 支持**网页端**和**移动端**两种客户端实现，可通过配置切换（**移动端不限ip兼容性好，网页端限制ip地区但效率高**）
- 支持**自动重试和域名切换**机制
- 支持按下载任务维度结构化收集日志
- **可配置性强**

  - 不配置也能使用，十分方便
  - 配置可以从配置文件生成，支持多种文件格式
  - 配置点有：`请求域名` `客户端实现` `是否使用磁盘缓存` `同时下载的章节/图片数量` `图片格式转换` `下载路径规则` `请求元信息（headers,cookies,proxies)` `中文繁/简转换` 
    等
- **可扩展性强**

  - 支持自定义本子/章节/图片下载前后事件的回调函数
  - 支持自定义类：`Downloader（负责调度）` `Option（负责配置）` `Client（负责请求）` `实体类`等
  - 支持自定义日志、异常监听器
  - **支持Plugin插件，可以方便地扩展功能，以及使用别人的插件，目前核心内置插件有**：
    - `登录插件`、`只下载新章插件`、`导出收藏夹为csv文件插件`
    - `合并所有图片为pdf文件插件`、`合并所有图片为长图png插件`
    - `压缩文件插件`、`自动获取浏览器cookies插件`、`订阅更新插件`等

## 使用小说明

* 推荐使用 **Python 3.12+**，目前最低兼容版本为3.9。
  > 注意：Python 3.9 及更早版本皆已于 2025 年彻底结束官方生命周期 (EOL)，使用3.9及以下随时有可能遇到第三方库不兼容的问题。

* 个人项目，文档和示例会有不及时之处，可以Issue提问。

## 项目文件夹介绍

* .github：GitHub Actions配置文件
* assets：存放一些非代码的资源文件

  * docs：项目文档
  * option：存放配置文件
* src：存放源代码

  * jmcomic：`jmcomic`模块
* tests：测试目录，存放测试代码，使用unittest
* usage：用法目录，存放示例/使用代码

## 感谢以下项目

### 图片分割算法代码+禁漫移动端API

<a href="https://github.com/tonquer/JMComic-qt">
  
    
    
    
  
</a>

## used by 项目

<a href="https://github.com/hect0x7/JMComic-Crawler-Python/network/dependents">
  
    
    
    
  
</a>

## Star History

---

## File: assets/docs/sources/api/client.md

# client

::: jmcomic.jm_client_impl
    options:
      members:
      - JmHtmlClient
      - JmApiClient

::: jmcomic.jm_client_interface
    options:
      members:
      - AsyncJmcomicClient

::: jmcomic.jm_async_client
    options:
      members:
      - AsyncJmApiClient

---

## File: assets/docs/sources/api/config.md

# config

::: jmcomic.jm_config
    options:
      members:
      - JmMagicConstants
      - JmModuleConfig
      - default_jm_logging

---

## File: assets/docs/sources/api/download.md

# download

::: jmcomic.api
    options:
      members:
      - download_album
      - download_photo
      - create_option
      - create_option_by_env
      - create_option_by_file
      - create_option_by_str
      - download_album_async
      - download_photo_async
      - download_batch_async

::: jmcomic.jm_async_downloader
    options:
      members:
      - JmAsyncDownloader

---

## File: assets/docs/sources/api/entity.md

# entity

::: jmcomic.jm_entity
    options:
      inherited_members: true
      members:
      - JmAlbumDetail
      - JmPhotoDetail
      - JmImageDetail
      - JmPageContent
      - JmAlbumComment
      - JmAlbumCommentPage
      - JmSearchPage

---

## File: assets/docs/sources/api/option.md

# option

::: jmcomic.jm_option
    options:
      members:
      - DirRule
      - JmOption

---

## File: assets/docs/sources/api/plugin.md

# plugin

::: jmcomic.jm_plugin
    options:
      filters:
      - (Plugin$|JmOptionPlugin|^Plugin)

---

## File: assets/docs/sources/api/toolkit.md

# toolkit

::: jmcomic.jm_toolkit
    options:
      inherited_members: true
      members:
      - JmcomicText
      - PatternTool
      - JmPageTool
      - JmImageTool
      - JmCryptoTool

---

## File: assets/docs/sources/tutorial/0_common_usage.md

# 常用类和方法

## 下载本子/章节

```python
from jmcomic import *

# 下载id为438696的本子 (https://18comic.vip/album/438696)
download_album(438696)

# 下载章节 (https://18comic.vip/photo/438696)
download_photo(438696)

# 同时下载多个本子
download_album([123, 456, 789])

# 查看本子/章节下载位置和耗时
result: DownloadResult = download_album(123)
album: JmAlbumDetail = result.detail # detail是实体类，download_album 返回 album，download_photo 返回 photo
print(f'本子-JM{album.id}，下载保存文件夹: {album.save_path}, 下载耗时: {result.duration:.3f}秒')
# DownloadResult 里还包含大量字段，更多用法请查阅下方章节【下载返回值】
```

## 使用option定制化下载本子

如果你在下载本子时有一些定制化需求，

例如指定禁漫域名，使用代理，登录禁漫，图片格式转换等等，

那么，你可以试试看jmcomic提供的option机制

```python
from jmcomic import *

# 1. 在调用下载api前，通过创建和使用option对象，可以定制化下载行为。
# 推荐使用配置文件的方式来创建option对象，
# 你可以配置很多东西，比如代理、cookies、下载规则等等。
# 配置文件的语法参考: https://jmcomic.readthedocs.io/zh-cn/latest/option_file_syntax/
option = create_option_by_file('op.yml')  # 通过配置文件来创建option对象

# 2. 调用下载api，把option作为参数传递 
download_album(123, option)
# 也可以使用下面这种面向对象的方式，是一样的
option.download_album(123)
```

## 获取本子/章节/图片的实体类，下载图片/封面图

```python
from jmcomic import *

# 客户端
client = JmOption.default().new_jm_client()

# 本子实体类
album: JmAlbumDetail = client.get_album_detail('427413')

# 下载本子封面图，保存为 cover.png （图片后缀可指定为jpg、webp等）
client.download_album_cover('427413', './cover.png')


def fetch(photo: JmPhotoDetail):
    # 章节实体类
    photo = client.get_photo_detail(photo.photo_id, False)
    print(f'章节id: {photo.photo_id}')

    # 图片实体类
    image: JmImageDetail
    for image in photo:
        print(f'图片url: {image.img_url}')

    # 下载单个图片
    client.download_by_image_detail(image, './a.jpg')
    # 如果是已知未混淆的图片，也可以直接使用url来下载
    random_image_domain = JmModuleConfig.DOMAIN_IMAGE_LIST[0]
    client.download_image(f'https://{random_image_domain}/media/albums/416130.jpg', './a.jpg')


# 多线程发起请求
multi_thread_launcher(
    iter_objs=album,
    apply_each_obj_func=fetch
)
```

## jmcomic异常处理示例

```python
from jmcomic import *

# 客户端
client = JmOption.default().new_jm_client()

# 捕获获取本子/章节详情时可能出现的异常
try:
    # 请求本子实体类
    album: JmAlbumDetail = client.get_album_detail('427413')
except MissingAlbumPhotoException as e:
    print(f'id={e.error_jmid}的本子不存在')

except JsonResolveFailException as e:
    print(f'解析json失败')
    # 响应对象
    resp = e.resp
    print(f'resp.text: {resp.text}, resp.status_code: {resp.status_code}')

except RequestRetryAllFailException as e:
    print(f'请求失败，重试次数耗尽')

except JmcomicException as e:
    # 捕获所有异常，用作兜底
    print(f'jmcomic遇到异常: {e}')

# 多线程下载时，可能出现非当前线程下载失败，抛出异常，
# 而JmDownloader有对应字段记录了这些线程发生的异常
# 使用check_exception=True参数可以使downloader主动检查是否存在下载异常
# 如果有，则当前线程会主动上抛一个PartialDownloadFailedException异常
# 该参数主要用于主动检查部分下载失败的情况，（仅对单个本子/章节 ID 生效，传入多个 ID 时不生效。多个 ID 的场景见下）
# 因为非当前线程抛出的异常（比如下载章节的线程和下载图片的线程），这些线程如果抛出异常，
# 当前线程是感知不到的，try-catch下载方法download_album不能捕获到其他线程发生的异常。
try:
    album, downloader = download_album(123, check_exception=True)
except PartialDownloadFailedException as e:
    downloader: JmDownloader = e.downloader
    print(f'下载出现部分失败, 下载失败的章节: {downloader.download_failed_photo}, 下载失败的图片: {downloader.download_failed_image}')

# 多 ID 下载不会因为某一项失败而中断，请检查 BatchResult.failed。
# 如果需要在批量失败时抛异常、重试或拿到更详细信息，建议自行封装一个批量下载方法。
result = download_album([123, 456, 789])
for album_id, error in result.failed.items():
    print(f'本子 {album_id} 下载失败: {error}')
```


## 搜索本子

```python
from jmcomic import *

client = JmOption.default().new_jm_client()

# 分页查询，search_site就是禁漫网页上的【站内搜索】
page: JmSearchPage = client.search_site(search_query='+MANA +无修正', page=1)
print(f'结果总数: {page.total}, 分页大小: {page.page_size}，页数: {page.page_count}')

# page默认的迭代方式是page.iter_id_title()，每次迭代返回 albun_id, title
for album_id, title in page:
    print(f'[{album_id}]: {title}')

# 直接搜索禁漫车号
page = client.search_site(search_query='427413')
album: JmAlbumDetail = page.single_album
print(album.tags)
```

## 搜索并下载本子

```python
from jmcomic import *

option = JmOption.default()
client = option.new_jm_client()

tag = '無修正'
# 搜索标签，可以使用search_tag。
# 搜索第一页。
page: JmSearchPage = client.search_tag(tag, page=1)

aid_list = []

for aid, atitle, tag_list in page.iter_id_title_tag():  # 使用page的iter_id_title_tag迭代器
    if tag in tag_list:
        print(f'[标签/{tag}] 发现目标: [{aid}]: [{atitle}]')
        aid_list.append(aid)

download_album(aid_list, option)
```

## 获取评论

目前支持获取两种评论：

| 评论类型 | 说明 | 获取一页的方法 | 自动翻页的方法 |
| --- | --- | --- | --- |
| 本子评论 | 获取指定本子下的评论和回评 | `album_pagination` | `album_pagination_gen` |
| 全站评论 | 获取全站最新发布的评论，可通过 `comment.album_id` 知道评论来自哪个本子 | `forum_pagination` | `forum_pagination_gen` |

评论页可以直接遍历，评论对象也可以直接打印，格式为 `[评论ID] 用户（剧透）: 内容`。

### 获取本子评论

`album_pagination` 获取指定本子的一页评论。

```python
from jmcomic import JmOption, JmAlbumComment

client = JmOption.default().new_jm_client(impl='api')
page = client.album_pagination('123456')

print(f'第 {page.page_number}/{page.page_count} 页，当前页一共 {len(page)} 条主评论，整本一共 {page.total} 条主评论')
for comment in page:
    comment: JmAlbumComment # 评论是实体类
    print(
        f'评论ID: {comment.comment_id} | 用户ID: {comment.user_id} | 用户: {comment.nickname or comment.username} | '
        f'是否剧透: {comment.is_spoiler} | 点赞数: {comment.likes} | 发布时间: {comment.created_at}\n内容: {comment.content}'
    )
    for reply in comment.replies:
        reply: JmAlbumComment # 回评也是相同的实体类
        print('  └─', reply)

# 需要连续获取分页时，使用生成器：
for page in client.album_pagination_gen('123456'):
    print(f'\n第 {page.page_number} 页')
    for comment in page:
        print(comment)
```

### 获取全站评论

`forum_pagination` 获取一页全站评论。

> [!NOTE]
> html 端不提供 `total`（全部分页的主评论总数）和 `page_count`（总页数），这两个字段都为 `None`，api端则有值。

```python
page = client.forum_pagination(page=1)

print(f'第 {page.page_number}/{page.page_count} 页，当前页一共 {len(page)} 条主评论，全站一共 {page.total} 条主评论')
for comment in page:
    print(f'本子 {comment.album_id} | {comment}')

# 全站评论同样支持生成器
for page in client.forum_pagination_gen(page=1):
    print(f'\n第 {page.page_number} 页')
    for comment in page:
        print(f'本子 {comment.album_id} | {comment}')
```

## 获取收藏夹

可参考discussions: https://github.com/hect0x7/JMComic-Crawler-Python/discussions/235

### 一键导出全部收藏夹

下面的代码不会下载图片，只会把帐号中的全部收藏夹导出为 CSV，并生成 `favorites.zip`：

```python
from jmcomic import JmOption, FavoriteFolderExportPlugin

USERNAME = '你的禁漫帐号'
PASSWORD = '你的禁漫密码'

option = JmOption.default()
option.build_jm_client().login(USERNAME, PASSWORD)

FavoriteFolderExportPlugin(option).invoke(
    save_dir='./',
    zip_enable=True,
    zip_filepath='./favorites.zip',
)
```

### 获取并遍历收藏夹

```python
from jmcomic import *

option = JmOption.default()
client = option.new_jm_client()
client.login('用户名', '密码')  # 也可以使用login插件/配置cookies

# 遍历全部收藏的所有页
for page in client.favorite_folder_gen():  # 如果你只想获取特定收藏夹，需要添加folder_id参数
    # 遍历每页结果
    for aid, atitle in page.iter_id_title():
        # aid: 本子的album_id
        # atitle: 本子的名称
        print(aid)
    # 打印当前帐号的所有收藏夹信息
    for folder_id, folder_name in page.iter_folder_id_name():
        print(f'收藏夹id: {folder_id}, 收藏夹名称: {folder_name}')

# 获取特定收藏夹的单页，使用favorite_folder方法
page = client.favorite_folder(page=1,
                              order_by=JmMagicConstants.ORDER_BY_LATEST,
                              folder_id='0'  # 收藏夹id
                              )
```

## 分类 / 排行榜

禁漫的分类是一个和搜索有些类似的功能。

搜索是按某一条件进行过滤。

分类没有过滤，就是把某一类别（category）下的本子全都调出来。

禁漫的排行榜就是分类的一种形式

下面演示调用分类api

```python
from jmcomic import *

# 创建客户端
op = JmOption.default()
cl = op.new_jm_client()

# 调用分类接口
# 根据下面的参数，这个调用的意义就是：
# 在全部分类下，选择所有时间范围，按观看数排序后，获取第一页的本子
page: JmCategoryPage = cl.categories_filter(
    page=1,
    time=JmMagicConstants.TIME_ALL,  # 时间选择全部，具体可以写什么请见JmMagicConstants
    category=JmMagicConstants.CATEGORY_ALL,  # 分类选择全部，具体可以写什么请见JmMagicConstants
    order_by=JmMagicConstants.ORDER_BY_VIEW,  # 按照观看数排序，具体可以写什么请见JmMagicConstants
)

# 月排行，底层实现也是调的categories_filter
page: JmCategoryPage = cl.month_ranking(1)
# 周排行
page: JmCategoryPage = cl.week_ranking(1)

# 循环获取分页，使用 cl.categories_filter_gen
# 基础用法：简单的 for 循环
for page in cl.categories_filter_gen(page=1, # 起始页码
                                     # 下面是分类参数
                                     time=JmMagicConstants.TIME_WEEK,
                                     category=JmMagicConstants.CATEGORY_ALL,
                                     order_by=JmMagicConstants.ORDER_BY_VIEW,
                                     ):
    for aid, atitle in page:
        print(aid, atitle)

# 高级用法：使用 generator 的 send() 方法在遍历中途动态修改查询条件
# 注意：必须用 while 循环手动接收 send() 的返回值，避免在 for 循环内调用 send() 跳过分页
generator = cl.categories_filter_gen(page=1, time=JmMagicConstants.TIME_WEEK)
try:
    page = next(generator)  # 预先启动生成器
    while True:
        # 打印第一页
        for aid, atitle in page:
            print(aid, atitle)
        
        # 假设我们只想看前一页，下一页想换一个排序方式
        # 调用 send 传入包含新参数的 dict 即可覆盖原来的查询条件
        page = generator.send({"order_by": JmMagicConstants.ORDER_BY_LATEST})
except StopIteration:
    pass

```

## 高级搜索（分类/副分类）

禁漫网页端的搜索除了常规条件，还支持【分类】和【副分类】的搜索。

在任一搜索页面，你会看到本子图的右上方有两个标签。左边的是【分类】，右边的是【副分类】。

下面演示代码如何编写。

* **注意！！禁漫移动端没有提供如下功能，以下代码仅对网页端生效。**

```python
# 在编写代码前，建议先熟悉禁漫网页的搜本功能，下面的代码都是对照网页编写的。
# 网页搜索示例：https://18comic.vip/search/photos/doujin/sub/CG?main_tag=0&search_query=mana&page=1&o=mr&t=a

from jmcomic import *

op = create_option_by_file('op.yml')
# 创建网页端client
html_cl = op.new_jm_client(impl='html')

# 使用站内搜索，指定【分类】和【副分类】
# 分类 = JmMagicConstants.CATEGORY_DOUJIN = 同人本
# 副分类 = JmMagicConstants.SUB_DOUJIN_CG = CG本
# 实际URL：https://18comic.vip/search/photos/doujin/sub/CG?main_tag=0&search_query=mana&page=1&o=mr&t=a
page = html_cl.search_site(search_query='mana',
                           category=JmMagicConstants.CATEGORY_DOUJIN,
                           sub_category=JmMagicConstants.SUB_DOUJIN_CG,
                           page=1,
                           )
# 打印page内容
for aid, atitle in page.iter_id_title():
    print(aid, atitle)

# 循环获取分页
for page in html_cl.search_gen(search_query='mana',
                               category=JmMagicConstants.CATEGORY_DOUJIN,
                               sub_category=JmMagicConstants.SUB_DOUJIN_CG,
                               page=1,  # 起始页码
                               ):
    # 打印page内容
    for aid, atitle in page.iter_id_title():
        print(aid, atitle)

# 高级用法：使用 generator 的 send() 方法进行手动翻页或修改查询条件
generator = html_cl.search_gen('mana')
try:
    page = next(generator)
    while True:
        for aid, atitle in page.iter_id_title():
            print(aid, atitle)
        
        # 可直接动态传参改变搜索条件，例如下一页换成搜索 'nana'
        page = generator.send({"search_query": 'nana'})
except StopIteration:
    pass
```


## 手动创建Client

```python
# 默认的使用方式是先创建option，option封装了所有配置，然后由option.new_jm_client() 创建客户端client，使用client可以访问禁漫接口

# 下面演示直接构造client的方式
from jmcomic import *

"""
创建JM客户端

:param postman: 负责实现HTTP请求的对象，持有cookies、headers、proxies等信息
:param domain_list: 禁漫域名
:param retry_times: 重试次数
"""

# 网页端
cl = JmHtmlClient(
    postman=JmModuleConfig.new_postman(),
    domain_list=['18comic.vip'],
    retry_times=1
)

# API端（APP）
cl = JmApiClient(
    postman=JmModuleConfig.new_postman(),
    domain_list=JmModuleConfig.DOMAIN_API_LIST,
    retry_times=1
)
```


## 下载返回值

`download_album` 和 `download_photo` 下载完成后，单个 ID 返回 `DownloadResult`，多个 ID 返回 `BatchResult`。

从 `result.detail` 可以取得下载的本子/章节的实体类：

```python
from jmcomic import download_album, download_photo

# 下载本子；result.detail 是本子实体
result = download_album('123')
album = result.detail
print(f'本子实体: {album}')

# 下载章节；result.detail 是章节实体
result = download_photo('456')
photo = result.detail
print(f'章节实体: {photo}')
```

### 获取保存路径和耗时

下载完成后，你通常会关心两件事：文件保存在哪里，以及哪一步比较慢。下表介绍获取方法。

| 对象 | `save_path` | `duration`（单位-秒）                                |
| --- | --- |------------------------------------------------------|
| 下载结果 `result` | 通过 `result.detail.save_path` 查看 | 从调用下载方法到返回，你总共等了多久                 |
| 本子 `album` | 本子根目录 | 下载这个本子花了多久。包含获取详情、下载章节和插件   |
| 章节 `photo` | 章节图片目录 | 下载这个章节花了多久。包含获取详情、下载图片和插件   |
| 图片 `image` | 图片文件路径 | 下载这张图片花了多久。包含检查缓存、下载、保存和插件 |

> [!NOTE]
> 一次本子下载可能同时处理多个章节，一个章节也可能同时处理多张图片。因此，把所有章节或图片的耗时相加，不会得到本子的耗时，这是正常现象。



完整示例：查看路径、耗时和缓存状态

```python
from jmcomic import download_album

result = download_album('123')
album = result.detail

# result.duration 是调用下载方法后总共等待的时间
print(f'本子目录: {album.save_path}，总共等待: {result.duration:.2f} 秒')
print(f'下载本子用了: {album.duration:.2f} 秒')

for photo in album:
    # 查看每个章节的保存目录和处理耗时
    print(f'章节 {photo.id} 目录: {photo.save_path}，耗时: {photo.duration:.2f} 秒')

    for image in photo:
        # exists 和 cache 都为 True，表示满足缓存复用条件
        not_download = image.exists and image.cache
        print(
            f'图片 {image.filename} 路径: {image.save_path}，'
            f'耗时: {image.duration:.2f} 秒，是否因存在而跳过下载: {not_download}'
        )
```

`not_download` 为 `True`，表示目标图片原本存在并且允许使用缓存。



### 使用 `manifest` 获取结果文件

相比于遍历实体类， `manifest` 提供了更直接的写法，适合场景：直接取得全部图片路径，以及取得**额外产物**（由插件/Feature产出的 PDF、ZIP、长图）

| 想要什么                                   | 推荐写法 |
|--------------------------------------------| --- |
| 查看某张图片的路径、耗时和缓存状态         | 遍历实体，读取 `image.save_path` 等字段 |
| 直接获取所有图片路径（包含命中缓存的图片） | 用`manifest`，`result.manifest.image_filepath_list` |
| 额外产物（PDF、ZIP 或长图）                | 用`manifest`，`result.manifest.get_export_filepath_list('文件后缀')` |


完整示例：获取图片和导出文件

```python
from jmcomic import Feature, download_album

# 下载本子，并使用内置 Feature 导出 PDF、ZIP 和长图
result = download_album(
    '123',
    extra=Feature.export_pdf + Feature.export_zip + Feature.export_long_img,
)

# 本次成功下载或直接复用的图片路径
print('图片文件:', result.manifest.image_filepath_list)

# 插件导出的文件按后缀查询，后缀前面的点可以省略
pdf_filepath_list = result.manifest.get_export_filepath_list('pdf')
zip_filepath_list = result.manifest.get_export_filepath_list('.zip')
png_filepath_list = result.manifest.get_export_filepath_list('png')

print('PDF 文件:', pdf_filepath_list)
print('ZIP 文件:', zip_filepath_list)
print('长图导出文件:', png_filepath_list)
```



### 批量下载的返回值

传入多个 ID 时，返回值是 `BatchResult`。每一项成功下载对应一个 `DownloadResult`，失败任务则记录在 `failed` 中：


完整示例：处理批量下载结果

```python
from jmcomic import download_album

# 同时下载多个本子
batch_result = download_album(['123', '456', '789'])

# BatchResult 继承 set，成功结果没有输入顺序保证
for result in batch_result:
    album = result.detail
    # 通过实体 ID 识别当前结果，不要用遍历位置对应输入列表
    print(f'JM{album.id} 下载到: {album.save_path}')

# failed 的键是下载失败的 ID，值是记录失败原因的异常对象
for album_id, error in batch_result.failed.items():
    print(f'JM{album_id} 下载失败: {error}')

# total 是实际处理的不同 ID 数量，重复 ID 不会重复下载
print('任务总数:', batch_result.total)
print('是否全部成功:', batch_result.all_succeeded)
```



下载单个 ID 时，请求本子失败会直接抛出异常；如果只有部分章节或图片失败，会在任务结束后汇总抛出 `PartialDownloadFailedException`，此时不会返回 `DownloadResult`。批量下载则继续执行其他任务，并把失败项集中放进 `batch_result.failed`。

### 速查表

| 你的需求 | 推荐写法 |
| --- | --- |
| 查看本子或章节信息 | `result.detail` |
| 查看本子或章节目录 | `result.detail.save_path` |
| 查看单张图片路径和状态 | 遍历实体后读取 `image.save_path` 等字段 |
| 查看图片或章节失败原因 | 捕获 `PartialDownloadFailedException` 后，读取 `e.downloader.download_failed_image` / `download_failed_photo`；列表元素为 `(实体, 异常)` |
| 获取本次成功图片路径列表 | `result.manifest.image_filepath_list` |
| 获取已登记的导出文件路径 | `result.manifest.get_export_filepath_list('后缀')` |
| 查看单个 ID 下载的完整时间 | `result.duration` |
| 定位本子、章节或图片的内部处理慢点 | 对应实体的 `duration` |
| 检查批量下载失败项 | `batch_result.failed` |


兼容旧版本的返回值解包写法

旧代码可能会把返回值直接解包成两个变量，这种写法仍然可以继续使用：

```python
from jmcomic import download_album

result = download_album('123')

# 旧写法：第一个变量是本子实体，第二个变量是下载器
album, downloader = result

# 新代码更推荐直接通过 result.detail 读取本子实体
assert album is result.detail
```