Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# .github/release.yml

changelog:
exclude:
labels:
- ignore-for-release
authors:
- octocat
categories:
- title: 🏕 Features
labels:
- '*'
exclude:
labels:
- dependencies
- title: 👒 Dependencies
labels:
- dependencies
- title: Other Changes
labels:
- "*"
3 changes: 2 additions & 1 deletion .github/workflows/release_auto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@ jobs:
python .github/release.py "$commit_message"

- name: Create Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.tb.outputs.tag }}
body_path: release_body.txt
generate_release_notes: true

- name: Build
run: |
Expand Down
8 changes: 7 additions & 1 deletion assets/option/option_test_api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,10 @@ plugins:
- plugin: client_proxy
kwargs:
proxy_client_key: photo_concurrent_fetcher_proxy
whitelist: [ api, ]
whitelist: [ api, ]

- plugin: advanced-retry
kwargs:
retry_config:
retry_rounds: 3 # 一共对域名列表重试3轮
retry_domain_max_times: 5 # 当一个域名重试次数超过5次,忽略该域名,不再重试
Comment on lines +30 to +34
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Add newline at EOF; confirm retry interplay.

Add final newline. Also verify that client.retry_times is intentionally unused when advanced-retry is active to avoid confusion.


🏁 Script executed:

#!/bin/bash
# Inspect where domain_retry_strategy overrides built-in retry and where retry_times is read.
rg -n -C2 'domain_retry_strategy|retry_times' src/jmcomic

Length of output: 3936


🏁 Script executed:

#!/bin/bash
# Check newline at EOF
file="assets/option/option_test_api.yml"
if [ ! -f "$file" ]; then
  echo "$file not found"
else
  tail -c1 "$file" | od -An -t c
fi

echo '--- request_with_retry snippet ---'
rg -n -C3 "def request_with_retry" src/jmcomic/jm_plugin.py || echo "def request_with_retry not found"

echo '--- retry_times usages in jm_plugin.py ---'
rg -n "retry_times" -C3 src/jmcomic/jm_plugin.py || echo "retry_times not found"

Length of output: 842


🏁 Script executed:

#!/bin/bash
python3 - << 'EOF'
from pathlib import Path
f = Path('assets/option/option_test_api.yml')
b = f.read_bytes()
print("newline at EOF" if b.endswith(b'\n') else "no newline at EOF")
EOF

Length of output: 29


Add final newline to assets/option/option_test_api.yml. Confirmed that when advanced-retry (domain_retry_strategy) is set, JmcomicClient always uses request_with_retry and ignores client.retry_times, so retry_times is intentionally unused.

🧰 Tools
🪛 YAMLlint (1.37.1)

[error] 34-34: no new line character at the end of file

(new-line-at-end-of-file)

🤖 Prompt for AI Agents
In assets/option/option_test_api.yml around lines 30 to 34, the file is missing
a trailing newline; add a final newline character at the end of the file so the
YAML ends with a newline. Also leave the advanced-retry block unchanged (the
note that JmcomicClient uses request_with_retry when domain_retry_strategy is
set and ignores client.retry_times is expected), so only add the newline and do
not modify the retry configuration.

2 changes: 1 addition & 1 deletion src/jmcomic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# 被依赖方 <--- 使用方
# config <--- entity <--- toolkit <--- client <--- option <--- downloader

__version__ = '2.6.6'
__version__ = '2.6.7'

from .api import *
from .jm_plugin import *
Expand Down
4 changes: 2 additions & 2 deletions src/jmcomic/jm_client_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,7 @@ def require_resp_status_ok(self, resp: JmApiResp):
检查返回数据中的status字段是否为ok
"""
data = resp.model_data
if data.status == 'ok':
if data.status != 'ok':
ExceptionTool.raises_resp(data.msg, resp)

def req_api(self, url, get=True, require_success=True, **kwargs) -> JmApiResp:
Expand Down Expand Up @@ -995,7 +995,7 @@ def raise_if_resp_should_retry(self, resp, is_image):
# 找到第一个有效字符
ExceptionTool.require_true(
char == '{',
f'请求不是json格式,强制重试!响应文本: [{resp.text}]'
f'请求不是json格式,强制重试!响应文本: [{JmcomicText.limit_text(text, 200)}]'
)
return resp

Expand Down
8 changes: 8 additions & 0 deletions src/jmcomic/jm_client_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ def __init__(self, resp, ts: str):
super().__init__(resp)
self.ts = ts

# 重写json()方法,可以忽略一些非json格式的脏数据
@field_cache()
def json(self) -> Dict:
try:
return JmcomicText.try_parse_json_object(self.resp.text)
except Exception as e:
ExceptionTool.raises_resp(f'json解析失败: {e}', self, JsonResolveFailException)

@property
def is_success(self) -> bool:
return super().is_success and self.json()['code'] == 200
Expand Down
2 changes: 1 addition & 1 deletion src/jmcomic/jm_option.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ def decide_domain_list():
if clazz == AbstractJmClient or not issubclass(clazz, AbstractJmClient):
raise NotImplementedError(clazz)

client: AbstractJmClient = clazz(
client: JmcomicClient = clazz(
postman=postman,
domain_list=decide_domain_list(),
retry_times=retry_times,
Expand Down
77 changes: 71 additions & 6 deletions src/jmcomic/jm_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1221,23 +1221,88 @@ def new_decide_dir(photo, ensure_exists=True) -> str:
class AdvancedRetryPlugin(JmOptionPlugin):
plugin_key = 'advanced-retry'

def __init__(self, option: JmOption):
super().__init__(option)
self.retry_config = None

def invoke(self,
retry_config,
**kwargs):
self.require_param(isinstance(retry_config, dict), '必须配置retry_config为dict')
self.retry_config = retry_config

Comment on lines +1231 to +1233
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate required keys and normalize types in retry_config.

Fail fast if keys are missing; coerce to int to avoid surprises later.

Apply this diff:

-        self.require_param(isinstance(retry_config, dict), '必须配置retry_config为dict')
-        self.retry_config = retry_config
+        self.require_param(isinstance(retry_config, dict), '必须配置retry_config为dict')
+        required = ('retry_rounds', 'retry_domain_max_times')
+        missing = [k for k in required if k not in retry_config]
+        self.require_param(not missing, f'缺少配置项: {missing}')
+        retry_config['retry_rounds'] = int(retry_config['retry_rounds'])
+        retry_config['retry_domain_max_times'] = int(retry_config['retry_domain_max_times'])
+        self.retry_config = retry_config
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.require_param(isinstance(retry_config, dict), '必须配置retry_config为dict')
self.retry_config = retry_config
self.require_param(isinstance(retry_config, dict), '必须配置retry_config为dict')
required = ('retry_rounds', 'retry_domain_max_times')
missing = [k for k in required if k not in retry_config]
self.require_param(not missing, f'缺少配置项: {missing}')
retry_config['retry_rounds'] = int(retry_config['retry_rounds'])
retry_config['retry_domain_max_times'] = int(retry_config['retry_domain_max_times'])
self.retry_config = retry_config
🤖 Prompt for AI Agents
In src/jmcomic/jm_plugin.py around lines 1231-1233, after verifying retry_config
is a dict, validate that all required keys used elsewhere (define a local
required_keys list matching the keys the retry logic expects) are present and
non-empty by calling self.require_param for each missing key to fail fast; then
normalize numeric fields by coercing values to int (wrap int(...) in try/except
to raise a clear require_param error on invalid types) and replace
self.retry_config with the normalized dict so later code won’t encounter
unexpected types.

new_jm_client: Callable = self.option.new_jm_client

def hook_new_jm_client(*args, **kwargs):
client: AbstractJmClient = new_jm_client(*args, **kwargs)
client: JmcomicClient = new_jm_client(*args, **kwargs)
client.domain_retry_strategy = self.request_with_retry
client.domain_req_failed_counter = {}
from threading import Lock
client.domain_counter_lock = Lock()
return client

self.option.new_jm_client = hook_new_jm_client

def request_with_retry(self,
client,
request,
url,
is_image,
client: AbstractJmClient,
request: Callable,
url: str,
is_image: bool,
**kwargs,
):
pass
"""
实现如下域名重试机制:
- 对域名列表轮询请求,配置:retry_rounds
- 限制单个域名最大失败次数,配置:retry_domain_max_times
- 轮询域名列表前,根据历史失败次数对域名列表排序,失败多的后置
"""

def do_request(domain):
url_to_use = url
if url_to_use.startswith('/'):
# path → url
url_to_use = client.of_api_url(url, domain)
client.update_request_with_specify_domain(kwargs, domain, is_image)
jm_log(client.log_topic(), client.decode(url_to_use))
elif is_image:
# 图片url
client.update_request_with_specify_domain(kwargs, None, is_image)

resp = request(url_to_use, **kwargs)
resp = client.raise_if_resp_should_retry(resp, is_image)
return resp

Comment on lines +1260 to +1274
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use per-attempt kwargs and reset failure count on success.

  • Each attempt should use its own kwargs copy; domain-specific header mutations shouldn’t leak across attempts.
  • Reset the domain’s failure counter on success to allow recovery.

Apply this diff:

-        def do_request(domain):
-            url_to_use = url
+        def do_request(domain):
+            # Use a fresh kwargs for each attempt
+            local_kwargs = dict(kwargs)
+            url_to_use = url
             if url_to_use.startswith('/'):
                 # path → url
                 url_to_use = client.of_api_url(url, domain)
-                client.update_request_with_specify_domain(kwargs, domain, is_image)
+                client.update_request_with_specify_domain(local_kwargs, domain, is_image)
                 jm_log(client.log_topic(), client.decode(url_to_use))
             elif is_image:
                 # 图片url
-                client.update_request_with_specify_domain(kwargs, None, is_image)
+                client.update_request_with_specify_domain(local_kwargs, None, is_image)
 
-            resp = request(url_to_use, **kwargs)
+            resp = request(url_to_use, **local_kwargs)
             resp = client.raise_if_resp_should_retry(resp, is_image)
+            # Success → clear failed counter for this domain
+            with client.domain_counter_lock:
+                client.domain_req_failed_counter[domain] = 0
             return resp
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def do_request(domain):
url_to_use = url
if url_to_use.startswith('/'):
# path → url
url_to_use = client.of_api_url(url, domain)
client.update_request_with_specify_domain(kwargs, domain, is_image)
jm_log(client.log_topic(), client.decode(url_to_use))
elif is_image:
# 图片url
client.update_request_with_specify_domain(kwargs, None, is_image)
resp = request(url_to_use, **kwargs)
resp = client.raise_if_resp_should_retry(resp, is_image)
return resp
def do_request(domain):
# Use a fresh kwargs for each attempt
local_kwargs = dict(kwargs)
url_to_use = url
if url_to_use.startswith('/'):
# path → url
url_to_use = client.of_api_url(url, domain)
client.update_request_with_specify_domain(local_kwargs, domain, is_image)
jm_log(client.log_topic(), client.decode(url_to_use))
elif is_image:
# 图片url
client.update_request_with_specify_domain(local_kwargs, None, is_image)
resp = request(url_to_use, **local_kwargs)
resp = client.raise_if_resp_should_retry(resp, is_image)
# Success → clear failed counter for this domain
with client.domain_counter_lock:
client.domain_req_failed_counter[domain] = 0
return resp
🧰 Tools
🪛 Ruff (0.12.2)

1266-1266: jm_log may be undefined, or defined from star imports

(F405)

🤖 Prompt for AI Agents
In src/jmcomic/jm_plugin.py around lines 1260 to 1274, the request loop mutates
and reuses the same kwargs across retry attempts and never clears a domain
failure count on success; change it so each attempt creates a fresh copy of
kwargs (e.g., per_attempt_kwargs = dict(kwargs)) and use that copy for
client.update_request_with_specify_domain and request so domain-specific header
changes do not leak between attempts, and after a successful response reset the
domain's failure counter (either via the existing client reset helper if
available or by setting the client's failure counter for that domain to zero) so
the domain can recover for future requests.

retry_domain_max_times: int = self.retry_config['retry_domain_max_times']
retry_rounds: int = self.retry_config['retry_rounds']
for rindex in range(retry_rounds):
domain_list = self.get_sorted_domain(client, retry_domain_max_times)
for i, domain in enumerate(domain_list):
if self.failed_count(client, domain) >= retry_domain_max_times:
continue

try:
return do_request(domain)
except Exception as e:
from common import traceback_print_exec
traceback_print_exec()
jm_log('req.error', str(e))
self.update_failed_count(client, domain)

return client.fallback(request, url, 0, 0, is_image, **kwargs)

def get_sorted_domain(self, client: JmcomicClient, times):
domain_list = client.get_domain_list()
return sorted(
filter(lambda d: self.failed_count(client, d) < times, domain_list),
key=lambda d: self.failed_count(client, d)
)

# noinspection PyUnresolvedReferences
def update_failed_count(self, client: AbstractJmClient, domain: str):
with client.domain_counter_lock:
client.domain_req_failed_counter[domain] = self.failed_count(client, domain) + 1

@staticmethod
def failed_count(client: JmcomicClient, domain: str) -> int:
# noinspection PyUnresolvedReferences
return client.domain_req_failed_counter.get(domain, 0)
34 changes: 26 additions & 8 deletions src/jmcomic/jm_toolkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class JmcomicText:

# 提取接口返回值信息
pattern_ajax_favorite_msg = compile(r'</button>(.*?)</div>')
# 提取api接口返回值里的json,防止返回值里有无关日志导致json解析报错
pattern_api_response_json_object = compile(r'\{[\s\S]*?}')

@classmethod
def parse_to_jm_domain(cls, text: str):
Expand Down Expand Up @@ -344,6 +346,28 @@ def try_mkdir(cls, save_dir: str):
raise e
return save_dir

# noinspection PyTypeChecker
@classmethod
def try_parse_json_object(cls, resp_text: str) -> dict:
import json
text = resp_text.strip()
if text.startswith('{') and text.endswith('}'):
# fast case
return json.loads(text)

for match in cls.pattern_api_response_json_object.finditer(text):
try:
return json.loads(match.group(0))
except Exception as e:
jm_log('parse_json_object.error', e)

raise AssertionError(f'未解析出json数据: {cls.limit_text(resp_text, 200)}')

@classmethod
def limit_text(cls, text: str, limit: int) -> str:
length = len(text)
return text if length <= limit else (text[:limit] + f'...({length - limit}')


# 支持dsl: #{???} -> os.getenv(???)
JmcomicText.dsl_replacer.add_dsl_and_replacer(r'\$\{(.*?)\}', JmcomicText.match_os_env)
Expand Down Expand Up @@ -450,10 +474,7 @@ def parse_html_to_search_page(cls, html: str) -> JmSearchPage:
# 这里不作解析,因为没什么用...
tags = cls.pattern_html_search_tags.findall(tag_text)
content.append((
album_id, {
'name': title, # 改成name是为了兼容 parse_api_resp_to_page
'tags': tags
}
album_id, dict(name=title, tags=tags) # 改成name是为了兼容 parse_api_resp_to_page
))

return JmSearchPage(content, total)
Expand All @@ -468,10 +489,7 @@ def parse_html_to_category_page(cls, html: str) -> JmSearchPage:
for (album_id, title, tag_text) in album_info_list:
tags = cls.pattern_html_search_tags.findall(tag_text)
content.append((
album_id, {
'name': title, # 改成name是为了兼容 parse_api_resp_to_page
'tags': tags
}
album_id, dict(name=title, tags=tags) # 改成name是为了兼容 parse_api_resp_to_page
))

return JmSearchPage(content, total)
Expand Down
Loading