Skip to content

Commit b5d9858

Browse files
committed
v2.6.19: 新增 Feature 机制,支持下载时通过 extra 参数附加导出 PDF/ZIP/长图等行为; Feature 参数根据 download_album/download_photo 来源自适应; 新增 Feature 教程文档
1 parent b063ce5 commit b5d9858

11 files changed

Lines changed: 644 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
> **🧭 快速指路**
3131
> - [教程:使用 GitHub Actions 下载禁漫本子](./assets/docs/sources/tutorial/1_github_actions.md)
3232
> - [教程:导出并下载你的禁漫收藏夹数据](./assets/docs/sources/tutorial/10_export_favorites.md)
33+
> - [教程:下载后转为 PDF / ZIP](./assets/docs/sources/tutorial/13_export_and_feature.md)
3334
> - [塔台广播:欢迎各位机长加入并贡献代码](./.github/CONTRIBUTING.md)
3435
>
3536
> **友情提示:珍爱JM,为了减轻JM的服务器压力,请不要一次性爬取太多本子,西门🙏🙏🙏**.

assets/docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ nav:
5858
- tutorial/10_export_favorites.md
5959
- tutorial/11_log_custom.md
6060
- tutorial/12_domain_strategy.md
61+
- tutorial/13_export_and_feature.md
6162

6263
plugins:
6364
- search

assets/docs/sources/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
- [快速上手(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)
1919
- [常用类和方法演示](tutorial/0_common_usage.md)
20+
- [下载同时转 PDF/ZIP/长图](tutorial/13_export_and_feature.md)
2021
- [option配置以及插件写法](./option_file_syntax.md)
2122

2223
## 特殊用法教程
@@ -30,6 +31,7 @@
3031

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

3436
## 自定义
3537

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# Feature 机制——下载附加行为
2+
3+
## 1. 需求场景
4+
5+
下载本子后,很多用户有进一步导出的需求:
6+
- 导出为 **PDF**:方便在电子阅读器上查看
7+
- 导出为 **ZIP**:方便传输和存档
8+
- 合并为 **长图**:方便一张图看完整个章节
9+
10+
jmcomic 一直通过内置插件(`img2pdf``zip``long_img`)支持这些功能,但传统方式需要在 YAML 配置文件中编写插件配置,门槛偏高。
11+
12+
从最新版本起,jmcomic 引入了 **Feature(特性)** 机制——一套通用的**下载附加行为系统**,让你用一行代码搞定导出。Feature 不仅能调用插件,还能封装任意自定义逻辑(通知、清理等),并且会根据调用方式自动选择最合理的配置。
13+
14+
内置了三个开箱即用的导出 Feature:
15+
16+
| Feature | 效果 |
17+
|---------|------|
18+
| `Feature.export_pdf` | 下载完自动导出为 PDF |
19+
| `Feature.export_zip` | 下载完自动打包为 ZIP |
20+
| `Feature.export_long_img` | 下载完自动拼接为长图 PNG |
21+
22+
## 2. 快速上手
23+
24+
### 2.1 导出 PDF——基本用法示例
25+
26+
```python
27+
from jmcomic import download_album, Feature
28+
29+
# 只需要加一个 extra 参数,就能在下载完成后自动导出 PDF
30+
download_album('123', option, extra=Feature.export_pdf)
31+
```
32+
33+
效果:在**当前工作目录**下生成以本子标题命名的 PDF 文件:
34+
35+
```
36+
./
37+
├── [本子标题].pdf ← 整本合并为 1 个 PDF
38+
```
39+
40+
### 2.2 需要多种导出格式(PDF、ZIP)——直接组合 Feature
41+
42+
`+` 号组合,同时导出多种格式:
43+
44+
```python
45+
# 下载完后同时导出 PDF 和 ZIP
46+
download_album('123', option, extra=Feature.export_pdf + Feature.export_zip)
47+
48+
# 也支持列表语法
49+
download_album('123', option, extra=[Feature.export_pdf, Feature.export_zip])
50+
```
51+
52+
### 2.3 自定义参数
53+
54+
像调用函数一样传入自定义参数,可以改变输出目录、命名规则等:
55+
56+
```python
57+
# 示例 1:指定输出目录和命名规则
58+
download_album('123', option, extra=Feature.export_pdf(
59+
pdf_dir='D:/my_pdfs', # PDF 保存到 D:/my_pdfs 文件夹
60+
filename_rule='Ptitle', # 用章节标题作为文件名
61+
delete_original_file=True, # 合并完 PDF 后删除原图
62+
))
63+
64+
# 示例 2:全都要——ZIP 存盘 + 长图阅读
65+
combo = (
66+
Feature.export_zip(zip_dir='D:/zips')
67+
+ Feature.export_long_img(img_dir='D:/long_imgs')
68+
)
69+
download_album('123', option, extra=combo)
70+
```
71+
72+
### 2.4 download_photo 也支持
73+
74+
```python
75+
from jmcomic import download_photo, Feature
76+
77+
# 对单个章节导出
78+
download_photo('456', option, extra=Feature.export_pdf)
79+
```
80+
81+
效果:在当前工作目录下生成以章节标题命名的 PDF:
82+
83+
```
84+
./
85+
├── [章节标题].pdf ← 该章节导出为 1 个 PDF
86+
```
87+
88+
> 💡 **提示**:同一个 Feature,通过 `download_album``download_photo` 调用时会自动适配不同的导出行为,详见下方 [智能适配规则](#智能适配规则)
89+
90+
### 2.5 智能适配规则
91+
92+
内置的导出 Feature 会根据调用的 API **自动适配**参数(命名规则、打包级别等):
93+
94+
| 调用方式 | Feature.export_pdf | Feature.export_zip | Feature.export_long_img |
95+
|---------|-------------------|-------------------|----------------------|
96+
| `download_album` | 整本合并为 1 个 PDF<br>`[本子标题].pdf` | 整本打包为 1 个 ZIP<br>`[本子标题].zip` | 所有章节合并为 1 张长图<br>`[本子ID].png` |
97+
| `download_photo` | 该章节导出为 PDF<br>`[章节标题].pdf` | 该章节打包为 ZIP<br>`[章节标题].zip` | 该章节拼接为长图<br>`[章节ID].png` |
98+
99+
当你显式传入参数时(如 `filename_rule='Ptitle'`),**你的配置优先**,不会被自适应覆盖。
100+
101+
> 💡 **提示**:更多可选参数(如加密密码 `encrypt`、后缀名 `suffix` 等),参考 [Plugin 插件参数大全](./6_plugin.md#参数)
102+
103+
## 3. 传统写法(YAML 插件配置)
104+
105+
如果你更习惯配置文件,仍然可以使用传统的插件配置方式:
106+
107+
```yaml
108+
# option.yml
109+
plugins:
110+
after_album:
111+
- plugin: img2pdf
112+
kwargs:
113+
pdf_dir: ./output
114+
filename_rule: Atitle
115+
- plugin: zip
116+
kwargs:
117+
level: album
118+
zip_dir: ./output
119+
```
120+
121+
传统写法的更多细节见 → [Plugin 插件教程](./6_plugin.md)
122+
123+
## 4. Feature 架构设计
124+
125+
### 类层次
126+
127+
```
128+
Feature (基类)
129+
├── PluginFeature ← 封装插件调用,参数根据来源自适应
130+
└── 你的自定义 Feature ← 继承 Feature,实现任意逻辑
131+
```
132+
133+
- **Feature 基类**:通用的附加行为抽象,不绑定任何具体实现。默认在所有生命周期钩子中执行。
134+
- **PluginFeature**:Feature 的子类,专门封装 jmcomic 插件。除了调用插件之外,还会根据调用来源动态适配 `filename_rule``level` 等参数。
135+
136+
### 执行流程
137+
138+
Feature **自然嵌入到 downloader 的生命周期钩子**中自动触发:
139+
140+
```
141+
api.download_album(extra=Feature.export_pdf)
142+
143+
├→ dler.add_features(pdf, 'download_album') # 注册: [(pdf, 'download_album')]
144+
145+
└→ dler.download_album(id)
146+
147+
├→ before_album(album)
148+
149+
├→ download_by_photo_detail(photo)
150+
│ ├→ before_photo(photo)
151+
│ ├→ download images ...
152+
│ └→ after_photo(photo)
153+
│ └→ _invoke_features_for('after_photo')
154+
│ └→ pdf.should_invoke('after_photo', 'download_album') → False ✗ 跳过
155+
156+
└→ after_album(album)
157+
└→ _invoke_features_for('after_album')
158+
└→ pdf.should_invoke('after_album', 'download_album') → True ✓ 执行!
159+
└→ _adapt_kwargs('download_album')
160+
# Atitle 不变, Ptitle→Atitle, Pid→Aid, level→album
161+
```
162+
163+
> 💡 **关键点**
164+
>
165+
> - **执行时机**`PluginFeature` 根据注册来源自动推导(`download_album``after_album``download_photo``after_photo`)。自定义 Feature 默认在所有钩子都会执行,你可以覆写 `should_invoke` 来控制。
166+
> - **参数自适应**`PluginFeature``filename_rule` 前缀(A/P)和 `level`(album/photo)会根据来源动态适配。用户显式传入的参数不会被覆盖。
167+
168+
### 自定义 Feature
169+
170+
Feature 基类完全不绑定插件,你可以实现任意逻辑:
171+
172+
```python
173+
from jmcomic import Feature, download_album
174+
175+
class NotifyFeature(Feature):
176+
"""下载完成后发送通知"""
177+
def invoke(self, option, **context):
178+
album = context.get('album')
179+
if album:
180+
print(f'下载完成通知: {album.name}')
181+
182+
# 使用
183+
download_album('123', option, extra=NotifyFeature())
184+
```
185+
186+
### 自定义 PluginFeature
187+
188+
如果你注册了自定义插件,也可以创建对应的 PluginFeature:
189+
190+
```python
191+
from jmcomic import PluginFeature, Feature
192+
193+
# 假设你注册了一个 plugin_key 为 'my_export' 的插件
194+
Feature.my_export = PluginFeature('my_export', output_dir='./my_output')
195+
196+
# 使用
197+
download_album('123', option, extra=Feature.my_export)
198+
```

src/jmcomic/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
# 被依赖方 <--- 使用方
33
# config <--- entity <--- toolkit <--- client <--- option <--- downloader
44

5-
__version__ = '2.6.18'
5+
__version__ = '2.6.19'
66

77
from .api import *
88
from .jm_plugin import *
9+
from .jm_feature import *
910

1011
# 下面进行注册组件(客户端、插件)
1112
gb = dict(filter(lambda pair: isinstance(pair[1], type), globals().items()))

src/jmcomic/api.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def download_album(jm_album_id,
4949
downloader=None,
5050
callback=None,
5151
check_exception=True,
52+
extra=None,
5253
) -> Union[__DOWNLOAD_API_RET, Set[__DOWNLOAD_API_RET]]:
5354
"""
5455
下载一个本子(album),包含其所有的章节(photo)
@@ -60,13 +61,16 @@ def download_album(jm_album_id,
6061
:param downloader: 下载器类
6162
:param callback: 返回值回调函数,可以拿到 album 和 downloader
6263
:param check_exception: 是否检查异常, 如果为True,会检查downloader是否有下载异常,并上抛PartialDownloadFailedException
64+
:param extra: 下载特性(Feature),下载完成后自动执行对应插件。支持单个 Feature、FeatureChain、或列表
6365
:return: 对于的本子实体类,下载器(如果是上述的批量情况,返回值为download_batch的返回值)
6466
"""
6567

6668
if not isinstance(jm_album_id, (str, int)):
6769
return download_batch(download_album, jm_album_id, option, downloader)
6870

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

7276
if callback is not None:
@@ -81,14 +85,17 @@ def download_photo(jm_photo_id,
8185
downloader=None,
8286
callback=None,
8387
check_exception=True,
88+
extra=None,
8489
):
8590
"""
8691
下载一个章节(photo),参数同 download_album
8792
"""
8893
if not isinstance(jm_photo_id, (str, int)):
89-
return download_batch(download_photo, jm_photo_id, option)
94+
return download_batch(download_photo, jm_photo_id, option, downloader)
9095

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

94101
if callback is not None:

src/jmcomic/jm_config.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,3 +551,55 @@ def register_exception_listener(cls, etype, listener):
551551

552552
jm_log = JmModuleConfig.jm_log
553553
disable_jm_log = JmModuleConfig.disable_jm_log
554+
555+
556+
class PrettyFormatter(logging.Formatter):
557+
"""带 ANSI 颜色的日志格式化器,按 topic 前缀分配颜色"""
558+
559+
TOPIC_COLORS = {
560+
'album': '\033[1;36m', # 青色加粗 — 本子级别
561+
'photo': '\033[36m', # 青色 — 章节级别
562+
'image': '\033[2;37m', # 暗灰 — 图片级别(弱化)
563+
'plugin': '\033[35m', # 紫色 — 插件
564+
'req': '\033[33m', # 黄色 — 网络请求
565+
'api': '\033[34m', # 蓝色 — API
566+
}
567+
ERROR_COLOR = '\033[1;31m' # 红色加粗
568+
WARN_COLOR = '\033[33m' # 黄色
569+
RESET = '\033[0m'
570+
571+
def __init__(self):
572+
super().__init__(fmt='[%(asctime)s] %(message)s', datefmt='%H:%M:%S')
573+
574+
def format(self, record):
575+
topic = getattr(record, 'topic', '')
576+
if record.levelno >= logging.ERROR:
577+
color = self.ERROR_COLOR
578+
elif record.levelno >= logging.WARNING:
579+
color = self.WARN_COLOR
580+
else:
581+
# 按 topic 前缀匹配颜色
582+
color = next(
583+
(c for prefix, c in self.TOPIC_COLORS.items()
584+
if topic.startswith(prefix)),
585+
''
586+
)
587+
formatted = super().format(record)
588+
return f'{color}{formatted}{self.RESET}' if color else formatted
589+
590+
591+
def enable_pretty_log():
592+
"""开启带颜色的美化日志"""
593+
import sys
594+
import os as _os
595+
596+
# Windows 需要启用 VT100 ANSI 支持
597+
if sys.platform == 'win32':
598+
_os.system('')
599+
600+
jm_logger.handlers.clear()
601+
handler = logging.StreamHandler(sys.stdout)
602+
handler.setFormatter(PrettyFormatter())
603+
jm_logger.addHandler(handler)
604+
jm_logger.setLevel(logging.INFO)
605+

0 commit comments

Comments
 (0)