v2.3.4: 支持Client自动配置域名,优化模块配置类,跟进项目文档 (#144)

今后GitHub Actions默认采用移动端实现
This commit is contained in:
hect0x7 2023-09-30 18:33:23 +08:00 committed by GitHub
parent 49e6d26aaf
commit fc3842f2ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 281 additions and 132 deletions

View File

@ -6,8 +6,6 @@
**友情提示珍爱JM为了减轻JM的服务器压力请不要一次性爬取太多本子西门🙏🙏🙏**.
## 安装教程
* 通过pip官方源安装推荐并且更新也是这个命令
@ -21,8 +19,6 @@
pip install -e ./
```
## 快速上手
使用下面的两行代码即可实现功能把某个本子album里的所有章节photo下载到本地
@ -45,14 +41,13 @@ $ jmcomic 422866
# 更多用法请参考文件 usage/usage_cl.py (命令行使用介绍)
```
## 进阶使用
进阶使用可以参考本repo下usage文件夹内的示例代码文件下面是各个文件的作用你可以挑感兴趣的阅读
- API上手介绍: `getting_started.py`
- 命令行使用介绍: `usage_cl.py`
- 配置客户端的实现(网页端、移动端): `usage_configure_client_impl.py`
- 使用API实现简单功能: `usage_simple.py`
- 演示jmcomic模块的可自定义功能点: `usage_custom.py`
- 使用API的Filter过滤功能: `usage_feature_filter.py`
@ -66,39 +61,33 @@ $ jmcomic 422866
- 测试你的ip可以访问哪些禁漫域名: `pick_domain.py`
- 基于GitHub Actions下载本子: `workflow_download.py`
## 项目特点
- **绕过Cloudflare的反爬虫**
- 支持使用**命令行**下载本子无需写Python代码简单易用
- 支持使用**GitHub Actions**下载本子网页上直接输入本子id就能下载[教程使用GitHub Actions下载禁漫本子](./assets/docs/教程使用GitHub%20Actions下载禁漫本子.md)
- 支持**网页端**和**移动端**两种客户端实现,可通过配置切换(**移动端不限ip兼容性好网页端限制ip地区但效率高**
- 支持**自动重试和域名切换**机制
- **多线程下载**(可细化到一图一线程,效率极高)
- 跟进了JM最新的图片分割算法2023-02-08
- **可配置性强**
- 不配置也能使用,十分方便
- 配置可以从**配置文件**生成,支持多种文件格式
- 配置点有:`是否使用磁盘缓存` `并发下载图片数` `图片类型转换` `下载路径` `请求元信息headers,cookies,proxies`
- 配置点有:`请求域名` `客户端实现` `是否使用磁盘缓存` `同时下载的章节/图片数量` `图片格式转换` `下载路径规则` `请求元信息headers,cookies,proxies`
- **可扩展性强**
- **支持Plugin插件可以方便地扩展功能以及使用别人的插件**
- 目前内置支持的插件有:`登录插件` `硬件占用监控插件` `只下载新章插件` `压缩文件插件`
- 支持自定义本子/章节/图片下载前后的回调函数
- 支持自定义debug日志
- 支持自定义类Downloader负责调度/Option负责配置/Client负责请求/实体类 等等
- ......
- 支持**自动重试和域名切换**机制
- **多线程下载**(可细化到一图一线程,效率极高)
- 跟进了JM最新的图片分割算法2023-02-08
- 支持自定义类:`Downloader负责调度` `Option负责配置` `Client负责请求` `实体类`
## 使用小说明
* Python >= 3.7
* 个人项目文档和示例会有不及时之处可以Issue提问
## 项目文件夹介绍
* assets存放一些非代码的资源文件
@ -111,8 +100,6 @@ $ jmcomic 422866
* tests测试目录存放测试代码使用unittest
* usage用法目录存放示例/使用代码
## 感谢以下项目
### 图片分割算法代码+禁漫移动端API

View File

@ -1,15 +1,16 @@
# GitHub Actions 下载脚本配置
version: '2.0'
dir_rule:
base_dir: ${JM_DOWNLOAD_DIR}
rule: Bd_Aauthor_Atitle_Pindex
client:
domain:
- jmcomic1.me
- jmcomic.me
impl: api
domain: [
"www.jmapinode1.cc",
"www.jmapinode2.cc",
"www.jmapinode3.cc",
"www.jmapibranch2.cc",
]
# 插件配置
plugin:
@ -18,10 +19,3 @@ plugin:
kwargs:
interval: 0.5 # 间隔时间
enable_warning: false # 不告警
# after_album:
# - plugin: zip # 压缩文件插件
# kwargs:
# level: album # 按照章节,一个章节一个压缩文件
# filename_rule: Atitle # 压缩文件的命名规则
# zip_dir: ${JM_ZIP_DIR} # 压缩文件存放的文件夹

View File

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

View File

@ -1,11 +1,12 @@
from .jm_client_interface import *
# 抽象基类实现了域名管理发请求重试机制debug缓存等功能
class AbstractJmClient(
JmcomicClient,
PostmanProxy,
):
client_key: None
client_key = None
def __init__(self,
postman: Postman,
@ -145,11 +146,11 @@ class AbstractJmClient(
def is_cache_enabled(self) -> bool:
return getattr(self, '__enable_cache__', False)
def get_jmcomic_url(self, postman=None):
return JmModuleConfig.get_jmcomic_url(postman or self.get_root_postman())
def get_html_domain(self, postman=None):
return JmModuleConfig.get_html_domain(postman or self.get_root_postman())
def get_jmcomic_domain_all(self, postman=None):
return JmModuleConfig.get_jmcomic_domain_all(postman or self.get_root_postman())
def get_html_domain_all(self, postman=None):
return JmModuleConfig.get_html_domain_all(postman or self.get_root_postman())
def get_domain_list(self):
return self.domain_list
@ -390,6 +391,7 @@ class JmHtmlClient(AbstractJmClient):
)
# 基于禁漫移动端APP实现的JmClient
class JmApiClient(AbstractJmClient):
client_key = 'api'
API_SEARCH = '/search'

View File

@ -373,14 +373,22 @@ class JmcomicClient(
JmSearchAlbumClient,
Postman,
):
def get_jmcomic_url(self):
return JmModuleConfig.get_jmcomic_url()
def get_jmcomic_domain_all(self):
return JmModuleConfig.get_jmcomic_domain_all()
client_key: None
def get_domain_list(self) -> List[str]:
"""
获取当前client的域名配置
"""
raise NotImplementedError
def set_domain_list(self, domain_list: List[str]):
"""
设置当前client的域名配置
"""
raise NotImplementedError
def get_html_domain(self):
return JmModuleConfig.get_html_domain()
def get_html_domain_all(self):
return JmModuleConfig.get_html_domain_all()

View File

@ -52,18 +52,27 @@ class JmModuleConfig:
SCRAMBLE_NUM_8 = 421926 # 2023-02-08后改了图片切割算法
SCRAMBLE_CACHE = {}
# API的相关配置
# 移动端api密钥
# 移动端API的相关配置
# API密钥
MAGIC_18COMICAPPCONTENT = '18comicAPPContent'
# 移动端的图片域名
DOMAIN_IMAGE_LIST = [f"cdn-msp.jmapiproxy{i}.cc" for i in range(1, 4)]
# 下载时的一些默认值配置
default_author = 'default-author'
# 域名配置 - 移动端
# 图片域名
DOMAIN_API_IMAGE_LIST = [f"cdn-msp.jmapiproxy{i}.cc" for i in range(1, 4)]
# API域名
DOMAIN_API_LIST = [
"www.jmapinode1.cc",
"www.jmapinode2.cc",
"www.jmapinode3.cc",
"www.jmapibranch2.cc",
]
# 模块级别的可重写配置
DOMAIN = None
DOMAIN_LIST = None
# 域名配置 - 网页端
# 无需配置默认为None需要的时候会发起请求获得
DOMAIN_HTML = None
DOMAIN_HTML_LIST = None
# 模块级别的可重写类配置
CLASS_DOWNLOADER = None
CLASS_OPTION = None
CLASS_ALBUM = None
@ -86,6 +95,8 @@ class JmModuleConfig:
enable_jm_debug = True
# debug时解码url
decode_url_when_debug = True
# 下载时的一些默认值配置
default_author = 'default-author'
@classmethod
def downloader_class(cls):
@ -139,18 +150,18 @@ class JmModuleConfig:
return clazz
@classmethod
@field_cache("DOMAIN")
def domain(cls, postman=None):
@field_cache("DOMAIN_HTML")
def get_html_domain(cls, postman=None):
"""
由于禁漫的域名经常变化调用此方法可以获取一个当前可用的最新的域名 domain
并且设置把 domain 设置为禁漫模块的默认域名
这样一来配置文件也不用配置域名了一切都在运行时动态获取
"""
from .jm_toolkit import JmcomicText
return JmcomicText.parse_to_jm_domain(cls.get_jmcomic_url(postman))
return JmcomicText.parse_to_jm_domain(cls.get_html_url(postman))
@classmethod
def get_jmcomic_url(cls, postman=None):
def get_html_url(cls, postman=None):
"""
访问禁漫的永久网域从而得到一个可用的禁漫网址
@return: https://jm-comic2.cc
@ -158,14 +169,14 @@ class JmModuleConfig:
postman = postman or cls.new_postman(session=True)
url = postman.with_redirect_catching().get(cls.JM_REDIRECT_URL)
cls.jm_debug('获取禁漫URL', f'[{cls.JM_REDIRECT_URL}] → [{url}]')
cls.jm_debug('获取禁漫网页URL', f'[{cls.JM_REDIRECT_URL}] → [{url}]')
return url
@classmethod
@field_cache("DOMAIN_LIST")
def get_jmcomic_domain_all(cls, postman=None):
@field_cache("DOMAIN_HTML_LIST")
def get_html_domain_all(cls, postman=None):
"""
访问禁漫发布页得到所有禁漫的域名
访问禁漫发布页得到所有的禁漫网页域名
@return: ['18comic.vip', ..., 'jm365.xyz/ZNPJam'], 最后一个是APP軟件下載
"""
@ -179,7 +190,7 @@ class JmModuleConfig:
from .jm_toolkit import JmcomicText
domain_list = JmcomicText.analyse_jm_pub_html(resp.text)
cls.jm_debug('获取禁漫全部域名', f'[{resp.url}] → {domain_list}')
cls.jm_debug('获取禁漫网页全部域名', f'[{resp.url}] → {domain_list}')
return domain_list
@classmethod
@ -240,6 +251,8 @@ class JmModuleConfig:
# option 默认配置字典
JM_OPTION_VER = '2.1'
CLIENT_IMPL_DEFAULT = 'html'
default_option_dict: dict = {
'version': JM_OPTION_VER,
'debug': None,
@ -262,7 +275,7 @@ class JmModuleConfig:
'headers': None,
}
},
'impl': 'html',
'impl': None,
'retry_times': 5
},
'plugin': {},
@ -293,10 +306,9 @@ class JmModuleConfig:
if client['cache'] is None:
client['cache'] = True
# headers
meta_data = client['postman']['meta_data']
if meta_data['headers'] is None:
meta_data['headers'] = cls.headers()
# client impl
if client['impl'] is None:
client['impl'] = cls.CLIENT_IMPL_DEFAULT
# threading photo
dt = option_dict['download']['threading']
@ -312,7 +324,12 @@ class JmModuleConfig:
@classmethod
def register_client(cls, client_class):
cls.CLASS_CLIENT_IMPL[client_class.client_key] = client_class
client_key = getattr(client_class, 'client_key', None)
if client_key is None:
from .jm_toolkit import ExceptionTool
ExceptionTool.raises(f'未配置client_key, class: {client_class}')
cls.CLASS_CLIENT_IMPL[client_key] = client_class
jm_debug = JmModuleConfig.jm_debug

View File

@ -300,6 +300,10 @@ class JmOption:
def new_jm_client(self, domain_list=None, impl=None, **kwargs) -> JmcomicClient:
postman_conf: dict = self.client.postman.src_dict
impl = impl or self.client.impl
if domain_list is None:
domain_list = self.client.domain
domain_list: List[str]
# support kwargs overwrite meta_data
if len(kwargs) != 0:
@ -311,15 +315,16 @@ class JmOption:
postman = Postmans.create(data=postman_conf)
# domain_list
if domain_list is None:
domain_list = self.client.domain
domain_list: List[str]
if len(domain_list) == 0:
domain_list = [JmModuleConfig.domain()]
domain_list = self.decide_client_domain(impl)
# headers
meta_data = postman_conf['meta_data']
if meta_data['headers'] is None:
meta_data['headers'] = JmModuleConfig.headers(domain_list[0])
# client
client = JmModuleConfig.client_impl_class(impl or self.client.impl)(
client = JmModuleConfig.client_impl_class(impl)(
postman,
self.client.retry_times,
fallback_domain_list=domain_list,
@ -331,6 +336,28 @@ class JmOption:
return client
# noinspection PyMethodMayBeStatic
def decide_client_domain(self, client_key: str) -> List[str]:
def is_client_type(ct: Type[JmcomicClient]):
if client_key == ct:
return True
clazz = JmModuleConfig.client_impl_class(client_key)
if issubclass(clazz, ct):
return True
return False
if is_client_type(JmApiClient):
# 移动端
return JmModuleConfig.DOMAIN_API_LIST
if is_client_type(JmHtmlClient):
# 网页端
return [JmModuleConfig.get_html_domain()]
ExceptionTool.raises(f'没有配置域名且是无法识别的client类型: {client_key}')
@classmethod
def merge_default_dict(cls, user_dict, default_dict=None):
"""

View File

@ -465,7 +465,7 @@ class JmApiAdaptTool:
fields['sort'] = sort
fields['scramble_id'] = '0'
import random
fields['data_original_domain'] = random.choice(JmModuleConfig.DOMAIN_IMAGE_LIST)
fields['data_original_domain'] = random.choice(JmModuleConfig.DOMAIN_API_IMAGE_LIST)
class JmImageTool:

View File

@ -9,33 +9,66 @@ from jmcomic import *
# set encoding
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, 'utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, 'utf-8')
# 获取项目根目录
project_dir = os.path.abspath(os.path.dirname(__file__) + '/../..')
os.chdir(project_dir)
def ts():
return time_stamp(False)
skip_time_cost_debug = file_exists('./.idea')
cost_time_dict = {}
class JmTestConfigurable(unittest.TestCase):
option: JmOption = None
client: JmcomicClient = None
project_dir: str = None
project_dir: str = project_dir
def setUp(self) -> None:
print_sep('>')
if skip_time_cost_debug:
return
method_name = self._testMethodName
cost_time_dict[method_name] = ts()
print_eye_catching(f' [{format_ts()} | {method_name}] '.center(70, '🚀'))
def tearDown(self) -> None:
print_sep('<')
if skip_time_cost_debug:
return
method_name = self._testMethodName
begin = cost_time_dict[method_name]
end = ts()
print_eye_catching(f' [cost {end - begin:.02f}s | {self._testMethodName}] '.center(70, ''))
@classmethod
def setUpClass(cls):
# 获取项目根目录
cls.project_dir = os.path.abspath(os.path.dirname(__file__) + '/../..')
os.chdir(cls.project_dir)
# 设置 JmOptionJmcomicClient
option = create_option_by_env('JM_OPTION_PATH_TEST')
try:
option = create_option_by_env('JM_OPTION_PATH_TEST')
except JmcomicException:
option = create_option('./assets/config/option_test.yml')
cls.option = option
cls.client = option.build_jm_client()
# 跨平台设置
cls.adapt_os()
if skip_time_cost_debug:
return
cost_time_dict[cls.__name__] = ts()
@classmethod
def tearDownClass(cls) -> None:
if skip_time_cost_debug:
return
begin = cost_time_dict[cls.__name__]
end = ts()
print_eye_catching(f' [total cost {end - begin:.02f}s | {cls.__name__}] '.center(60, '-'))
@classmethod
def use_option(cls, op_filename: str) -> JmOption:
return create_option(f'./assets/config/{op_filename}')

View File

@ -45,8 +45,8 @@ class Test_Api(JmTestConfigurable):
def test_get_jmcomic_url(self):
func_list = {
self.client.get_jmcomic_url,
self.client.get_jmcomic_domain_all,
self.client.get_html_domain,
self.client.get_html_domain_all,
# JmModuleConfig.get_jmcomic_url,
# JmModuleConfig.get_jmcomic_domain_all,
}

View File

@ -60,6 +60,8 @@ class Test_Client(JmTestConfigurable):
JmModuleConfig.raise_exception_executor = default_raise_exception_executor
ExceptionTool.replace_old_exception_executor(raises)
self.assertRaises(B, JmcomicText.parse_to_album_id, 'asdhasjhkd')
# 还原
JmModuleConfig.raise_exception_executor = default_raise_exception_executor
def test_detail_property_list(self):
album = self.client.get_album_detail(410090)

View File

@ -36,3 +36,63 @@ class Test_Custom(JmTestConfigurable):
os.path.abspath(save_dir),
os.path.abspath(base_dir + dic[1] + '/' + dic[2]),
)
def test_extends_api_client(self):
class MyClient(JmApiClient):
pass
JmModuleConfig.register_client(MyClient)
self.assertListEqual(
JmModuleConfig.DOMAIN_API_LIST,
self.option.new_jm_client(domain_list=[], impl=MyClient.client_key).get_domain_list()
)
def test_extends_html_client(self):
class MyClient(JmHtmlClient):
pass
JmModuleConfig.register_client(MyClient)
html_domain = self.client.get_html_domain()
self.assertListEqual(
[html_domain],
self.option.new_jm_client(domain_list=[], impl=MyClient.client_key).get_domain_list()
)
def test_client_key_missing(self):
class MyClient(JmcomicClient):
pass
# '不重写 client_key'
self.assertRaises(
JmModuleConfig.CLASS_EXCEPTION,
JmModuleConfig.register_client,
MyClient,
)
def test_custom_client_empty_domain(self):
class MyClient(JmcomicClient):
client_key = 'myclient'
pass
JmModuleConfig.register_client(MyClient)
# '自定义client不配置域名'
self.assertRaises(
JmModuleConfig.CLASS_EXCEPTION,
self.option.new_jm_client,
domain_list=[],
impl=MyClient.client_key,
)
def test_client_empty_domain(self):
class MyClient(JmApiClient):
client_key = 'myclient'
pass
JmModuleConfig.register_client(MyClient)
self.assertListEqual(
JmModuleConfig.DOMAIN_API_LIST,
self.option.new_jm_client(domain_list=[], impl=MyClient.client_key).get_domain_list(),
msg='继承client不配置域名',
)

View File

@ -12,6 +12,21 @@ jmcomic.download_album('422866') # 传入要下载的album的id即可下载
# 如果没有配置,则会使用 JmOption.default(),下载的路径是[当前工作文件夹/本子章节名称/图片].
"""
--------------------
批量下载介绍
--------------------
"""
from jmcomic import download_album
# 如果你想要批量下载,可以使用 list/set/tuple/生成器 作为第一个参数。
# 第二个参数依然是可选的JmOption对象
download_album(['422866', '1', '2', '3']) # list
download_album({'422866', '1', '2', '3'}) # set
download_album(('422866', '1', '2', '3')) # tuple
download_album(aid for aid in ('422866', '1', '2', '3')) # 生成器
"""
--------------------
配置文件上手
@ -27,9 +42,9 @@ option.to_file('保存路径.yml') # yml格式
option.to_file('保存路径.json') # json格式
# 如果你修改了默认配置,现在想用你修改后的配置来下载,使用如下代码
option = JmOption.from_file('保存路径.yml')
# 或者
option = create_option('保存路径.yml')
# 或者
option = JmOption.from_file('保存路径.yml')
# 使用你的option配置来下载
jmcomic.download_album('23333', option)
@ -71,39 +86,3 @@ download_album('xxx', option)
JmModuleConfig.default_option_dict['client']['postman']['meta_data']['proxies'] = ProxyBuilder.clash_proxy()
# 调用下载api**不需要**传入option
download_album('xxx')
"""
--------------------
批量下载介绍
--------------------
"""
from jmcomic import download_album
# 如果你想要批量下载,可以使用 list/set/tuple/生成器 作为第一个参数。
# 第二个参数依然是可选的JmOption对象
download_album(['422866', '1', '2', '3']) # list
download_album({'422866', '1', '2', '3'}) # set
download_album(('422866', '1', '2', '3')) # tuple
download_album(aid for aid in ('422866', '1', '2', '3')) # 生成器
"""
--------------------
获取域名介绍
--------------------
"""
from jmcomic import JmModuleConfig
# 方式1: 访问禁漫发布页
domain_ls = JmModuleConfig.get_jmcomic_url_all()
print(url_ls)
# 方式2: 访问禁漫的永久网域
url = JmModuleConfig.get_jmcomic_url()
print(url)
# Qdownload_album(xxx)没有传入option那么访问JM使用的默认域名是什么?
# A默认域名是: JmModuleConfig.get_jmcomic_url(),如果想定制此默认域名:
# 做法1: 定制option见上
# 做法2: JmModuleConfig.DOMAIN = '18comic.vip'

View File

@ -0,0 +1,41 @@
"""
该文件介绍不同客户端以及配置客户端实现的方式移动端网页端
有关不同客户端的介绍:
网页端:
代号为 html
最早实现的客户端由于网页信息较为丰富相对而言效率比较高
但是不同域名会限制ip比如美国ip不能使用 18comic.vip 域名
不同域名的网站稳定性也差别很大
国内直连域名经常换
移动端:
代号为 api
在v2.3.3版本中引入的新客户端实现
比网页端效率比较低因为同样的信息量移动端需要请求多个接口
但是不限制ip地区兼容性很好国内直连比较稳定
1. 通过配置文件
```yml
client:
# 下面两行只能选一行放到你的配置文件
impl: api # 移动端
impl: html # 网页端
```
2. 通过修改模块配置类 JmModuleConfig
# JmModuleConfig.CLIENT_IMPL_DEFAULT 该字段表示默认的客户端实现
# 当配置文件中不存在client.impl配置时就使用 JmModuleConfig.CLIENT_IMPL_DEFAULT
# 你可以不在配置文件中配置而是使用下面的代码在你创建option前
JmModuleConfig.CLIENT_IMPL_DEFAULT = 'api' # 移动端
JmModuleConfig.CLIENT_IMPL_DEFAULT = 'html' # 网页端
"""

View File

@ -56,21 +56,20 @@ def custom_client_class():
# 默认情况下JmOption使用client类是根据配置项 `client.impl` 决定的
# JmOption会根据`client.impl`到 JmModuleConfig.CLASS_CLIENT_IMPL 中查找
# 你可以自定义一个`client.impl`,例如 'my-client'
# 或者使用jmcomic内置 'html' 和 'api'
# 然后把你的`client.impl`和类一起配置到JmModuleConfig中
# 自定义client的步骤如下
# 1. 自定义Client类
class MyClient(JmHtmlClient):
client_key = 'myclient'
pass
# 2. 让你的配置类生效
JmModuleConfig.CLASS_CLIENT_IMPL['my-client'] = MyClient
# 2. 让MyClient生效
JmModuleConfig.register_client(MyClient)
# 3. 在配置文件中使用你定义的client.impl后续使用这个option即可
"""
client:
impl: 'my-client'
impl: myclient
"""

View File

@ -22,7 +22,7 @@ client = option.build_jm_client()
def download_jm_album():
ls = str_to_list('''
438696
https://18comic.vip/album/497896/
''')