Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
> **🧭 快速指路**
> - [教程:使用 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的服务器压力,请不要一次性爬取太多本子,西门🙏🙏🙏**.
Expand Down
1 change: 1 addition & 0 deletions assets/docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ nav:
- tutorial/10_export_favorites.md
- tutorial/11_log_custom.md
- tutorial/12_domain_strategy.md
- tutorial/13_export_and_feature.md

plugins:
- search
Expand Down
2 changes: 2 additions & 0 deletions assets/docs/sources/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

- [快速上手(GitHub README)](https://github.com/hect0x7/JMComic-Crawler-Python/tree/master?tab=readme-ov-file#%E5%BF%AB%E9%80%9F%E4%B8%8A%E6%89%8B)
- [常用类和方法演示](tutorial/0_common_usage.md)
- [下载同时转 PDF/ZIP/长图](tutorial/13_export_and_feature.md)
- [option配置以及插件写法](./option_file_syntax.md)

## 特殊用法教程
Expand All @@ -30,6 +31,7 @@

- [下载过滤器机制](tutorial/5_filter.md)
- [插件机制](tutorial/6_plugin.md)
- [Feature机制](tutorial/13_export_and_feature.md)

## 自定义

Expand Down
23 changes: 11 additions & 12 deletions assets/docs/sources/option_file_syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,29 +223,28 @@ plugins:
rule: '{Atitle}/{Aid}_cover.jpg'


after_album:
after_album: # 钩子(插件被调用时机)
- plugin: zip # 压缩文件插件
kwargs:
level: photo # 按照章节,一个章节一个压缩文件
# level 也可以配成 album,表示一个本子对应一个压缩文件,该压缩文件会包含这个本子的所有章节

filename_rule: Ptitle # 压缩文件的命名规则
# 请注意⚠ [https://github.com/hect0x7/JMComic-Crawler-Python/issues/223#issuecomment-2045227527]
# filename_rule和level有对应关系
# 如果level=[photo], filename_rule只能写Pxxx
# 如果level=[album], filename_rule只能写Axxx
# 压缩文件插件,配在不同钩子下面,效果不一样。可以选择配在 after_album 或者 after_photo 下
# 配置在 after_album 下 → 整个本子合并为一个压缩文件
# 配置在 after_photo 下 → 每个章节各一个压缩文件
# (旧的 level 配置已废弃,如果你配置过level,比如level=photo, 请直接改用after_photo)

zip_dir: D:/jmcomic/zip/ # 压缩文件存放的文件夹

suffix: zip #压缩包后缀名,默认值为zip,可以指定为zip或者7z
filename_rule: Atitle # 压缩文件的命名规则
# 请注意⚠ [https://github.com/hect0x7/JMComic-Crawler-Python/issues/223#issuecomment-2045227527]
# filename_rule和所在钩子有对应关系
# 如果配置在 after_photo 下, filename_rule只能写 Pxxx
# 如果配置在 after_album 下, filename_rule只能写 Axxx

# v2.6.0 以后,zip插件也支持dir_rule配置项,可以替代旧版本的zip_dir和filename_rule
# zip插件也支持dir_rule配置项,可以替代旧版本的zip_dir和filename_rule
# 请注意⚠ 使用此配置项会使filename_rule,zip_dir,suffix三个配置项无效,与这三个配置项同时存在时仅会使用dir_rule
# 示例如下:
# dir_rule: # 新配置项,可取代旧的zip_dir和filename_rule
# base_dir: D:/jmcomic-zip
# rule: 'Bd / {Atitle} / [{Pid}]-{Ptitle}.zip' # 设置压缩文件夹规则,中间Atitle表示创建一层文件夹,名称是本子标题。[{Pid}]-{Ptitle}.zip 表示压缩文件的命名规则(需显式写出后缀名)
# 使用此方法指定压缩包存储路径则无需和level对应

delete_original_file: true # 压缩成功后,删除所有原文件和文件夹

Expand Down
203 changes: 203 additions & 0 deletions assets/docs/sources/tutorial/13_export_and_feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Feature 机制——下载附加行为

## 1. 需求场景

下载本子后,很多用户有进一步导出的需求:
- 导出为 **PDF**:方便在电子阅读器上查看
- 导出为 **ZIP**:方便传输和存档
- 合并为 **长图**:方便一张图看完整个章节

jmcomic 内置了三个开箱即用的导出 Feature,对应这三种需求:

| Feature | 效果 |
|---------|------|
| `Feature.export_pdf` | 下载完自动导出为 PDF |
| `Feature.export_zip` | 下载完自动打包为 ZIP |
| `Feature.export_long_img` | 下载完自动拼接为长图 PNG |


> 也许你知道,这些功能之前是以插件形式 (JmOptionPlugin) 存在的。
>
> 是的,传统方式需要在 option 配置文件中编写插件配置,门槛偏高。
>
> 因此,从v2.6.19起,jmcomic 引入了上述的 **Feature** 机制,尽可能简化这些最常用的功能,让小白也能用一行代码搞定导出。


## 2. 快速上手

### 2.1 导出 PDF——基本用法示例

```python
from jmcomic import download_album, Feature

# 只需要加一个 extra 参数,就能在下载完成后自动导出 PDF
download_album('123', extra=Feature.export_pdf)

# 如果要传 option 参数,就是如下写法,三个参数
download_album('123', option, extra=Feature.export_pdf)
```

**效果**:在本子下载完以后,额外在**当前工作目录**下生成包含所有本子图片的 PDF 文件:

```
./
├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF,注意pdf文件名的格式,默认包含本子禁漫车号+本子标题
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### 2.2 需要多种导出格式(PDF、ZIP等)——直接组合 Feature

用 `+` 号组合,同时导出多种格式:

```python
# 下载完后同时导出 PDF 和 ZIP
download_album('123', option, extra=Feature.export_pdf + Feature.export_zip)

# 也支持列表语法,|语法
download_album('123', option, extra=[Feature.export_pdf, Feature.export_zip])
download_album('123', option, extra=Feature.export_pdf | Feature.export_zip)
```
Comment on lines +55 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Replace fullwidth with ASCII | in the OR-syntax example.

Line 57 uses (U+FF5C, FULLWIDTH VERTICAL LINE), which is not the Python bitwise-or operator. A user copy-pasting this snippet will hit a SyntaxError.

🔧 Proposed fix
 # 也支持列表语法,|语法
 download_album('123', option, extra=[Feature.export_pdf, Feature.export_zip])
-download_album('123', option, extra=Feature.export_pdf | Feature.export_zip)
+download_album('123', option, extra=Feature.export_pdf | Feature.export_zip)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@assets/docs/sources/tutorial/13_export_and_feature.md` around lines 55 - 58,
The OR-syntax example uses a fullwidth vertical bar (|) which is not Python's
bitwise-or operator; update the example in the download_album call to use the
ASCII pipe character so it reads extra=Feature.export_pdf | Feature.export_zip
(refer to the download_album(...) example and the symbols Feature.export_pdf and
Feature.export_zip).


效果同pdf,会在本子下载完以后,额外在当前工作目录下,生成包含所有本子图片的 PDF 文件和 ZIP 文件:

```
./
├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF
├── [JM123]本子标题.zip ← 整本合并为 1 个 zip 压缩包
```


### 2.3 自定义参数

如果你了解插件配置,可以同样使用Feature传递插件的自定义参数,例如改变输出目录、命名规则等:

```python
# 示例 1:指定输出目录和命名规则
download_album('123', option, extra=Feature.export_pdf(
# 下面是自定义参数
pdf_dir='D:/my_pdfs', # PDF 保存到 D:/my_pdfs 文件夹
filename_rule='Ptitle', # 用章节标题作为文件名
delete_original_file=True, # 合并完 PDF 后删除原图
))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 示例 2:全都要——ZIP 存盘 + 长图阅读
combo = (
Feature.export_zip(zip_dir='D:/zips')
+ Feature.export_long_img(img_dir='D:/long_imgs')
)
download_album('123', option, extra=combo)
```

### 2.4 download_photo 也支持

```python
from jmcomic import download_photo, Feature

# 对单个章节导出
download_photo('456', option, extra=Feature.export_pdf)
```

效果:在当前工作目录下生成以章节标题命名的 PDF:

```
./
├── [章节标题].pdf ← 该章节导出为 1 个 PDF
```

> 💡 **提示**:同一个 Feature,通过 `download_album` 和 `download_photo` 调用时会自动适配不同的导出行为,详见下方 [智能适配规则](#25-智能适配规则)。

### 2.5 智能适配规则

内置的导出 Feature 会根据调用的 API **自动适配**参数:

| 调用方式 | Feature.export_pdf | Feature.export_zip | Feature.export_long_img |
|-----------------|-------------------|-------------------|----------------------|
| `download_album` | 整本合并为 1 个 PDF<br>`[本子标题].pdf` | 整本打包为 1 个 ZIP<br>`[本子标题].zip` | 所有章节合并为 1 张长图<br>`[本子ID].png` |
| `download_photo` | 该章节导出为 PDF<br>`[章节标题].pdf` | 该章节打包为 ZIP<br>`[章节标题].zip` | 该章节拼接为长图<br>`[章节ID].png` |

当你显式传入参数时(如 `filename_rule='Ptitle'`),**你的配置优先**,不会被自适应覆盖。

> 💡 **提示**:更多可选参数(如加密密码 `encrypt`、后缀名 `suffix` 等),参考 [Plugin 插件参数大全](./6_plugin.md#参数)。

## 3. 传统写法(YAML 插件配置)

如果你更习惯配置文件,仍然可以使用传统的插件配置方式:

```yaml
# option.yml
plugins:
after_album: # 整本下载完以后
- plugin: img2pdf # 合并pdf
kwargs:
pdf_dir: ./output
filename_rule: Atitle
- plugin: zip # 合并为压缩文件
kwargs:
level: album
zip_dir: ./output
```

传统写法的更多细节见 → [Plugin 插件教程](./6_plugin.md)

## 4. Feature 架构设计

### 类层次

```
Feature (基类)
├── PluginFeature ← 封装插件调用,参数根据来源自适应
└── 你的自定义 Feature ← 继承 Feature,实现任意逻辑
```

- **Feature 基类**:通用的附加行为抽象,不绑定任何具体实现。默认在所有生命周期钩子中执行。
- **PluginFeature**:Feature 的子类,专门封装 jmcomic 插件。除了调用插件之外,还会根据调用来源动态适配 `filename_rule`、`level` 等参数。

### 执行流程

Feature **自然嵌入到 downloader 的生命周期钩子**中自动触发:

```
api.download_album(extra=Feature.export_pdf)
├→ dler.add_features(pdf, 'download_album') # 注册: [(pdf, 'download_album')]
└→ dler.download_album(id)
├→ before_album(album)
├→ download_by_photo_detail(photo)
│ ├→ before_photo(photo)
│ ├→ download jmcomic images ... # 下载禁漫图片
│ └→ after_photo(photo)
│ └→ _invoke_features_for('after_photo')
│ └→ pdf.should_invoke('after_photo', 'download_album') → False ✗ 跳过
└→ after_album(album)
└→ _invoke_features_for('after_album')
└→ pdf.should_invoke('after_album', 'download_album') → True ✓ 执行!
└→ _adapt_plugin_kwargs(from, when) # 动态生成插件参数
└→ option.invoke(pdf, kwargs) # 调用pdf插件,传入参数
```

> 💡 **关键点**:
>
> - **执行时机**:`PluginFeature` 根据注册来源自动推导(`download_album` → `after_album`,`download_photo` → `after_photo`)。自定义 Feature 默认在所有钩子都会执行,你可以覆写 `should_invoke` 来控制。
> - **参数自适应**:`PluginFeature` 的 `filename_rule` 前缀(A/P)和 `level`(album/photo)会根据来源动态适配。用户显式传入的参数不会被覆盖。
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### 自定义 Feature

Feature 基类完全不绑定插件,你可以实现任意逻辑,欢迎贡献你的feature到本项目中:

```python
from jmcomic import Feature, download_album

class NotifyFeature(Feature):
"""下载完成后发送通知"""
def invoke(self, option, **kwargs):
album = kwargs.get('album')
if album:
print(f'下载完成通知: {album.name}')

# 使用
download_album('123', option, extra=NotifyFeature())
```

3 changes: 2 additions & 1 deletion src/jmcomic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
# 被依赖方 <--- 使用方
# config <--- entity <--- toolkit <--- client <--- option <--- downloader

__version__ = '2.6.18'
__version__ = '2.6.19'

from .api import *
from .jm_plugin import *
from .jm_feature import *

# 下面进行注册组件(客户端、插件)
gb = dict(filter(lambda pair: isinstance(pair[1], type), globals().items()))
Expand Down
13 changes: 11 additions & 2 deletions src/jmcomic/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ def download_batch(download_api,
jm_id_iter: Union[Iterable, Generator],
option=None,
downloader=None,
**kwargs,
) -> Set[__DOWNLOAD_API_RET]:
"""
批量下载 album / photo
Expand Down Expand Up @@ -37,6 +38,7 @@ def callback(*ret):
option,
downloader,
callback=callback,
**kwargs,
),
wait_finish=True
)
Expand All @@ -49,6 +51,7 @@ def download_album(jm_album_id,
downloader=None,
callback=None,
check_exception=True,
extra=None,
) -> Union[__DOWNLOAD_API_RET, Set[__DOWNLOAD_API_RET]]:
"""
下载一个本子(album),包含其所有的章节(photo)
Expand All @@ -60,13 +63,16 @@ def download_album(jm_album_id,
:param downloader: 下载器类
:param callback: 返回值回调函数,可以拿到 album 和 downloader
:param check_exception: 是否检查异常, 如果为True,会检查downloader是否有下载异常,并上抛PartialDownloadFailedException
:param extra: 下载特性(Feature),下载时动态挂载的附加行为上下文。会自动根据上下文(如 album/photo 来源)自适应参数行为。支持单个 Feature、FeatureChain、或列表
:return: 对于的本子实体类,下载器(如果是上述的批量情况,返回值为download_batch的返回值)
"""

if not isinstance(jm_album_id, (str, int)):
return download_batch(download_album, jm_album_id, option, downloader)
return download_batch(download_album, jm_album_id, option, downloader, extra=extra)

with new_downloader(option, downloader) as dler:
# 注册 Feature 及来源,由 downloader 在 after_album 钩子中自动执行
dler.add_features(extra, 'download_album')
album = dler.download_album(jm_album_id)

if callback is not None:
Expand All @@ -81,14 +87,17 @@ def download_photo(jm_photo_id,
downloader=None,
callback=None,
check_exception=True,
extra=None,
):
"""
下载一个章节(photo),参数同 download_album
"""
if not isinstance(jm_photo_id, (str, int)):
return download_batch(download_photo, jm_photo_id, option)
return download_batch(download_photo, jm_photo_id, option, downloader, extra=extra)

with new_downloader(option, downloader) as dler:
# 注册 Feature 及来源,由 downloader 在 after_photo 钩子中自动执行
dler.add_features(extra, 'download_photo')
photo = dler.download_photo(jm_photo_id)

if callback is not None:
Expand Down
Loading
Loading