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.
@@ -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