v2.6.0: 实现API域名可自动更新; 压缩插件支持zip加密、7z加密; 增强文件路径的插件配置,支持配置项dir_rule。(#437) (#438)
Some checks failed
Auto Release & Publish / release (push) Has been cancelled

This commit is contained in:
hect0x7 2025-06-07 13:17:38 +08:00 committed by GitHub
parent 905f72c690
commit 1679f009b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 194 additions and 175 deletions

View File

@ -157,10 +157,11 @@ jmcomic 123
- `下载特定后缀图片插件`
- `发送QQ邮件插件`
- `自动使用浏览器cookies插件`
- `jpg图片合成为一个pdf插件`
- `导出收藏夹为csv文件插件`
- `合并所有图片为pdf文件插件`
- `合并所有图片为长图插件`
- `合并所有图片为长图png插件`
- `重复文件检测删除插件`
- `网页观看本地章节插件`
## 使用小说明

View File

@ -264,19 +264,4 @@ plugins:
img_dir: D:/pdf/ # 长图存放文件夹
filename_rule: Aname # 长图命名规则,同上
# 请注意⚠
# 下方的j2p插件的功能不如img2pdf插件不建议使用。
# 如有图片转pdf的需求直接使用img2pdf即可下面的内容请忽略。
- plugin: j2p # 图片合并插件可以将下载下来的jpg图片合成为一个pdf插件
# 请注意⚠ 该插件的使用前提是下载下来的图片是jpg图片
# 因此,使用该插件前,需要有如下配置:下载图片格式转为jpg上文有解释过此配置
# download:
# image:
# suffix: .jpg
kwargs:
pdf_dir: D:/pdf/ # pdf存放文件夹
filename_rule: Pid # pdf命名规则
quality: 100 # pdf质量0 - 100
```

View File

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

View File

@ -1005,10 +1005,51 @@ class JmApiClient(AbstractJmClient):
ExceptionTool.raises_resp(f'响应无数据request_url=[{url}]', resp)
def after_init(self):
# 自动更新禁漫API域名
if JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN:
self.update_api_domain()
# 保证拥有cookies因为移动端要求必须携带cookies否则会直接跳转同一本子【禁漫娘】
if JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES:
self.ensure_have_cookies()
client_update_domain_lock = Lock()
def update_api_domain(self):
if True is JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN_DONE:
return
with self.client_update_domain_lock:
if True is JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN_DONE:
return
try:
# 获取域名列表
resp = self.postman.get(JmModuleConfig.API_URL_DOMAIN_SERVER)
res_json = JmCryptoTool.decode_resp_data(resp.text, '', JmMagicConstants.API_DOMAIN_SERVER_SECRET)
res_data = json_loads(res_json)
# 检查返回值
if not res_data.get('Server', None):
jm_log('api.update_domain.empty',
f'获取禁漫最新API域名失败, 返回值: {res_json}')
return
new_server_list: list[str] = res_data['Server']
old_server_list = JmModuleConfig.DOMAIN_API_LIST
jm_log('api.update_domain.success',
f'获取到最新的API域名替换jmcomic内置域名(new){new_server_list} ---→ (old){old_server_list}'
)
# 更新域名
if self.domain_list is old_server_list:
self.domain_list = new_server_list
JmModuleConfig.DOMAIN_API_LIST = new_server_list
except Exception as e:
jm_log('api.update_domain.error',
f'自动更新API域名失败仍使用jmcomic内置域名。'
f'可通过代码[JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN=False]关闭自动更新API域名. 异常: {e}'
)
finally:
JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN_DONE = True
client_init_cookies_lock = Lock()
def ensure_have_cookies(self):

View File

@ -76,7 +76,8 @@ class JmMagicConstants:
APP_TOKEN_SECRET = '18comicAPP'
APP_TOKEN_SECRET_2 = '18comicAPPContent'
APP_DATA_SECRET = '185Hcomic3PAPP7R'
APP_VERSION = '1.7.9'
API_DOMAIN_SERVER_SECRET = 'diosfjckwpqpdfjkvnqQjsik'
APP_VERSION = '1.8.0'
# 模块级别共用配置
@ -128,11 +129,15 @@ class JmModuleConfig:
# 移动端API域名
DOMAIN_API_LIST = shuffled('''
www.cdnmhwscc.vip
www.cdnblackmyth.club
www.cdnmhws.cc
www.cdnplaystation6.club
www.cdnplaystation6.org
www.cdnuc.vip
www.cdn-mspjmapiproxy.xyz
''')
# 获取最新移动端API域名的地址
API_URL_DOMAIN_SERVER = f'{PROT}jmappc01-1308024008.cos.ap-guangzhou.myqcloud.com/server-2024.txt'
APP_HEADERS_TEMPLATE = {
'Accept-Encoding': 'gzip, deflate',
'user-agent': 'Mozilla/5.0 (Linux; Android 9; V1938CT Build/PQ3A.190705.11211812; wv) AppleWebKit/537.36 (KHTML, '
@ -200,6 +205,9 @@ class JmModuleConfig:
FLAG_USE_FIX_TIMESTAMP = True
# 移动端Client初始化cookies
FLAG_API_CLIENT_REQUIRE_COOKIES = True
# 自动更新禁漫API域名
FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = True
FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN_DONE = None
# log开关标记
FLAG_ENABLE_JM_LOG = True
# log时解码url
@ -380,7 +388,7 @@ class JmModuleConfig:
@classmethod
def new_postman(cls, session=False, **kwargs):
kwargs.setdefault('impersonate', 'chrome110')
kwargs.setdefault('impersonate', 'chrome')
kwargs.setdefault('headers', JmModuleConfig.new_html_headers())
kwargs.setdefault('proxies', JmModuleConfig.DEFAULT_PROXIES)
@ -416,7 +424,7 @@ class JmModuleConfig:
'postman': {
'type': 'curl_cffi',
'meta_data': {
'impersonate': 'chrome110',
'impersonate': 'chrome',
'headers': None,
'proxies': None,
}

View File

@ -70,12 +70,12 @@ class DirRule:
album: JmAlbumDetail,
photo: JmPhotoDetail,
) -> str:
return self._build_path_from_rules(album, photo)
return self.apply_rule_to_path(album, photo)
def decide_album_root_dir(self, album: JmAlbumDetail) -> str:
return self._build_path_from_rules(album, None, True)
return self.apply_rule_to_path(album, None, True)
def _build_path_from_rules(self, album, photo, only_album_rules=False) -> str:
def apply_rule_to_path(self, album, photo, only_album_rules=False) -> str:
path_ls = []
for rule, parser in self.parser_list:
if only_album_rules and not (rule == self.RULE_BASE_DIR or rule.startswith('A')):
@ -92,7 +92,7 @@ class DirRule:
path_ls.append(path)
return fix_filepath('/'.join(path_ls), is_dir=True)
return fix_filepath('/'.join(path_ls))
def get_rule_parser_list(self, rule_dsl: str):
"""
@ -103,7 +103,6 @@ class DirRule:
parser_list: list = []
for rule in rule_list:
rule = rule.strip()
if rule == self.RULE_BASE_DIR:
parser_list.append((rule, self.parse_bd_rule))
continue
@ -135,17 +134,21 @@ class DirRule:
return str(DetailEntity.get_dirname(detail, rule[1:]))
# noinspection PyMethodMayBeStatic
def split_rule_dsl(self, rule_dsl: str):
if rule_dsl == self.RULE_BASE_DIR:
return [rule_dsl]
def split_rule_dsl(self, rule_dsl: str) -> list[str]:
if '/' in rule_dsl:
return rule_dsl.split('/')
rule_list = rule_dsl.split('/')
elif '_' in rule_dsl:
rule_list = rule_dsl.split('_')
else:
rule_list = [rule_dsl]
if '_' in rule_dsl:
return rule_dsl.split('_')
for i, e in enumerate(rule_list):
rule_list[i] = e.strip()
ExceptionTool.raises(f'不支持的rule配置: "{rule_dsl}"')
if rule_list[0] != self.RULE_BASE_DIR:
rule_list.insert(0, self.RULE_BASE_DIR)
return rule_list
@classmethod
def get_rule_parser(cls, rule: str):
@ -158,7 +161,7 @@ class DirRule:
ExceptionTool.raises(f'不支持的rule配置: "{rule}"')
@classmethod
def apply_rule_directly(cls, album, photo, rule: str) -> str:
def apply_rule_to_filename(cls, album, photo, rule: str) -> str:
if album is None:
album = photo.from_album
# noinspection PyArgumentList

View File

@ -1,7 +1,6 @@
"""
该文件存放的是option插件
"""
import os.path
from .jm_option import *
@ -57,11 +56,12 @@ class JmOptionPlugin:
raise PluginValidationException(self, msg)
def warning_lib_not_install(self, lib: str):
def warning_lib_not_install(self, lib: str, throw=False):
msg = (f'插件`{self.plugin_key}`依赖库: {lib},请先安装{lib}再使用。'
f'安装命令: [pip install {lib}]')
import warnings
warnings.warn(msg)
self.require_param(throw, msg)
def execute_deletion(self, paths: List[str]):
"""
@ -76,6 +76,9 @@ class JmOptionPlugin:
continue
if os.path.isdir(p):
if os.listdir(p):
self.log(f'文件夹中存在非本次下载的文件,请手动删除文件夹内的文件: {p}', 'remove.ignore')
continue
os.rmdir(p)
self.log(f'删除文件夹: {p}', 'remove')
else:
@ -104,6 +107,33 @@ class JmOptionPlugin:
def wait_until_finish(self):
pass
# noinspection PyMethodMayBeStatic
def decide_filepath(self,
album: Optional[JmAlbumDetail],
photo: Optional[JmPhotoDetail],
filename_rule: str, suffix: str, base_dir: Optional[str],
dir_rule_dict: Optional[dict]
):
"""
根据规则计算一个文件的全路径
参数 dir_rule_dict 优先级最高
如果 dir_rule_dict 不为空优先用 dir_rule_dict
否则使用 base_dir + filename_rule + suffix
"""
filepath: str
base_dir: str
if dir_rule_dict is not None:
dir_rule = DirRule(**dir_rule_dict)
filepath = dir_rule.apply_rule_to_path(album, photo)
base_dir = os.path.dirname(filepath)
else:
base_dir = base_dir or os.getcwd()
filepath = os.path.join(base_dir, DirRule.apply_rule_to_filename(album, photo, filename_rule) + fix_suffix(suffix))
mkdir_if_not_exists(base_dir)
return filepath
class JmLoginPlugin(JmOptionPlugin):
"""
@ -274,6 +304,11 @@ class FindUpdatePlugin(JmOptionPlugin):
class ZipPlugin(JmOptionPlugin):
"""
感谢zip加密功能的贡献者:
- AXIS5 a.k.a AXIS5Hacker (https://github.com/hect0x7/JMComic-Crawler-Python/pull/375)
"""
plugin_key = 'zip'
# noinspection PyAttributeOutsideInit
@ -285,7 +320,9 @@ class ZipPlugin(JmOptionPlugin):
level='photo',
filename_rule='Ptitle',
suffix='zip',
zip_dir='./'
zip_dir='./',
dir_rule=None,
encrypt=None,
) -> None:
from .jm_downloader import JmDownloader
@ -302,19 +339,20 @@ class ZipPlugin(JmOptionPlugin):
photo_dict = self.get_downloaded_photo(downloader, album, photo)
if level == 'album':
zip_path = self.get_zip_path(album, None, filename_rule, suffix, zip_dir)
self.zip_album(album, photo_dict, zip_path, path_to_delete)
zip_path = self.decide_filepath(album, None, filename_rule, suffix, zip_dir, dir_rule)
self.zip_album(album, photo_dict, zip_path, path_to_delete, encrypt)
elif level == 'photo':
for photo, image_list in photo_dict.items():
zip_path = self.get_zip_path(photo.from_album, photo, filename_rule, suffix, zip_dir)
self.zip_photo(photo, image_list, zip_path, path_to_delete)
zip_path = self.decide_filepath(photo.from_album, photo, filename_rule, suffix, zip_dir, dir_rule)
self.zip_photo(photo, image_list, zip_path, path_to_delete, encrypt)
else:
ExceptionTool.raises(f'Not Implemented Zip Level: {level}')
self.after_zip(path_to_delete)
# noinspection PyMethodMayBeStatic
def get_downloaded_photo(self, downloader, album, photo):
return (
downloader.download_success_dict[album]
@ -322,7 +360,7 @@ class ZipPlugin(JmOptionPlugin):
else downloader.download_success_dict[photo.from_album] # after_photo
)
def zip_photo(self, photo, image_list: list, zip_path: str, path_to_delete):
def zip_photo(self, photo, image_list: list, zip_path: str, path_to_delete, encrypt_dict):
"""
压缩photo文件夹
"""
@ -330,8 +368,11 @@ class ZipPlugin(JmOptionPlugin):
if len(image_list) == 0 \
else os.path.dirname(image_list[0][0])
from common import backup_dir_to_zip
backup_dir_to_zip(photo_dir, zip_path)
with self.open_zip_file(zip_path, encrypt_dict) as f:
for file in files_of_dir(photo_dir):
abspath = os.path.join(photo_dir, file)
relpath = os.path.relpath(abspath, photo_dir)
f.write(abspath, relpath)
self.log(f'压缩章节[{photo.photo_id}]成功 → {zip_path}', 'finish')
path_to_delete.append(self.unified_path(photo_dir))
@ -340,14 +381,13 @@ class ZipPlugin(JmOptionPlugin):
def unified_path(f):
return fix_filepath(f, os.path.isdir(f))
def zip_album(self, album, photo_dict: dict, zip_path, path_to_delete):
def zip_album(self, album, photo_dict: dict, zip_path, path_to_delete, encrypt_dict):
"""
压缩album文件夹
"""
album_dir = self.option.dir_rule.decide_album_root_dir(album)
import zipfile
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as f:
with self.open_zip_file(zip_path, encrypt_dict) as f:
for photo in photo_dict.keys():
# 定位到章节所在文件夹
photo_dir = self.unified_path(self.option.decide_image_save_dir(photo))
@ -372,16 +412,65 @@ class ZipPlugin(JmOptionPlugin):
self.execute_deletion(dirs)
# noinspection PyMethodMayBeStatic
def get_zip_path(self, album, photo, filename_rule, suffix, zip_dir):
@classmethod
def generate_random_str(cls, random_length) -> str:
"""
计算zip文件的路径
自动生成随机字符密码长度由randomlength指定
"""
filename = DirRule.apply_rule_directly(album, photo, filename_rule)
from os.path import join
return join(
zip_dir,
filename + fix_suffix(suffix),
)
import random
random_str = ''
base_str = r'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
base_length = len(base_str) - 1
for _ in range(random_length):
random_str += base_str[random.randint(0, base_length)]
return random_str
def open_zip_file(self, zip_path: str, encrypt_dict: Optional[dict]):
if encrypt_dict is None:
import zipfile
return zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED)
password, is_random = self.decide_password(encrypt_dict, zip_path)
if encrypt_dict.get('impl', '') == '7z':
try:
# noinspection PyUnresolvedReferences
import py7zr
except ImportError:
self.warning_lib_not_install('py7zr', True)
# noinspection PyUnboundLocalVariable
filters = [{'id': py7zr.FILTER_COPY}]
return py7zr.SevenZipFile(zip_path, mode='w', password=password, filters=filters, header_encryption=True)
else:
try:
# noinspection PyUnresolvedReferences
import pyzipper
except ImportError:
self.warning_lib_not_install('pyzipper', True)
# noinspection PyUnboundLocalVariable
aes_zip_file = pyzipper.AESZipFile(zip_path, "w", pyzipper.ZIP_DEFLATED)
aes_zip_file.setencryption(pyzipper.WZ_AES, nbits=128)
password_bytes = str.encode(password)
aes_zip_file.setpassword(password_bytes)
if is_random:
aes_zip_file.comment = password_bytes
return aes_zip_file
def decide_password(self, encrypt_dict: dict, zip_path: str):
encrypt_type = encrypt_dict.get('type', '')
is_random = False
if encrypt_type == 'random':
is_random = True
password = self.generate_random_str(48)
self.log(f'生成随机密码: [{password}] → [{zip_path}]', 'encrypt')
else:
password = str(encrypt_dict['password'])
self.log(f'使用指定密码: [{password}] → [{zip_path}]', 'encrypt')
return password, is_random
class ClientProxyPlugin(JmOptionPlugin):
@ -649,95 +738,6 @@ class FavoriteFolderExportPlugin(JmOptionPlugin):
self.execute_multi_line_cmd(cmd_list)
class ConvertJpgToPdfPlugin(JmOptionPlugin):
plugin_key = 'j2p'
def check_image_suffix_is_valid(self, std_suffix):
"""
检查option配置的图片后缀转换目前限制使用Magick时只能搭配jpg
暂不探究Magick是否支持更多图片格式
"""
cur_suffix: Optional[str] = self.option.download.image.suffix
ExceptionTool.require_true(
cur_suffix is not None and cur_suffix.endswith(std_suffix),
'请把图片的后缀转换配置为jpg不然无法使用Magick'
f'(当前配置是[{cur_suffix}]\n'
f'配置模板如下: \n'
f'```\n'
f'download:\n'
f' image:\n'
f' suffix: {std_suffix} # 当前配置是{cur_suffix}\n'
f'```'
)
def invoke(self,
photo: JmPhotoDetail,
downloader=None,
pdf_dir=None,
filename_rule='Pid',
quality=100,
delete_original_file=False,
override_cmd=None,
override_jpg=None,
**kwargs,
):
self.delete_original_file = delete_original_file
# 检查图片后缀配置
suffix = override_jpg or '.jpg'
self.check_image_suffix_is_valid(suffix)
# 处理文件夹配置
filename = DirRule.apply_rule_directly(None, photo, filename_rule)
photo_dir = self.option.decide_image_save_dir(photo)
# 处理生成的pdf文件的路径
if pdf_dir is None:
pdf_dir = photo_dir
else:
pdf_dir = fix_filepath(pdf_dir, True)
mkdir_if_not_exists(pdf_dir)
pdf_filepath = os.path.join(pdf_dir, f'{filename}.pdf')
# 生成命令
def generate_cmd():
return (
override_cmd or
'magick convert -quality {quality} "{photo_dir}*{suffix}" "{pdf_filepath}"'
).format(
quality=quality,
photo_dir=photo_dir,
suffix=suffix,
pdf_filepath=pdf_filepath,
)
cmd = generate_cmd()
self.log(f'Execute Command: [{cmd}]')
code = self.execute_cmd(cmd)
ExceptionTool.require_true(
code == 0,
'jpg图片合并为pdf失败'
'请确认你是否安装了magick安装网站: [https://www.imagemagick.org/]',
)
self.log(f'Convert Successfully: JM{photo.id}{pdf_filepath}')
if downloader is not None:
from .jm_downloader import JmDownloader
downloader: JmDownloader
paths = [
path
for path, image in downloader.download_success_dict[photo.from_album][photo]
]
paths.append(self.option.decide_image_save_dir(photo, ensure_exists=False))
self.execute_deletion(paths)
class Img2pdfPlugin(JmOptionPlugin):
plugin_key = 'img2pdf'
@ -747,6 +747,7 @@ class Img2pdfPlugin(JmOptionPlugin):
downloader=None,
pdf_dir=None,
filename_rule='Pid',
dir_rule=None,
delete_original_file=False,
**kwargs,
):
@ -762,13 +763,7 @@ class Img2pdfPlugin(JmOptionPlugin):
self.delete_original_file = delete_original_file
# 处理生成的pdf文件的路径
pdf_dir = self.ensure_make_pdf_dir(pdf_dir)
# 处理pdf文件名
filename = DirRule.apply_rule_directly(album, photo, filename_rule)
# pdf路径
pdf_filepath = os.path.join(pdf_dir, f'{filename}.pdf')
pdf_filepath = self.decide_filepath(album, photo, filename_rule, 'pdf', pdf_dir, dir_rule)
# 调用 img2pdf 把 photo_dir 下的所有图片转为pdf
img_path_ls, img_dir_ls = self.write_img_2_pdf(pdf_filepath, album, photo)
@ -794,18 +789,14 @@ class Img2pdfPlugin(JmOptionPlugin):
continue
img_path_ls += imgs
if len(img_path_ls) == 0:
self.log(f'所有文件夹都不存在图片无法生成pdf{img_dir_ls}', 'error')
with open(pdf_filepath, 'wb') as f:
f.write(img2pdf.convert(img_path_ls))
return img_path_ls, img_dir_ls
@staticmethod
def ensure_make_pdf_dir(pdf_dir: str):
pdf_dir = pdf_dir or os.getcwd()
pdf_dir = fix_filepath(pdf_dir, True)
mkdir_if_not_exists(pdf_dir)
return pdf_dir
class LongImgPlugin(JmOptionPlugin):
plugin_key = 'long_img'
@ -817,6 +808,7 @@ class LongImgPlugin(JmOptionPlugin):
img_dir=None,
filename_rule='Pid',
delete_original_file=False,
dir_rule=None,
**kwargs,
):
if photo is None and album is None:
@ -830,14 +822,8 @@ class LongImgPlugin(JmOptionPlugin):
self.delete_original_file = delete_original_file
# 处理文件夹配置
img_dir = self.get_img_dir(img_dir)
# 处理生成的长图文件的路径
filename = DirRule.apply_rule_directly(album, photo, filename_rule)
# 长图路径
long_img_path = os.path.join(img_dir, f'{filename}.png')
long_img_path = self.decide_filepath(album, photo, filename_rule, 'png', img_dir, dir_rule)
# 调用 PIL 把 photo_dir 下的所有图片合并为长图
img_path_ls = self.write_img_2_long_img(long_img_path, album, photo)
@ -856,7 +842,7 @@ class LongImgPlugin(JmOptionPlugin):
img_dir_items = [self.option.decide_image_save_dir(photo) for photo in album]
img_paths = itertools.chain(*map(files_of_dir, img_dir_items))
img_paths = filter(lambda x: not x.startswith('.'), img_paths) # 过滤系统文件
img_paths = list(filter(lambda x: not x.startswith('.'), img_paths)) # 过滤系统文件
images = self.open_images(img_paths)
@ -895,12 +881,6 @@ class LongImgPlugin(JmOptionPlugin):
self.log(f"Failed to open image {img_path}: {e}", 'error')
return images
@staticmethod
def get_img_dir(img_dir: Optional[str]) -> str:
img_dir = fix_filepath(img_dir or os.getcwd())
mkdir_if_not_exists(img_dir)
return img_dir
class JmServerPlugin(JmOptionPlugin):
plugin_key = 'jm_server'
@ -960,6 +940,7 @@ class JmServerPlugin(JmOptionPlugin):
# 服务器的代码位于一个独立库plugin_jm_server需要独立安装
# 源代码仓库https://github.com/hect0x7/plugin-jm-server
try:
# noinspection PyUnresolvedReferences
import plugin_jm_server
self.log(f'当前使用plugin_jm_server版本: {plugin_jm_server.__version__}')
except ImportError: