jmcomic 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jmcomic/__init__.py +29 -0
- jmcomic/api.py +131 -0
- jmcomic/cl.py +121 -0
- jmcomic/jm_client_impl.py +1217 -0
- jmcomic/jm_client_interface.py +609 -0
- jmcomic/jm_config.py +505 -0
- jmcomic/jm_downloader.py +350 -0
- jmcomic/jm_entity.py +695 -0
- jmcomic/jm_exception.py +191 -0
- jmcomic/jm_option.py +647 -0
- jmcomic/jm_plugin.py +1203 -0
- jmcomic/jm_toolkit.py +937 -0
- jmcomic-0.0.2.dist-info/METADATA +229 -0
- jmcomic-0.0.2.dist-info/RECORD +18 -0
- jmcomic-0.0.2.dist-info/WHEEL +5 -0
- jmcomic-0.0.2.dist-info/entry_points.txt +2 -0
- jmcomic-0.0.2.dist-info/licenses/LICENSE +21 -0
- jmcomic-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
from threading import Lock
|
|
2
|
+
|
|
3
|
+
from .jm_client_interface import *
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# 抽象基类,实现了域名管理,发请求,重试机制,log,缓存等功能
|
|
7
|
+
class AbstractJmClient(
|
|
8
|
+
JmcomicClient,
|
|
9
|
+
PostmanProxy,
|
|
10
|
+
):
|
|
11
|
+
client_key = '__just_for_placeholder_do_not_use_me__'
|
|
12
|
+
func_to_cache = []
|
|
13
|
+
|
|
14
|
+
def __init__(self,
|
|
15
|
+
postman: Postman,
|
|
16
|
+
domain_list: List[str],
|
|
17
|
+
retry_times=0,
|
|
18
|
+
):
|
|
19
|
+
"""
|
|
20
|
+
创建JM客户端
|
|
21
|
+
|
|
22
|
+
:param postman: 负责实现HTTP请求的对象,持有cookies、headers、proxies等信息
|
|
23
|
+
:param domain_list: 禁漫域名
|
|
24
|
+
:param retry_times: 重试次数
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(postman)
|
|
27
|
+
self.retry_times = retry_times
|
|
28
|
+
self.domain_list = domain_list
|
|
29
|
+
self.CLIENT_CACHE = None
|
|
30
|
+
self._username = None # help for favorite_folder method
|
|
31
|
+
self.enable_cache()
|
|
32
|
+
self.after_init()
|
|
33
|
+
|
|
34
|
+
def after_init(self):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
def get(self, url, **kwargs):
|
|
38
|
+
return self.request_with_retry(self.postman.get, url, **kwargs)
|
|
39
|
+
|
|
40
|
+
def post(self, url, **kwargs):
|
|
41
|
+
return self.request_with_retry(self.postman.post, url, **kwargs)
|
|
42
|
+
|
|
43
|
+
def of_api_url(self, api_path, domain):
|
|
44
|
+
return JmcomicText.format_url(api_path, domain)
|
|
45
|
+
|
|
46
|
+
def get_jm_image(self, img_url) -> JmImageResp:
|
|
47
|
+
|
|
48
|
+
def callback(resp):
|
|
49
|
+
"""
|
|
50
|
+
使用此方法包装 self.get,使得图片数据为空时,判定为请求失败时,走重试逻辑
|
|
51
|
+
"""
|
|
52
|
+
resp = JmImageResp(resp)
|
|
53
|
+
resp.require_success()
|
|
54
|
+
return resp
|
|
55
|
+
|
|
56
|
+
return self.get(img_url, callback=callback, headers=JmModuleConfig.new_html_headers())
|
|
57
|
+
|
|
58
|
+
def request_with_retry(self,
|
|
59
|
+
request,
|
|
60
|
+
url,
|
|
61
|
+
domain_index=0,
|
|
62
|
+
retry_count=0,
|
|
63
|
+
callback=None,
|
|
64
|
+
**kwargs,
|
|
65
|
+
):
|
|
66
|
+
"""
|
|
67
|
+
支持重试和切换域名的机制
|
|
68
|
+
|
|
69
|
+
如果url包含了指定域名,则不会切换域名,例如图片URL。
|
|
70
|
+
|
|
71
|
+
如果需要拿到域名进行回调处理,可以重写 self.update_request_with_specify_domain 方法,例如更新headers
|
|
72
|
+
|
|
73
|
+
:param request: 请求方法
|
|
74
|
+
:param url: 图片url / path (/album/xxx)
|
|
75
|
+
:param domain_index: 域名下标
|
|
76
|
+
:param retry_count: 重试次数
|
|
77
|
+
:param callback: 回调,可以接收resp返回新的resp,也可以抛出异常强制重试
|
|
78
|
+
:param kwargs: 请求方法的kwargs
|
|
79
|
+
"""
|
|
80
|
+
if domain_index >= len(self.domain_list):
|
|
81
|
+
return self.fallback(request, url, domain_index, retry_count, **kwargs)
|
|
82
|
+
|
|
83
|
+
url_backup = url
|
|
84
|
+
|
|
85
|
+
if url.startswith('/'):
|
|
86
|
+
# path → url
|
|
87
|
+
domain = self.domain_list[domain_index]
|
|
88
|
+
url = self.of_api_url(url, domain)
|
|
89
|
+
|
|
90
|
+
self.update_request_with_specify_domain(kwargs, domain)
|
|
91
|
+
|
|
92
|
+
jm_log(self.log_topic(), self.decode(url))
|
|
93
|
+
else:
|
|
94
|
+
# 图片url
|
|
95
|
+
self.update_request_with_specify_domain(kwargs, None, True)
|
|
96
|
+
|
|
97
|
+
if domain_index != 0 or retry_count != 0:
|
|
98
|
+
jm_log(f'req.retry',
|
|
99
|
+
', '.join([
|
|
100
|
+
f'次数: [{retry_count}/{self.retry_times}]',
|
|
101
|
+
f'域名: [{domain_index} of {self.domain_list}]',
|
|
102
|
+
f'路径: [{url}]',
|
|
103
|
+
f'参数: [{kwargs if "login" not in url else "#login_form#"}]'
|
|
104
|
+
])
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
resp = request(url, **kwargs)
|
|
109
|
+
|
|
110
|
+
# 回调,可以接收resp返回新的resp,也可以抛出异常强制重试
|
|
111
|
+
if callback is not None:
|
|
112
|
+
resp = callback(resp)
|
|
113
|
+
|
|
114
|
+
# 依然是回调,在最后返回之前,还可以判断resp是否重试
|
|
115
|
+
resp = self.raise_if_resp_should_retry(resp)
|
|
116
|
+
|
|
117
|
+
return resp
|
|
118
|
+
except Exception as e:
|
|
119
|
+
if self.retry_times == 0:
|
|
120
|
+
raise e
|
|
121
|
+
|
|
122
|
+
self.before_retry(e, kwargs, retry_count, url)
|
|
123
|
+
|
|
124
|
+
if retry_count < self.retry_times:
|
|
125
|
+
return self.request_with_retry(request, url_backup, domain_index, retry_count + 1, callback, **kwargs)
|
|
126
|
+
else:
|
|
127
|
+
return self.request_with_retry(request, url_backup, domain_index + 1, 0, callback, **kwargs)
|
|
128
|
+
|
|
129
|
+
# noinspection PyMethodMayBeStatic
|
|
130
|
+
def raise_if_resp_should_retry(self, resp):
|
|
131
|
+
"""
|
|
132
|
+
依然是回调,在最后返回之前,还可以判断resp是否重试
|
|
133
|
+
"""
|
|
134
|
+
return resp
|
|
135
|
+
|
|
136
|
+
def update_request_with_specify_domain(self, kwargs: dict, domain: Optional[str], is_image: bool = False):
|
|
137
|
+
"""
|
|
138
|
+
域名自动切换时,用于更新请求参数的回调
|
|
139
|
+
"""
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
# noinspection PyMethodMayBeStatic
|
|
143
|
+
def log_topic(self):
|
|
144
|
+
return self.client_key
|
|
145
|
+
|
|
146
|
+
# noinspection PyMethodMayBeStatic, PyUnusedLocal
|
|
147
|
+
def before_retry(self, e, kwargs, retry_count, url):
|
|
148
|
+
jm_log('req.error', str(e))
|
|
149
|
+
|
|
150
|
+
def enable_cache(self):
|
|
151
|
+
# noinspection PyDefaultArgument,PyShadowingBuiltins
|
|
152
|
+
def make_key(args, kwds, typed,
|
|
153
|
+
kwd_mark=(object(),),
|
|
154
|
+
fasttypes={int, str},
|
|
155
|
+
tuple=tuple, type=type, len=len):
|
|
156
|
+
key = args
|
|
157
|
+
if kwds:
|
|
158
|
+
key += kwd_mark
|
|
159
|
+
for item in kwds.items():
|
|
160
|
+
key += item
|
|
161
|
+
if typed:
|
|
162
|
+
key += tuple(type(v) for v in args)
|
|
163
|
+
if kwds:
|
|
164
|
+
key += tuple(type(v) for v in kwds.values())
|
|
165
|
+
elif len(key) == 1 and type(key[0]) in fasttypes:
|
|
166
|
+
return key[0]
|
|
167
|
+
return hash(key)
|
|
168
|
+
|
|
169
|
+
def wrap_func_with_cache(func_name, cache_field_name):
|
|
170
|
+
if hasattr(self, cache_field_name):
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
func = getattr(self, func_name)
|
|
174
|
+
|
|
175
|
+
def cache_wrapper(*args, **kwargs):
|
|
176
|
+
cache = self.CLIENT_CACHE
|
|
177
|
+
|
|
178
|
+
# Equivalent to not enable cache
|
|
179
|
+
if cache is None:
|
|
180
|
+
return func(*args, **kwargs)
|
|
181
|
+
|
|
182
|
+
key = make_key(args, kwargs, False)
|
|
183
|
+
sentinel = object() # unique object used to signal cache misses
|
|
184
|
+
|
|
185
|
+
result = cache.get(key, sentinel)
|
|
186
|
+
if result is not sentinel:
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
result = func(*args, **kwargs)
|
|
190
|
+
cache[key] = result
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
setattr(self, func_name, cache_wrapper)
|
|
194
|
+
|
|
195
|
+
for func_name in self.func_to_cache:
|
|
196
|
+
wrap_func_with_cache(func_name, f'__{func_name}.cache.dict__')
|
|
197
|
+
|
|
198
|
+
def set_cache_dict(self, cache_dict: Optional[Dict]):
|
|
199
|
+
self.CLIENT_CACHE = cache_dict
|
|
200
|
+
|
|
201
|
+
def get_cache_dict(self):
|
|
202
|
+
return self.CLIENT_CACHE
|
|
203
|
+
|
|
204
|
+
def get_domain_list(self):
|
|
205
|
+
return self.domain_list
|
|
206
|
+
|
|
207
|
+
def set_domain_list(self, domain_list: List[str]):
|
|
208
|
+
self.domain_list = domain_list
|
|
209
|
+
|
|
210
|
+
# noinspection PyUnusedLocal
|
|
211
|
+
def fallback(self, request, url, domain_index, retry_count, **kwargs):
|
|
212
|
+
msg = f"请求重试全部失败: [{url}], {self.domain_list}"
|
|
213
|
+
jm_log('req.fallback', msg)
|
|
214
|
+
ExceptionTool.raises(msg, {}, RequestRetryAllFailException)
|
|
215
|
+
|
|
216
|
+
# noinspection PyMethodMayBeStatic
|
|
217
|
+
def append_params_to_url(self, url, params):
|
|
218
|
+
from urllib.parse import urlencode
|
|
219
|
+
|
|
220
|
+
# 将参数字典编码为查询字符串
|
|
221
|
+
query_string = urlencode(params)
|
|
222
|
+
url = f"{url}?{query_string}"
|
|
223
|
+
return url
|
|
224
|
+
|
|
225
|
+
# noinspection PyMethodMayBeStatic
|
|
226
|
+
def decode(self, url: str):
|
|
227
|
+
if not JmModuleConfig.FLAG_DECODE_URL_WHEN_LOGGING or '/search/' not in url:
|
|
228
|
+
return url
|
|
229
|
+
|
|
230
|
+
from urllib.parse import unquote
|
|
231
|
+
return unquote(url.replace('+', ' '))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# 基于网页实现的JmClient
|
|
235
|
+
class JmHtmlClient(AbstractJmClient):
|
|
236
|
+
client_key = 'html'
|
|
237
|
+
|
|
238
|
+
func_to_cache = ['search', 'fetch_detail_entity']
|
|
239
|
+
|
|
240
|
+
API_SEARCH = '/search/photos'
|
|
241
|
+
API_CATEGORY = '/albums'
|
|
242
|
+
|
|
243
|
+
def add_favorite_album(self,
|
|
244
|
+
album_id,
|
|
245
|
+
folder_id='0',
|
|
246
|
+
):
|
|
247
|
+
data = {
|
|
248
|
+
'album_id': album_id,
|
|
249
|
+
'fid': folder_id,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
resp = self.get_jm_html(
|
|
253
|
+
'/ajax/favorite_album',
|
|
254
|
+
data=data,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
res = resp.json()
|
|
258
|
+
|
|
259
|
+
if res['status'] != 1:
|
|
260
|
+
msg = parse_unicode_escape_text(res['msg'])
|
|
261
|
+
error_msg = PatternTool.match_or_default(msg, JmcomicText.pattern_ajax_favorite_msg, msg)
|
|
262
|
+
# 此圖片已經在您最喜愛的清單!
|
|
263
|
+
|
|
264
|
+
self.raise_request_error(
|
|
265
|
+
resp,
|
|
266
|
+
error_msg
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return resp
|
|
270
|
+
|
|
271
|
+
def get_album_detail(self, album_id) -> JmAlbumDetail:
|
|
272
|
+
return self.fetch_detail_entity(album_id, 'album')
|
|
273
|
+
|
|
274
|
+
def get_photo_detail(self,
|
|
275
|
+
photo_id,
|
|
276
|
+
fetch_album=True,
|
|
277
|
+
fetch_scramble_id=True,
|
|
278
|
+
) -> JmPhotoDetail:
|
|
279
|
+
photo = self.fetch_detail_entity(photo_id, 'photo')
|
|
280
|
+
|
|
281
|
+
# 一并获取该章节的所处本子
|
|
282
|
+
# todo: 可优化,获取章节所在本子,其实不需要等待章节获取完毕后。
|
|
283
|
+
# 可以直接调用 self.get_album_detail(photo_id),会重定向返回本子的HTML
|
|
284
|
+
# (had polished by FutureClientProxy)
|
|
285
|
+
if fetch_album is True:
|
|
286
|
+
photo.from_album = self.get_album_detail(photo.album_id)
|
|
287
|
+
|
|
288
|
+
return photo
|
|
289
|
+
|
|
290
|
+
def fetch_detail_entity(self, jmid, prefix):
|
|
291
|
+
# 参数校验
|
|
292
|
+
jmid = JmcomicText.parse_to_jm_id(jmid)
|
|
293
|
+
|
|
294
|
+
# 请求
|
|
295
|
+
resp = self.get_jm_html(f"/{prefix}/{jmid}")
|
|
296
|
+
|
|
297
|
+
# 用 JmcomicText 解析 html,返回实体类
|
|
298
|
+
if prefix == 'album':
|
|
299
|
+
return JmcomicText.analyse_jm_album_html(resp.text)
|
|
300
|
+
|
|
301
|
+
if prefix == 'photo':
|
|
302
|
+
return JmcomicText.analyse_jm_photo_html(resp.text)
|
|
303
|
+
|
|
304
|
+
def search(self,
|
|
305
|
+
search_query: str,
|
|
306
|
+
page: int,
|
|
307
|
+
main_tag: int,
|
|
308
|
+
order_by: str,
|
|
309
|
+
time: str,
|
|
310
|
+
category: str,
|
|
311
|
+
sub_category: Optional[str],
|
|
312
|
+
) -> JmSearchPage:
|
|
313
|
+
"""
|
|
314
|
+
网页搜索API
|
|
315
|
+
"""
|
|
316
|
+
params = {
|
|
317
|
+
'main_tag': main_tag,
|
|
318
|
+
'search_query': search_query,
|
|
319
|
+
'page': page,
|
|
320
|
+
'o': order_by,
|
|
321
|
+
't': time,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
url = self.build_search_url(self.API_SEARCH, category, sub_category)
|
|
325
|
+
|
|
326
|
+
resp = self.get_jm_html(
|
|
327
|
+
self.append_params_to_url(url, params),
|
|
328
|
+
allow_redirects=True,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# 检查是否发生了重定向
|
|
332
|
+
# 因为如果搜索的是禁漫车号,会直接跳转到本子详情页面
|
|
333
|
+
if resp.redirect_count != 0 and '/album/' in resp.url:
|
|
334
|
+
album = JmcomicText.analyse_jm_album_html(resp.text)
|
|
335
|
+
return JmSearchPage.wrap_single_album(album)
|
|
336
|
+
else:
|
|
337
|
+
return JmPageTool.parse_html_to_search_page(resp.text)
|
|
338
|
+
|
|
339
|
+
@classmethod
|
|
340
|
+
def build_search_url(cls, base: str, category: str, sub_category: Optional[str]):
|
|
341
|
+
"""
|
|
342
|
+
构建网页搜索/分类的URL
|
|
343
|
+
|
|
344
|
+
示例:
|
|
345
|
+
:param base: "/search/photos"
|
|
346
|
+
:param category CATEGORY_DOUJIN
|
|
347
|
+
:param sub_category SUB_DOUJIN_CG
|
|
348
|
+
:return "/search/photos/doujin/sub/CG"
|
|
349
|
+
"""
|
|
350
|
+
if category == JmMagicConstants.CATEGORY_ALL:
|
|
351
|
+
return base
|
|
352
|
+
|
|
353
|
+
if sub_category is None:
|
|
354
|
+
return f'{base}/{category}'
|
|
355
|
+
else:
|
|
356
|
+
return f'{base}/{category}/sub/{sub_category}'
|
|
357
|
+
|
|
358
|
+
def categories_filter(self,
|
|
359
|
+
page: int,
|
|
360
|
+
time: str,
|
|
361
|
+
category: str,
|
|
362
|
+
order_by: str,
|
|
363
|
+
sub_category: Optional[str] = None,
|
|
364
|
+
) -> JmCategoryPage:
|
|
365
|
+
params = {
|
|
366
|
+
'page': page,
|
|
367
|
+
'o': order_by,
|
|
368
|
+
't': time,
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
url = self.build_search_url(self.API_CATEGORY, category, sub_category)
|
|
372
|
+
|
|
373
|
+
resp = self.get_jm_html(
|
|
374
|
+
self.append_params_to_url(url, params),
|
|
375
|
+
allow_redirects=True,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return JmPageTool.parse_html_to_category_page(resp.text)
|
|
379
|
+
|
|
380
|
+
# -- 帐号管理 --
|
|
381
|
+
|
|
382
|
+
def login(self,
|
|
383
|
+
username,
|
|
384
|
+
password,
|
|
385
|
+
id_remember='on',
|
|
386
|
+
login_remember='on',
|
|
387
|
+
):
|
|
388
|
+
"""
|
|
389
|
+
返回response响应对象
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
data = {
|
|
393
|
+
'username': username,
|
|
394
|
+
'password': password,
|
|
395
|
+
'id_remember': id_remember,
|
|
396
|
+
'login_remember': login_remember,
|
|
397
|
+
'submit_login': '',
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
resp = self.post('/login',
|
|
401
|
+
data=data,
|
|
402
|
+
allow_redirects=False,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
if resp.status_code != 200:
|
|
406
|
+
ExceptionTool.raises_resp(f'登录失败,状态码为{resp.status_code}', resp)
|
|
407
|
+
|
|
408
|
+
orig_cookies = self.get_meta_data('cookies') or {}
|
|
409
|
+
new_cookies = dict(resp.cookies)
|
|
410
|
+
# 重复登录下存在bug,AVS会丢失
|
|
411
|
+
if 'AVS' in orig_cookies and 'AVS' not in new_cookies:
|
|
412
|
+
return resp
|
|
413
|
+
|
|
414
|
+
self['cookies'] = new_cookies
|
|
415
|
+
self._username = username
|
|
416
|
+
|
|
417
|
+
return resp
|
|
418
|
+
|
|
419
|
+
def favorite_folder(self,
|
|
420
|
+
page=1,
|
|
421
|
+
order_by=JmMagicConstants.ORDER_BY_LATEST,
|
|
422
|
+
folder_id='0',
|
|
423
|
+
username='',
|
|
424
|
+
) -> JmFavoritePage:
|
|
425
|
+
if username == '':
|
|
426
|
+
ExceptionTool.require_true(self._username is not None, 'favorite_folder方法需要传username参数')
|
|
427
|
+
username = self._username
|
|
428
|
+
|
|
429
|
+
resp = self.get_jm_html(
|
|
430
|
+
f'/user/{username}/favorite/albums',
|
|
431
|
+
params={
|
|
432
|
+
'page': page,
|
|
433
|
+
'o': order_by,
|
|
434
|
+
'folder': folder_id,
|
|
435
|
+
}
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
return JmPageTool.parse_html_to_favorite_page(resp.text)
|
|
439
|
+
|
|
440
|
+
# noinspection PyTypeChecker
|
|
441
|
+
def get_username_from_cookies(self) -> str:
|
|
442
|
+
# cookies = self.get_meta_data('cookies', None)
|
|
443
|
+
# if not cookies:
|
|
444
|
+
# ExceptionTool.raises('未登录,无法获取到对应的用户名,请给favorite方法传入username参数')
|
|
445
|
+
# 解析cookies,可能需要用到 phpserialize,比较麻烦,暂不实现
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
def get_jm_html(self, url, require_200=True, **kwargs):
|
|
449
|
+
"""
|
|
450
|
+
请求禁漫网页的入口
|
|
451
|
+
"""
|
|
452
|
+
resp = self.get(url, **kwargs)
|
|
453
|
+
|
|
454
|
+
if require_200 is True and resp.status_code != 200:
|
|
455
|
+
# 检查是否是特殊的状态码(JmModuleConfig.JM_ERROR_STATUS_CODE)
|
|
456
|
+
# 如果是,直接抛出异常
|
|
457
|
+
self.check_special_http_code(resp)
|
|
458
|
+
# 运行到这里说明上一步没有抛异常,说明是未知状态码,抛异常兜底处理
|
|
459
|
+
self.raise_request_error(resp)
|
|
460
|
+
|
|
461
|
+
# 检查请求是否成功
|
|
462
|
+
self.require_resp_success_else_raise(resp, url)
|
|
463
|
+
|
|
464
|
+
return resp
|
|
465
|
+
|
|
466
|
+
def update_request_with_specify_domain(self, kwargs: dict, domain: Optional[str], is_image=False):
|
|
467
|
+
if is_image:
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
latest_headers = kwargs.get('headers', None)
|
|
471
|
+
base_headers = self.get_meta_data('headers', None) or JmModuleConfig.new_html_headers(domain)
|
|
472
|
+
base_headers.update(latest_headers or {})
|
|
473
|
+
kwargs['headers'] = base_headers
|
|
474
|
+
|
|
475
|
+
@classmethod
|
|
476
|
+
def raise_request_error(cls, resp, msg: Optional[str] = None):
|
|
477
|
+
"""
|
|
478
|
+
请求如果失败,统一由该方法抛出异常
|
|
479
|
+
"""
|
|
480
|
+
if msg is None:
|
|
481
|
+
msg = f"请求失败," \
|
|
482
|
+
f"响应状态码为{resp.status_code}," \
|
|
483
|
+
f"URL=[{resp.url}]," \
|
|
484
|
+
+ (f"响应文本=[{resp.text}]" if len(resp.text) < 200 else
|
|
485
|
+
f'响应文本过长(len={len(resp.text)}),不打印'
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
ExceptionTool.raises_resp(msg, resp)
|
|
489
|
+
|
|
490
|
+
def album_comment(self,
|
|
491
|
+
video_id,
|
|
492
|
+
comment,
|
|
493
|
+
originator='',
|
|
494
|
+
status='true',
|
|
495
|
+
comment_id=None,
|
|
496
|
+
**kwargs,
|
|
497
|
+
) -> JmAlbumCommentResp:
|
|
498
|
+
data = {
|
|
499
|
+
'video_id': video_id,
|
|
500
|
+
'comment': comment,
|
|
501
|
+
'originator': originator,
|
|
502
|
+
'status': status,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
# 处理回复评论
|
|
506
|
+
if comment_id is not None:
|
|
507
|
+
data.pop('status')
|
|
508
|
+
data['comment_id'] = comment_id
|
|
509
|
+
data['is_reply'] = 1
|
|
510
|
+
data['forum_subject'] = 1
|
|
511
|
+
|
|
512
|
+
jm_log('album.comment',
|
|
513
|
+
f'{video_id}: [{comment}]' +
|
|
514
|
+
(f' to ({comment_id})' if comment_id is not None else '')
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
resp = self.post('/ajax/album_comment', data=data)
|
|
518
|
+
|
|
519
|
+
ret = JmAlbumCommentResp(resp)
|
|
520
|
+
jm_log('album.comment', f'{video_id}: [{comment}] ← ({ret.model().cid})')
|
|
521
|
+
|
|
522
|
+
return ret
|
|
523
|
+
|
|
524
|
+
@classmethod
|
|
525
|
+
def require_resp_success_else_raise(cls, resp, url: str):
|
|
526
|
+
"""
|
|
527
|
+
:param resp: 响应对象
|
|
528
|
+
:param url: /photo/12412312
|
|
529
|
+
"""
|
|
530
|
+
resp_url: str = resp.url
|
|
531
|
+
|
|
532
|
+
# 1. 是否是特殊的内容
|
|
533
|
+
cls.check_special_text(resp)
|
|
534
|
+
|
|
535
|
+
# 2. 检查响应发送重定向,重定向url是否表示错误网页,即 /error/xxx
|
|
536
|
+
if resp.redirect_count == 0 or '/error/' not in resp_url:
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
# 3. 检查错误类型
|
|
540
|
+
def match_case(error_path):
|
|
541
|
+
return resp_url.endswith(error_path) and not url.endswith(error_path)
|
|
542
|
+
|
|
543
|
+
# 3.1 album_missing
|
|
544
|
+
if match_case('/error/album_missing'):
|
|
545
|
+
ExceptionTool.raise_missing(resp, JmcomicText.parse_to_jm_id(url))
|
|
546
|
+
|
|
547
|
+
# 3.2 user_missing
|
|
548
|
+
if match_case('/error/user_missing'):
|
|
549
|
+
ExceptionTool.raises_resp('此用戶名稱不存在,或者你没有登录,請再次確認使用名稱', resp)
|
|
550
|
+
|
|
551
|
+
# 3.3 invalid_module
|
|
552
|
+
if match_case('/error/invalid_module'):
|
|
553
|
+
ExceptionTool.raises_resp('發生了無法預期的錯誤。若問題持續發生,請聯繫客服支援', resp)
|
|
554
|
+
|
|
555
|
+
@classmethod
|
|
556
|
+
def check_special_text(cls, resp):
|
|
557
|
+
html = resp.text
|
|
558
|
+
url = resp.url
|
|
559
|
+
|
|
560
|
+
if len(html) > 500:
|
|
561
|
+
return
|
|
562
|
+
|
|
563
|
+
for content, reason in JmModuleConfig.JM_ERROR_RESPONSE_TEXT.items():
|
|
564
|
+
if content not in html:
|
|
565
|
+
continue
|
|
566
|
+
|
|
567
|
+
cls.raise_request_error(
|
|
568
|
+
resp,
|
|
569
|
+
f'{reason}({content})'
|
|
570
|
+
+ (f': {url}' if url is not None else '')
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
@classmethod
|
|
574
|
+
def check_special_http_code(cls, resp):
|
|
575
|
+
code = resp.status_code
|
|
576
|
+
url = resp.url
|
|
577
|
+
|
|
578
|
+
error_msg = JmModuleConfig.JM_ERROR_STATUS_CODE.get(int(code), None)
|
|
579
|
+
if error_msg is None:
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
cls.raise_request_error(
|
|
583
|
+
resp,
|
|
584
|
+
f"请求失败,"
|
|
585
|
+
f"响应状态码为{code},"
|
|
586
|
+
f'原因为: [{error_msg}], '
|
|
587
|
+
+ (f'URL=[{url}]' if url is not None else '')
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# 基于禁漫移动端(APP)实现的JmClient
|
|
592
|
+
class JmApiClient(AbstractJmClient):
|
|
593
|
+
client_key = 'api'
|
|
594
|
+
func_to_cache = ['search', 'fetch_detail_entity']
|
|
595
|
+
|
|
596
|
+
API_SEARCH = '/search'
|
|
597
|
+
API_CATEGORIES_FILTER = '/categories/filter'
|
|
598
|
+
API_ALBUM = '/album'
|
|
599
|
+
API_CHAPTER = '/chapter'
|
|
600
|
+
API_SCRAMBLE = '/chapter_view_template'
|
|
601
|
+
API_FAVORITE = '/favorite'
|
|
602
|
+
|
|
603
|
+
def search(self,
|
|
604
|
+
search_query: str,
|
|
605
|
+
page: int,
|
|
606
|
+
main_tag: int,
|
|
607
|
+
order_by: str,
|
|
608
|
+
time: str,
|
|
609
|
+
category: str,
|
|
610
|
+
sub_category: Optional[str],
|
|
611
|
+
) -> JmSearchPage:
|
|
612
|
+
"""
|
|
613
|
+
移动端暂不支持 category和sub_category
|
|
614
|
+
"""
|
|
615
|
+
params = {
|
|
616
|
+
'main_tag': main_tag,
|
|
617
|
+
'search_query': search_query,
|
|
618
|
+
'page': page,
|
|
619
|
+
'o': order_by,
|
|
620
|
+
't': time,
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
resp = self.req_api(self.append_params_to_url(self.API_SEARCH, params))
|
|
624
|
+
|
|
625
|
+
# 直接搜索禁漫车号,发生重定向的响应数据 resp.model_data
|
|
626
|
+
# {
|
|
627
|
+
# "search_query": "310311",
|
|
628
|
+
# "total": 1,
|
|
629
|
+
# "redirect_aid": "310311",
|
|
630
|
+
# "content": []
|
|
631
|
+
# }
|
|
632
|
+
data = resp.model_data
|
|
633
|
+
if data.get('redirect_aid', None) is not None:
|
|
634
|
+
aid = data.redirect_aid
|
|
635
|
+
return JmSearchPage.wrap_single_album(self.get_album_detail(aid))
|
|
636
|
+
|
|
637
|
+
return JmPageTool.parse_api_to_search_page(data)
|
|
638
|
+
|
|
639
|
+
def categories_filter(self,
|
|
640
|
+
page: int,
|
|
641
|
+
time: str,
|
|
642
|
+
category: str,
|
|
643
|
+
order_by: str,
|
|
644
|
+
sub_category: Optional[str] = None,
|
|
645
|
+
):
|
|
646
|
+
"""
|
|
647
|
+
移动端不支持 sub_category
|
|
648
|
+
"""
|
|
649
|
+
# o: mv, mv_m, mv_w, mv_t
|
|
650
|
+
o = f'{order_by}_{time}' if time != JmMagicConstants.TIME_ALL else order_by
|
|
651
|
+
|
|
652
|
+
params = {
|
|
653
|
+
'page': page,
|
|
654
|
+
'order': '', # 该参数为空
|
|
655
|
+
'c': category,
|
|
656
|
+
'o': o,
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
resp = self.req_api(self.append_params_to_url(self.API_CATEGORIES_FILTER, params))
|
|
660
|
+
|
|
661
|
+
return JmPageTool.parse_api_to_search_page(resp.model_data)
|
|
662
|
+
|
|
663
|
+
def get_album_detail(self, album_id) -> JmAlbumDetail:
|
|
664
|
+
return self.fetch_detail_entity(album_id,
|
|
665
|
+
JmModuleConfig.album_class(),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
def get_photo_detail(self,
|
|
669
|
+
photo_id,
|
|
670
|
+
fetch_album=True,
|
|
671
|
+
fetch_scramble_id=True,
|
|
672
|
+
) -> JmPhotoDetail:
|
|
673
|
+
photo: JmPhotoDetail = self.fetch_detail_entity(photo_id,
|
|
674
|
+
JmModuleConfig.photo_class(),
|
|
675
|
+
)
|
|
676
|
+
if fetch_album or fetch_scramble_id:
|
|
677
|
+
self.fetch_photo_additional_field(photo, fetch_album, fetch_scramble_id)
|
|
678
|
+
|
|
679
|
+
return photo
|
|
680
|
+
|
|
681
|
+
def get_scramble_id(self, photo_id, album_id=None):
|
|
682
|
+
"""
|
|
683
|
+
带有缓存的fetch_scramble_id,缓存位于 JmModuleConfig.SCRAMBLE_CACHE
|
|
684
|
+
"""
|
|
685
|
+
cache = JmModuleConfig.SCRAMBLE_CACHE
|
|
686
|
+
if photo_id in cache:
|
|
687
|
+
return cache[photo_id]
|
|
688
|
+
|
|
689
|
+
if album_id is not None and album_id in cache:
|
|
690
|
+
return cache[album_id]
|
|
691
|
+
|
|
692
|
+
scramble_id = self.fetch_scramble_id(photo_id)
|
|
693
|
+
cache[photo_id] = scramble_id
|
|
694
|
+
if album_id is not None:
|
|
695
|
+
cache[album_id] = scramble_id
|
|
696
|
+
|
|
697
|
+
return scramble_id
|
|
698
|
+
|
|
699
|
+
def fetch_detail_entity(self, jmid, clazz):
|
|
700
|
+
"""
|
|
701
|
+
请求实体类
|
|
702
|
+
"""
|
|
703
|
+
jmid = JmcomicText.parse_to_jm_id(jmid)
|
|
704
|
+
url = self.API_ALBUM if issubclass(clazz, JmAlbumDetail) else self.API_CHAPTER
|
|
705
|
+
resp = self.req_api(self.append_params_to_url(
|
|
706
|
+
url,
|
|
707
|
+
{
|
|
708
|
+
'id': jmid
|
|
709
|
+
})
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
if resp.res_data.get('name') is None:
|
|
713
|
+
ExceptionTool.raise_missing(resp, jmid)
|
|
714
|
+
|
|
715
|
+
return JmApiAdaptTool.parse_entity(resp.res_data, clazz)
|
|
716
|
+
|
|
717
|
+
def fetch_scramble_id(self, photo_id):
|
|
718
|
+
"""
|
|
719
|
+
请求scramble_id
|
|
720
|
+
"""
|
|
721
|
+
photo_id: str = JmcomicText.parse_to_jm_id(photo_id)
|
|
722
|
+
resp = self.req_api(
|
|
723
|
+
self.API_SCRAMBLE,
|
|
724
|
+
params={
|
|
725
|
+
'id': photo_id,
|
|
726
|
+
'mode': 'vertical',
|
|
727
|
+
'page': '0',
|
|
728
|
+
'app_img_shunt': '1',
|
|
729
|
+
'express': 'off',
|
|
730
|
+
'v': time_stamp(),
|
|
731
|
+
},
|
|
732
|
+
require_success=False,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
scramble_id = PatternTool.match_or_default(resp.text,
|
|
736
|
+
JmcomicText.pattern_html_album_scramble_id,
|
|
737
|
+
None,
|
|
738
|
+
)
|
|
739
|
+
if scramble_id is None:
|
|
740
|
+
jm_log('api.scramble', f'未匹配到scramble_id,响应文本:{resp.text}')
|
|
741
|
+
scramble_id = str(JmMagicConstants.SCRAMBLE_220980)
|
|
742
|
+
|
|
743
|
+
return scramble_id
|
|
744
|
+
|
|
745
|
+
def fetch_photo_additional_field(self, photo: JmPhotoDetail, fetch_album: bool, fetch_scramble_id: bool):
|
|
746
|
+
"""
|
|
747
|
+
获取章节的额外信息
|
|
748
|
+
1. scramble_id
|
|
749
|
+
2. album
|
|
750
|
+
如果都需要获取,会排队,效率低
|
|
751
|
+
|
|
752
|
+
todo: 改进实现 (had polished by FutureClientProxy)
|
|
753
|
+
1. 直接开两个线程跑
|
|
754
|
+
2. 开两个线程,但是开之前检查重复性
|
|
755
|
+
3. 线程池,也要检查重复性
|
|
756
|
+
23做法要改不止一处地方
|
|
757
|
+
"""
|
|
758
|
+
if fetch_album:
|
|
759
|
+
photo.from_album = self.get_album_detail(photo.album_id)
|
|
760
|
+
|
|
761
|
+
if fetch_scramble_id:
|
|
762
|
+
# 同album的scramble_id相同
|
|
763
|
+
photo.scramble_id = self.get_scramble_id(photo.photo_id, photo.album_id)
|
|
764
|
+
|
|
765
|
+
def setting(self) -> JmApiResp:
|
|
766
|
+
"""
|
|
767
|
+
禁漫app的setting请求,返回如下内容(resp.res_data)
|
|
768
|
+
{
|
|
769
|
+
"logo_path": "https://cdn-msp.jmapiproxy1.monster/media/logo/new_logo.png",
|
|
770
|
+
"main_web_host": "18-comic.work",
|
|
771
|
+
"img_host": "https://cdn-msp.jmapiproxy1.monster",
|
|
772
|
+
"base_url": "https://www.jmapinode.biz",
|
|
773
|
+
"is_cn": 0,
|
|
774
|
+
"cn_base_url": "https://www.jmapinode.biz",
|
|
775
|
+
"version": "1.6.0",
|
|
776
|
+
"test_version": "1.6.1",
|
|
777
|
+
"store_link": "https://play.google.com/store/apps/details?id=com.jiaohua_browser",
|
|
778
|
+
"ios_version": "1.6.0",
|
|
779
|
+
"ios_test_version": "1.6.1",
|
|
780
|
+
"ios_store_link": "https://18comic.vip/stray/",
|
|
781
|
+
"ad_cache_version": 1698140798,
|
|
782
|
+
"bundle_url": "https://18-comic.work/static/apk/patches1.6.0.zip",
|
|
783
|
+
"is_hot_update": true,
|
|
784
|
+
"api_banner_path": "https://cdn-msp.jmapiproxy1.monster/media/logo/channel_log.png?v=",
|
|
785
|
+
"version_info": "\nAPP & IOS更新\nV1.6.0\n#禁漫 APK 更新拉!!\n更新調整以下項目\n1. 系統優化\n\nV1.5.9\n1. 跳錯誤新增 重試 網頁 按鈕\n2. 圖片讀取優化\n3.
|
|
786
|
+
線路調整優化\n\n無法順利更新或是系統題是有風險請使用下方\n下載點2\n有問題可以到DC群反饋\nhttps://discord.gg/V74p7HM\n",
|
|
787
|
+
"app_shunts": [
|
|
788
|
+
{
|
|
789
|
+
"title": "圖源1",
|
|
790
|
+
"key": 1
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
"title": "圖源2",
|
|
794
|
+
"key": 2
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
"title": "圖源3",
|
|
798
|
+
"key": 3
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
"title": "圖源4",
|
|
802
|
+
"key": 4
|
|
803
|
+
}
|
|
804
|
+
],
|
|
805
|
+
"download_url": "https://18-comic.work/static/apk/1.6.0.apk",
|
|
806
|
+
"app_landing_page": "https://jm365.work/pXYbfA",
|
|
807
|
+
"float_ad": true
|
|
808
|
+
}
|
|
809
|
+
"""
|
|
810
|
+
resp = self.req_api('/setting')
|
|
811
|
+
|
|
812
|
+
# 检查禁漫最新的版本号
|
|
813
|
+
setting_ver = str(resp.model_data.version)
|
|
814
|
+
# 禁漫接口的版本 > jmcomic库内置版本
|
|
815
|
+
if setting_ver > JmMagicConstants.APP_VERSION and JmModuleConfig.FLAG_USE_VERSION_NEWER_IF_BEHIND:
|
|
816
|
+
jm_log('api.setting', f'change APP_VERSION from [{JmMagicConstants.APP_VERSION}] to [{setting_ver}]')
|
|
817
|
+
JmMagicConstants.APP_VERSION = setting_ver
|
|
818
|
+
|
|
819
|
+
return resp
|
|
820
|
+
|
|
821
|
+
def login(self,
|
|
822
|
+
username,
|
|
823
|
+
password,
|
|
824
|
+
) -> JmApiResp:
|
|
825
|
+
"""
|
|
826
|
+
{
|
|
827
|
+
"uid": "123",
|
|
828
|
+
"username": "x",
|
|
829
|
+
"email": "x",
|
|
830
|
+
"emailverified": "yes",
|
|
831
|
+
"photo": "x",
|
|
832
|
+
"fname": "",
|
|
833
|
+
"gender": "x",
|
|
834
|
+
"message": "Welcome x!",
|
|
835
|
+
"coin": 123,
|
|
836
|
+
"album_favorites": 123,
|
|
837
|
+
"s": "x",
|
|
838
|
+
"level_name": "x",
|
|
839
|
+
"level": 1,
|
|
840
|
+
"nextLevelExp": 123,
|
|
841
|
+
"exp": "123",
|
|
842
|
+
"expPercent": 123,
|
|
843
|
+
"badges": [],
|
|
844
|
+
"album_favorites_max": 123
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
"""
|
|
848
|
+
resp = self.req_api('/login', False, data={
|
|
849
|
+
'username': username,
|
|
850
|
+
'password': password,
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
cookies = dict(resp.resp.cookies)
|
|
854
|
+
cookies.update({'AVS': resp.res_data['s']})
|
|
855
|
+
self['cookies'] = cookies
|
|
856
|
+
|
|
857
|
+
return resp
|
|
858
|
+
|
|
859
|
+
def favorite_folder(self,
|
|
860
|
+
page=1,
|
|
861
|
+
order_by=JmMagicConstants.ORDER_BY_LATEST,
|
|
862
|
+
folder_id='0',
|
|
863
|
+
username='',
|
|
864
|
+
) -> JmFavoritePage:
|
|
865
|
+
resp = self.req_api(
|
|
866
|
+
self.API_FAVORITE,
|
|
867
|
+
params={
|
|
868
|
+
'page': page,
|
|
869
|
+
'folder_id': folder_id,
|
|
870
|
+
'o': order_by,
|
|
871
|
+
}
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
return JmPageTool.parse_api_to_favorite_page(resp.model_data)
|
|
875
|
+
|
|
876
|
+
def add_favorite_album(self,
|
|
877
|
+
album_id,
|
|
878
|
+
folder_id='0',
|
|
879
|
+
):
|
|
880
|
+
"""
|
|
881
|
+
移动端没有提供folder_id参数
|
|
882
|
+
"""
|
|
883
|
+
resp = self.req_api(
|
|
884
|
+
'/favorite',
|
|
885
|
+
data={
|
|
886
|
+
'aid': album_id,
|
|
887
|
+
},
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
self.require_resp_status_ok(resp)
|
|
891
|
+
|
|
892
|
+
return resp
|
|
893
|
+
|
|
894
|
+
# noinspection PyMethodMayBeStatic
|
|
895
|
+
def require_resp_status_ok(self, resp: JmApiResp):
|
|
896
|
+
"""
|
|
897
|
+
检查返回数据中的status字段是否为ok
|
|
898
|
+
"""
|
|
899
|
+
data = resp.model_data
|
|
900
|
+
if data.status == 'ok':
|
|
901
|
+
ExceptionTool.raises_resp(data.msg, resp)
|
|
902
|
+
|
|
903
|
+
def req_api(self, url, get=True, require_success=True, **kwargs) -> JmApiResp:
|
|
904
|
+
ts = self.decide_headers_and_ts(kwargs, url)
|
|
905
|
+
|
|
906
|
+
if get:
|
|
907
|
+
resp = self.get(url, **kwargs)
|
|
908
|
+
else:
|
|
909
|
+
resp = self.post(url, **kwargs)
|
|
910
|
+
|
|
911
|
+
resp = JmApiResp(resp, ts)
|
|
912
|
+
|
|
913
|
+
if require_success:
|
|
914
|
+
self.require_resp_success(resp, url)
|
|
915
|
+
|
|
916
|
+
return resp
|
|
917
|
+
|
|
918
|
+
def update_request_with_specify_domain(self, kwargs: dict, domain: Optional[str], is_image=False):
|
|
919
|
+
if is_image:
|
|
920
|
+
# 设置APP端的图片请求headers
|
|
921
|
+
kwargs['headers'] = {**JmModuleConfig.APP_HEADERS_TEMPLATE, **JmModuleConfig.APP_HEADERS_IMAGE}
|
|
922
|
+
|
|
923
|
+
# noinspection PyMethodMayBeStatic
|
|
924
|
+
def decide_headers_and_ts(self, kwargs, url):
|
|
925
|
+
# 获取时间戳
|
|
926
|
+
if url == self.API_SCRAMBLE:
|
|
927
|
+
# /chapter_view_template
|
|
928
|
+
# 这个接口很特殊,用的密钥 18comicAPPContent 而不是 18comicAPP
|
|
929
|
+
# 如果用后者,则会返回403信息
|
|
930
|
+
ts = time_stamp()
|
|
931
|
+
token, tokenparam = JmCryptoTool.token_and_tokenparam(ts, secret=JmMagicConstants.APP_TOKEN_SECRET_2)
|
|
932
|
+
|
|
933
|
+
elif JmModuleConfig.FLAG_USE_FIX_TIMESTAMP:
|
|
934
|
+
ts, token, tokenparam = JmModuleConfig.get_fix_ts_token_tokenparam()
|
|
935
|
+
|
|
936
|
+
else:
|
|
937
|
+
ts = time_stamp()
|
|
938
|
+
token, tokenparam = JmCryptoTool.token_and_tokenparam(ts)
|
|
939
|
+
|
|
940
|
+
# 设置headers
|
|
941
|
+
headers = kwargs.get('headers', None) or JmModuleConfig.APP_HEADERS_TEMPLATE.copy()
|
|
942
|
+
headers.update({
|
|
943
|
+
'token': token,
|
|
944
|
+
'tokenparam': tokenparam,
|
|
945
|
+
})
|
|
946
|
+
kwargs['headers'] = headers
|
|
947
|
+
|
|
948
|
+
return ts
|
|
949
|
+
|
|
950
|
+
@classmethod
|
|
951
|
+
def require_resp_success(cls, resp: JmApiResp, url: Optional[str] = None):
|
|
952
|
+
"""
|
|
953
|
+
|
|
954
|
+
:param resp: 响应对象
|
|
955
|
+
:param url: 请求路径,例如 /setting
|
|
956
|
+
"""
|
|
957
|
+
resp.require_success()
|
|
958
|
+
|
|
959
|
+
# 1. 检查是否 album_missing
|
|
960
|
+
# json: {'code': 200, 'data': []}
|
|
961
|
+
data = resp.model().data
|
|
962
|
+
if isinstance(data, list) and len(data) == 0:
|
|
963
|
+
ExceptionTool.raise_missing(resp, JmcomicText.parse_to_jm_id(url))
|
|
964
|
+
|
|
965
|
+
# 2. 是否是特殊的内容
|
|
966
|
+
# 暂无
|
|
967
|
+
|
|
968
|
+
def raise_if_resp_should_retry(self, resp):
|
|
969
|
+
"""
|
|
970
|
+
该方法会判断resp返回值是否是json格式,
|
|
971
|
+
如果不是,大概率是禁漫内部异常,需要进行重试
|
|
972
|
+
|
|
973
|
+
由于完整的json格式校验会有性能开销,所以只做简单的检查,
|
|
974
|
+
只校验第一个有效字符是不是 '{',如果不是,就认为异常数据,需要重试
|
|
975
|
+
|
|
976
|
+
:param resp: 响应对象
|
|
977
|
+
:return: resp
|
|
978
|
+
"""
|
|
979
|
+
if isinstance(resp, JmResp):
|
|
980
|
+
# 不对包装过的resp对象做校验,包装者自行校验
|
|
981
|
+
# 例如图片请求
|
|
982
|
+
return resp
|
|
983
|
+
|
|
984
|
+
code = resp.status_code
|
|
985
|
+
if code >= 500:
|
|
986
|
+
msg = JmModuleConfig.JM_ERROR_STATUS_CODE.get(code, f'HTTP状态码: {code}')
|
|
987
|
+
ExceptionTool.raises_resp(f"禁漫API异常响应, {msg}", resp)
|
|
988
|
+
|
|
989
|
+
url = resp.request.url
|
|
990
|
+
|
|
991
|
+
if self.API_SCRAMBLE in url:
|
|
992
|
+
# /chapter_view_template 这个接口不是返回json数据,不做检查
|
|
993
|
+
return resp
|
|
994
|
+
|
|
995
|
+
text = resp.text
|
|
996
|
+
for char in text:
|
|
997
|
+
if char not in (' ', '\n', '\t'):
|
|
998
|
+
# 找到第一个有效字符
|
|
999
|
+
ExceptionTool.require_true(
|
|
1000
|
+
char == '{',
|
|
1001
|
+
f'请求不是json格式,强制重试!响应文本: [{resp.text}]'
|
|
1002
|
+
)
|
|
1003
|
+
return resp
|
|
1004
|
+
|
|
1005
|
+
ExceptionTool.raises_resp(f'响应无数据!request_url=[{url}]', resp)
|
|
1006
|
+
|
|
1007
|
+
def after_init(self):
|
|
1008
|
+
# 自动更新禁漫API域名
|
|
1009
|
+
if JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN:
|
|
1010
|
+
self.update_api_domain()
|
|
1011
|
+
|
|
1012
|
+
# 保证拥有cookies,因为移动端要求必须携带cookies,否则会直接跳转同一本子【禁漫娘】
|
|
1013
|
+
if JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES:
|
|
1014
|
+
self.ensure_have_cookies()
|
|
1015
|
+
|
|
1016
|
+
client_update_domain_lock = Lock()
|
|
1017
|
+
|
|
1018
|
+
def update_api_domain(self):
|
|
1019
|
+
if True is JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN_DONE:
|
|
1020
|
+
return
|
|
1021
|
+
|
|
1022
|
+
with self.client_update_domain_lock:
|
|
1023
|
+
if True is JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN_DONE:
|
|
1024
|
+
return
|
|
1025
|
+
try:
|
|
1026
|
+
# 获取域名列表
|
|
1027
|
+
resp = self.postman.get(JmModuleConfig.API_URL_DOMAIN_SERVER)
|
|
1028
|
+
res_json = JmCryptoTool.decode_resp_data(resp.text, '', JmMagicConstants.API_DOMAIN_SERVER_SECRET)
|
|
1029
|
+
res_data = json_loads(res_json)
|
|
1030
|
+
|
|
1031
|
+
# 检查返回值
|
|
1032
|
+
if not res_data.get('Server', None):
|
|
1033
|
+
jm_log('api.update_domain.empty',
|
|
1034
|
+
f'获取禁漫最新API域名失败, 返回值: {res_json}')
|
|
1035
|
+
return
|
|
1036
|
+
new_server_list: List[str] = res_data['Server']
|
|
1037
|
+
old_server_list = JmModuleConfig.DOMAIN_API_LIST
|
|
1038
|
+
jm_log('api.update_domain.success',
|
|
1039
|
+
f'获取到最新的API域名,替换jmcomic内置域名:(new){new_server_list} ---→ (old){old_server_list}'
|
|
1040
|
+
)
|
|
1041
|
+
# 更新域名
|
|
1042
|
+
if self.domain_list is old_server_list:
|
|
1043
|
+
self.domain_list = new_server_list
|
|
1044
|
+
JmModuleConfig.DOMAIN_API_LIST = new_server_list
|
|
1045
|
+
except Exception as e:
|
|
1046
|
+
jm_log('api.update_domain.error',
|
|
1047
|
+
f'自动更新API域名失败,仍使用jmcomic内置域名。'
|
|
1048
|
+
f'可通过代码[JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN=False]关闭自动更新API域名. 异常: {e}'
|
|
1049
|
+
)
|
|
1050
|
+
finally:
|
|
1051
|
+
JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN_DONE = True
|
|
1052
|
+
|
|
1053
|
+
client_init_cookies_lock = Lock()
|
|
1054
|
+
|
|
1055
|
+
def ensure_have_cookies(self):
|
|
1056
|
+
if self.get_meta_data('cookies'):
|
|
1057
|
+
return
|
|
1058
|
+
|
|
1059
|
+
with self.client_init_cookies_lock:
|
|
1060
|
+
if self.get_meta_data('cookies'):
|
|
1061
|
+
return
|
|
1062
|
+
|
|
1063
|
+
self['cookies'] = self.get_cookies()
|
|
1064
|
+
|
|
1065
|
+
@field_cache("APP_COOKIES", obj=JmModuleConfig)
|
|
1066
|
+
def get_cookies(self):
|
|
1067
|
+
resp = self.setting()
|
|
1068
|
+
cookies = dict(resp.resp.cookies)
|
|
1069
|
+
return cookies
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
class PhotoConcurrentFetcherProxy(JmcomicClient):
|
|
1073
|
+
"""
|
|
1074
|
+
为了解决 JmApiClient.get_photo_detail 方法的排队调用问题,
|
|
1075
|
+
即在访问完photo的接口后,需要另外排队访问获取album和scramble_id的接口。
|
|
1076
|
+
|
|
1077
|
+
这三个接口可以并发请求,这样可以提高效率。
|
|
1078
|
+
|
|
1079
|
+
此Proxy代理了get_photo_detail,实现了并发请求这三个接口,然后组装返回值返回photo。
|
|
1080
|
+
|
|
1081
|
+
可通过插件 ClientProxyPlugin 启用本类,配置如下:
|
|
1082
|
+
```yml
|
|
1083
|
+
plugins:
|
|
1084
|
+
after_init:
|
|
1085
|
+
- plugin: client_proxy
|
|
1086
|
+
kwargs:
|
|
1087
|
+
proxy_client_key: photo_concurrent_fetcher_proxy
|
|
1088
|
+
```
|
|
1089
|
+
"""
|
|
1090
|
+
client_key = 'photo_concurrent_fetcher_proxy'
|
|
1091
|
+
|
|
1092
|
+
class FutureWrapper:
|
|
1093
|
+
def __init__(self, future, after_done_callback):
|
|
1094
|
+
from concurrent.futures import Future
|
|
1095
|
+
future: Future
|
|
1096
|
+
self.future = future
|
|
1097
|
+
self.done = False
|
|
1098
|
+
self._result = None
|
|
1099
|
+
self.after_done_callback = after_done_callback
|
|
1100
|
+
|
|
1101
|
+
def result(self):
|
|
1102
|
+
if not self.done:
|
|
1103
|
+
result = self.future.result()
|
|
1104
|
+
self._result = result
|
|
1105
|
+
self.done = True
|
|
1106
|
+
self.future = None # help gc
|
|
1107
|
+
self.after_done_callback()
|
|
1108
|
+
|
|
1109
|
+
return self._result
|
|
1110
|
+
|
|
1111
|
+
def __init__(self,
|
|
1112
|
+
client: JmcomicClient,
|
|
1113
|
+
max_workers=None,
|
|
1114
|
+
executors=None,
|
|
1115
|
+
):
|
|
1116
|
+
self.client = client
|
|
1117
|
+
self.route_notimpl_method_to_internal_client(client)
|
|
1118
|
+
|
|
1119
|
+
if executors is None:
|
|
1120
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
1121
|
+
executors = ThreadPoolExecutor(max_workers)
|
|
1122
|
+
|
|
1123
|
+
self.executors = executors
|
|
1124
|
+
self.future_dict: Dict[str, PhotoConcurrentFetcherProxy.FutureWrapper] = {}
|
|
1125
|
+
from threading import Lock
|
|
1126
|
+
self.lock = Lock()
|
|
1127
|
+
|
|
1128
|
+
def route_notimpl_method_to_internal_client(self, client):
|
|
1129
|
+
|
|
1130
|
+
proxy_methods = str_to_set('''
|
|
1131
|
+
get_album_detail
|
|
1132
|
+
get_photo_detail
|
|
1133
|
+
''')
|
|
1134
|
+
|
|
1135
|
+
# 获取对象的所有属性和方法的名称列表
|
|
1136
|
+
attributes_and_methods = dir(client)
|
|
1137
|
+
# 遍历属性和方法列表,并访问每个方法
|
|
1138
|
+
for method in attributes_and_methods:
|
|
1139
|
+
# 判断是否为方法(可调用对象)
|
|
1140
|
+
if (not method.startswith('_')
|
|
1141
|
+
and callable(getattr(client, method))
|
|
1142
|
+
and method not in proxy_methods
|
|
1143
|
+
):
|
|
1144
|
+
setattr(self, method, getattr(client, method))
|
|
1145
|
+
|
|
1146
|
+
def get_album_detail(self, album_id) -> JmAlbumDetail:
|
|
1147
|
+
album_id = JmcomicText.parse_to_jm_id(album_id)
|
|
1148
|
+
cache_key = f'album_{album_id}'
|
|
1149
|
+
future = self.get_future(cache_key, task=lambda: self.client.get_album_detail(album_id))
|
|
1150
|
+
return future.result()
|
|
1151
|
+
|
|
1152
|
+
def get_future(self, cache_key, task):
|
|
1153
|
+
if cache_key in self.future_dict:
|
|
1154
|
+
# cache hit, means that a same task is running
|
|
1155
|
+
return self.future_dict[cache_key]
|
|
1156
|
+
|
|
1157
|
+
with self.lock:
|
|
1158
|
+
if cache_key in self.future_dict:
|
|
1159
|
+
return self.future_dict[cache_key]
|
|
1160
|
+
|
|
1161
|
+
# after future done, remove it from future_dict.
|
|
1162
|
+
# cache depends on self.client instead of self.future_dict
|
|
1163
|
+
future = self.FutureWrapper(self.executors.submit(task),
|
|
1164
|
+
after_done_callback=lambda: self.future_dict.pop(cache_key, None)
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
self.future_dict[cache_key] = future
|
|
1168
|
+
return future
|
|
1169
|
+
|
|
1170
|
+
def get_photo_detail(self, photo_id, fetch_album=True, fetch_scramble_id=True) -> JmPhotoDetail:
|
|
1171
|
+
photo_id = JmcomicText.parse_to_jm_id(photo_id)
|
|
1172
|
+
client: JmcomicClient = self.client
|
|
1173
|
+
futures = [None, None, None]
|
|
1174
|
+
results = [None, None, None]
|
|
1175
|
+
|
|
1176
|
+
# photo_detail
|
|
1177
|
+
photo_future = self.get_future(f'photo_{photo_id}',
|
|
1178
|
+
lambda: client.get_photo_detail(photo_id,
|
|
1179
|
+
False,
|
|
1180
|
+
False)
|
|
1181
|
+
)
|
|
1182
|
+
futures[0] = photo_future
|
|
1183
|
+
|
|
1184
|
+
# fetch_album
|
|
1185
|
+
if fetch_album:
|
|
1186
|
+
album_future = self.get_future(f'album_{photo_id}',
|
|
1187
|
+
lambda: client.get_album_detail(photo_id))
|
|
1188
|
+
futures[1] = album_future
|
|
1189
|
+
else:
|
|
1190
|
+
results[1] = None
|
|
1191
|
+
|
|
1192
|
+
# fetch_scramble_id
|
|
1193
|
+
if fetch_scramble_id and isinstance(client, JmApiClient):
|
|
1194
|
+
client: JmApiClient
|
|
1195
|
+
scramble_future = self.get_future(f'scramble_id_{photo_id}',
|
|
1196
|
+
lambda: client.get_scramble_id(photo_id))
|
|
1197
|
+
futures[2] = scramble_future
|
|
1198
|
+
else:
|
|
1199
|
+
results[2] = ''
|
|
1200
|
+
|
|
1201
|
+
# wait finish
|
|
1202
|
+
for i, f in enumerate(futures):
|
|
1203
|
+
if f is None:
|
|
1204
|
+
continue
|
|
1205
|
+
results[i] = f.result()
|
|
1206
|
+
|
|
1207
|
+
# compose
|
|
1208
|
+
photo: JmPhotoDetail = results[0]
|
|
1209
|
+
album = results[1]
|
|
1210
|
+
scramble_id = results[2]
|
|
1211
|
+
|
|
1212
|
+
if album is not None:
|
|
1213
|
+
photo.from_album = album
|
|
1214
|
+
if scramble_id != '':
|
|
1215
|
+
photo.scramble_id = scramble_id
|
|
1216
|
+
|
|
1217
|
+
return photo
|