v2.4.11: 实现两大插件【导出禁漫收藏夹数据】+【多图合并为一个pdf】; 实现【导出禁漫收藏夹数据】的工作流用法; 完善文档和README (#183)(#184)

This commit is contained in:
hect0x7 2023-12-18 21:28:12 +08:00 committed by GitHub
parent 4f56f46cbc
commit 51c1db7057
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 444 additions and 20 deletions

80
.github/workflows/export_favorites.yml vendored Normal file
View File

@ -0,0 +1,80 @@
name: 导出收藏夹数据
on:
# schedule:
# - cron: "0 0 * * *"
push:
workflow_dispatch:
inputs:
IN_JM_USERNAME:
type: string
default: ''
description: '禁漫帐号不建议使用会泄露在日志中。最好用secrets'
required: false
IN_JM_PASSWORD:
type: string
default: ''
description: '禁漫密码不建议使用会泄露在日志中。最好用secrets'
required: false
IN_ZIP_PASSWORD:
type: string
default: ''
description: '压缩文件密码不建议使用会泄露在日志中。最好用secrets'
required: false
jobs:
crawler:
runs-on: ubuntu-latest
env:
# 工作流输入
IN_JM_USERNAME: ${{ github.event.inputs.IN_JM_USERNAME }}
IN_JM_PASSWORD: ${{ github.event.inputs.IN_JM_PASSWORD }}
IN_ZIP_PASSWORD: ${{ github.event.inputs.IN_ZIP_PASSWORD }}
# 登录相关secrets
JM_USERNAME: ${{ secrets.JM_USERNAME }}
JM_PASSWORD: ${{ secrets.JM_PASSWORD }}
ZIP_PASSWORD: ${{ secrets.ZIP_PASSWORD }}
# 邮件相关secrets
EMAIL_FROM: ${{ secrets.EMAIL_FROM }}
EMAIL_TO: ${{ secrets.EMAIL_TO }}
EMAIL_PASS: ${{ secrets.EMAIL_PASS }}
EMAIL_TITLE: ${{ secrets.EMAIL_TITLE }}
EMAIL_CONTENT: ${{ secrets.EMAIL_CONTENT }}
# 固定值
ZIP_FP: /home/runner/work/jmcomic/download/export.7z
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install Dependency
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
sudo apt update
sudo apt install p7zip-full
- name: 安装jmcomiclocal
run: |
pip install -e ./
- name: 执行代码
run: |
cd ./usage/
python workflow_export_favorites.py
- name: 上传结果
uses: actions/upload-artifact@v3
with:
name: '收藏夹导出zip'
path: ${{ env.ZIP_FP }}
if-no-files-found: error
retention-days: 90

View File

@ -4,6 +4,10 @@
你可以通过简单的几行Python代码实现下载JM上的本子到本地并且是处理好的图片。
[【指路】教程使用GitHub Actions下载禁漫本子](./assets/docs/sources/tutorial/1_github_actions.md)
[【指路】教程:导出并下载你的禁漫收藏夹数据](./assets/docs/sources/tutorial/10_export_favorites.md)
**友情提示珍爱JM为了减轻JM的服务器压力请不要一次性爬取太多本子西门🙏🙏🙏**.
## 项目介绍
@ -14,7 +18,6 @@
目前核心功能实现较为稳定,项目也处于维护阶段(因为禁漫接口经常变动,需要经常维护)。
## 安装教程
* 通过pip官方源安装推荐并且更新也是这个命令
@ -50,24 +53,35 @@ $ jmcomic 422866
- **实现禁漫APP接口最新的加解密算法 (1.6.3)**
- 用法多样:
- GitHub Actions网页上直接输入本子id就能下载[教程使用GitHub Actions下载禁漫本子](./assets/docs/sources/tutorial/1_github_actions.md)
- 命令行无需写Python代码简单易用[教程:使用命令行下载禁漫本子](./assets/docs/sources/tutorial/2_command_line.md)
- Python代码最本质、最强大的使用方式需要你有一定的python编程基础
- 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`
- 不配置也能使用,十分方便
- 配置可以从配置文件生成,支持多种文件格式
- 配置点有:`请求域名` `客户端实现` `是否使用磁盘缓存` `同时下载的章节/图片数量` `图片格式转换` `下载路径规则` `请求元信息headers,cookies,proxies`
- **可扩展性强**
- **支持Plugin插件可以方便地扩展功能以及使用别人的插件**
- 目前内置支持的插件有:`登录插件` `硬件占用监控插件` `只下载新章插件` `压缩文件插件` `下载特定后缀图片插件` `发送QQ邮件插件` `日志主题过滤插件` `自动使用浏览器cookies插件`
- 支持自定义本子/章节/图片下载前后的回调函数
- 支持自定义日志
- 支持自定义类:`Downloader负责调度` `Option负责配置` `Client负责请求` `实体类`
- 支持自定义本子/章节/图片下载前后的回调函数
- 支持自定义日志
- 支持自定义类:`Downloader负责调度` `Option负责配置` `Client负责请求` `实体类`
- **支持Plugin插件可以方便地扩展功能以及使用别人的插件目前内置插件有**
- `登录插件`
- `硬件占用监控插件`
- `只下载新章插件`
- `压缩文件插件`
- `下载特定后缀图片插件`
- `发送QQ邮件插件`
- `日志主题过滤插件`
- `自动使用浏览器cookies插件`
- `jpg图片合成为一个pdf插件`
- `导出收藏夹为csv文件插件`
## 进阶使用
@ -91,12 +105,12 @@ $ jmcomic 422866
* assets存放一些非代码的资源文件
* config存放配置文件
* docs项目文档
* config存放配置文件
* docs项目文档
* src存放源代码
* jmcomic`jmcomic`模块
* jmcomic`jmcomic`模块
* tests测试目录存放测试代码使用unittest
* usage用法目录存放示例/使用代码

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@ -141,4 +141,19 @@ plugins:
title: jmcomic # 标题
content: jmcomic finished !!! # 内容
main:
- plugin: favorite_folder_export # 导出收藏夹插件
log: false
kwargs:
zip_enable: true # 对收藏夹进行压缩
zip_filepath: ${JM_DOWNLOAD_DIR}/export.zip # 压缩文件路径
zip_password: ${ZIP_PASSWORD} # 压缩密码
after_photo:
- plugin: j2p # jpg图片合成为一个pdf插件
kwargs:
pdf_dir: D:/pdf # pdf存放文件夹
filename_rule: Pid # pdf命名规则
quality: 100 # pdf质量0 - 100
```

View File

@ -0,0 +1,75 @@
# 导出并下载你的禁漫收藏夹数据
一共需要三步:
1. fork一份我的代码仓库。
2. 配置你的禁漫帐号密码。
3. 运行工作流,下载结果。
下面截图解析这三步的详细过程。
## 1. fork一份我的代码仓库
访问下面这个网址:
`https://github.com/hect0x7/JMComic-Crawler-Python/fork`
直接拉到页面最底部,如下所示:
最新提示下图的1可以不做即直接点绿色的Create fork按钮
![1](../images/1.png)
## 2. 配置你的禁漫帐号密码
在开始下面的步骤之前你需要先启用你的repo的Actions开启方式如下
![6](../images/6.png)
**注意事项:
最好使用secrets保证不会泄漏信息。
虽然也支持通过工作流dispatch输入帐号密码但是那种方式会让账号密码明文显示在Actions日志中。
如果你打算采用后者,最好下载完就及时删除工作流。**
然后访问下面这个网址:
`https://github.com/你的用户名/JMComic-Crawler-Python/settings/secrets/actions`
按下图步骤进行操作:
![7](../images/7.png)
然后来到填写页面
![8](../images/8.png)
你需要填入下面的内容并点【Add secret】保存
`JM_USERNAME`: 你的禁漫帐号用户名
同样的方式再配置如下内容:
`JM_PASSWORD`: 你的禁漫帐号密码
`ZIP_PASSWORD`: 压缩文件密码,防止别人下载使用你的文件
## 3. 运行工作流,下载结果。
访问如下网址:
`https://github.com/你的用户名/JMComic-Crawler-Python/actions/workflows/export_favorites.yml`
按照下图步骤点击,即可运行工作流。
* 如果你在步骤2已经配置了secrets无需在意输入框。
* 如果你没有配置secrets你可以在输入框中输入账号 密码 压缩密码,但要记得及时删除工作流来避免信息泄漏。
![9](../images/9.png)
待工作流变绿,表示已完成,点击去拉到页面底部,即可下载文件。
![10](../images/10.png)

View File

@ -0,0 +1,27 @@
client:
impl: api
domain:
html: [ jmcomic1.me, jmcomic.me ]
# 插件配置
plugins:
main:
- plugin: login # 登录插件
kwargs:
username: ${JM_USERNAME}
password: ${JM_PASSWORD}
- plugin: favorite_folder_export
log: false
kwargs:
zip_enable: true
zip_filepath: ${ZIP_FP}
zip_password: ${ZIP_PASSWORD}
- plugin: send_qq_email # 发送邮件如果未配置下面的前3个环境变量则不会发送。
kwargs:
msg_from: ${EMAIL_FROM}
msg_to: ${EMAIL_TO}
password: ${EMAIL_PASS}
title: ${EMAIL_TITLE}
content: ${EMAIL_CONTENT}

View File

@ -2,7 +2,7 @@
# 被依赖方 <--- 使用方
# config <--- entity <--- toolkit <--- client <--- option <--- downloader
__version__ = '2.4.10'
__version__ = '2.4.11'
from .api import *
from .jm_plugin import *

View File

@ -181,6 +181,14 @@ class JmDownloader(DownloadCallback):
downloader=self,
)
def after_photo(self, photo: JmPhotoDetail):
super().after_photo(photo)
self.option.call_all_plugin(
'after_photo',
photo=photo,
downloader=self,
)
def after_image(self, image: JmImageDetail, img_save_path):
super().after_image(image, img_save_path)
photo = image.from_photo

View File

@ -528,6 +528,10 @@ class JmOption:
# 构建插件对象
plugin: JmOptionPlugin = plugin_class.build(self)
# 设置日志开关
if pinfo.get('log', True) is not True:
plugin.log_enable = False
jm_log('plugin.invoke', f'调用插件: [{plugin_class.plugin_key}]')
# 调用插件功能
plugin.invoke(**kwargs)

View File

@ -17,6 +17,7 @@ class JmOptionPlugin:
def __init__(self, option: JmOption):
self.option = option
self.log_enable = True
def invoke(self, **kwargs) -> None:
"""
@ -33,10 +34,12 @@ class JmOptionPlugin:
"""
return cls(option)
@classmethod
def log(cls, msg, topic=None):
def log(self, msg, topic=None):
if self.log_enable is not True:
return
jm_log(
topic=f'plugin.{cls.plugin_key}' + (f'.{topic}' if topic is not None else ''),
topic=f'plugin.{self.plugin_key}' + (f'.{topic}' if topic is not None else ''),
msg=msg
)
@ -65,11 +68,12 @@ class JmLoginPlugin(JmOptionPlugin):
def invoke(self,
username: str,
password: str,
impl=None,
) -> None:
self.require_true(username, '用户名不能为空')
self.require_true(password, '密码不能为空')
client = self.option.new_jm_client()
client = self.option.new_jm_client(impl=impl)
client.login(username, password)
cookies = dict(client['cookies'])
@ -515,3 +519,165 @@ class AutoSetBrowserCookiesPlugin(JmOptionPlugin):
{k: v for k, v in cookies.items() if k in self.accepted_cookies_keys}
)
self.log('获取浏览器cookies成功')
# noinspection PyMethodMayBeStatic
class FavoriteFolderExportPlugin(JmOptionPlugin):
plugin_key = 'favorite_folder_export'
# noinspection PyAttributeOutsideInit
def invoke(self,
save_dir=None,
zip_enable=False,
zip_filepath=None,
zip_password=None,
delete_original_file=False,
):
self.save_dir = os.path.abspath(save_dir if save_dir is not None else os.getcwd() + '/export/')
self.zip_enable = zip_enable
self.zip_filepath = os.path.abspath(zip_filepath)
self.zip_password = zip_password
self.delete_original_file = delete_original_file
self.files = []
mkdir_if_not_exists(self.save_dir)
mkdir_if_not_exists(of_dir_path(self.zip_filepath))
self.main()
def main(self):
cl = self.option.new_jm_client(impl=JmApiClient)
# noinspection PyAttributeOutsideInit
self.cl = cl
page = cl.favorite_folder()
# 获取所有的收藏夹
folders = {fid: fname for fid, fname in page.iter_folder_id_name()}
# 加上特殊收藏栏【全部】
folders.setdefault('0', '全部')
# 一个收藏夹一个线程,导出收藏夹数据到文件
multi_thread_launcher(
iter_objs=folders.items(),
apply_each_obj_func=self.handle_folder,
)
if not self.zip_enable:
return
# 压缩导出的文件
self.require_true(self.zip_filepath, '如果开启zip请指定zip_filepath参数压缩文件保存路径')
if self.zip_password is None:
self.zip_folder_without_password(self.files, self.zip_filepath)
else:
self.zip_with_password()
# 删除导出的原文件
if self.delete_original_file is True:
for f in self.files:
os.remove(f)
def handle_folder(self, fid: str, fname: str):
self.log(f'【收藏夹: {fname}, fid: {fid}】开始获取数据')
# 获取收藏夹数据
page_data = self.fetch_folder_page_data(fid)
# 序列化到文件
filepath = self.save_folder_page_data_to_file(page_data, fid, fname)
if filepath is None:
self.log(f'【收藏夹: {fname}, fid: {fid}】收藏夹无数据')
return
self.log(f'【收藏夹: {fname}, fid: {fid}】保存文件成功 → [{filepath}]')
self.files.append(filepath)
def fetch_folder_page_data(self, fid):
# 一页一页获取,不使用并行
page_data = list(self.cl.favorite_folder_gen(folder_id=fid))
return page_data
def save_folder_page_data_to_file(self, page_data: List[JmFavoritePage], fid: str, fname: str):
from os import path
filepath = path.abspath(path.join(self.save_dir, fix_windir_name(f'{fid}{fname}.csv')))
data = []
for page in page_data:
for aid, extra in page.content:
data.append(
(aid, extra.get('author', '') or JmMagicConstants.DEFAULT_AUTHOR, extra['name'])
)
if len(data) == 0:
return
with open(filepath, 'w', encoding='utf-8') as f:
f.write('id,author,name\n')
for item in data:
f.write(','.join(item) + '\n')
return filepath
def zip_folder_without_password(self, files, zip_path):
"""
压缩文件夹中的文件并设置密码
:param files: 要压缩的文件的绝对路径的列表
:param zip_path: 压缩文件的保存路径
"""
import zipfile
with zipfile.ZipFile(zip_path, 'w') as zipf:
# 获取文件夹中的文件列表并将其添加到 ZIP 文件中
for file in files:
zipf.write(file, arcname=of_file_name(file))
def zip_with_password(self):
os.chdir(self.save_dir)
cmd = f'7z a "{self.zip_filepath}" "{self.save_dir}" -p{self.zip_password} -mhe=on'
self.require_true(
0 == os.system(cmd),
'加密压缩文件失败'
)
class ConvertJpgToPdfPlugin(JmOptionPlugin):
plugin_key = 'j2p'
def invoke(self,
photo: JmPhotoDetail,
pdf_dir=None,
filename_rule='Pid',
quality=100,
**kwargs,
):
filename = DirRule.apply_rule_directly(None, photo, filename_rule)
photo_dir = self.option.decide_image_save_dir(photo)
if pdf_dir is None:
pdf_dir = photo_dir
else:
pdf_dir = fix_windir_name(pdf_dir)
mkdir_if_not_exists(pdf_dir)
pdf_filepath = f'{pdf_dir}{filename}.pdf'
def get_cmd(suffix='.jpg'):
return f'magick convert -quality {quality} "{photo_dir}*{suffix}" "{pdf_filepath}"'
cmd = get_cmd()
self.log(f'execute command: {cmd}')
code = self.execute_cmd(cmd)
self.require_true(
code == 0,
'jpg图片合并为pdf失败'
'请确认你是否安装了magick安装网站: [http://www.imagemagick.org/]'
)
self.log(f'Convert Successfully: JM{photo.id}{pdf_filepath}')
# noinspection PyMethodMayBeStatic
def execute_cmd(self, cmd):
return os.system(cmd)

View File

@ -0,0 +1,35 @@
from jmcomic import *
def prepare_actions_input_and_secrets():
def env(match: Match) -> str:
name = match[1]
value = os.getenv(name, '')
# 配置了有效的值,放行
if value != '':
return value
# 未配置或者值为空值为空是GitHub Actions的未配置默认值
# 是EMAIL相关也放行
if name.startswith('EMAIL'):
return value
# 尝试从工作流中取
value = os.getenv(f'IN_{name}', '')
# 工作流也没有传值
ExceptionTool.require_true(value != '', f'未配置secrets或工作流字段为: {name}')
return value
JmcomicText.dsl_replacer.add_dsl_and_replacer(r'\$\{(.*?)\}', env)
def main():
prepare_actions_input_and_secrets()
option = create_option('../assets/option/option_workflow_export_favorites.yml')
option.call_all_plugin('main')
if __name__ == '__main__':
main()