Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
Copy Markdown

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.

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

# 重写json()方法,专门处理API响应的清理
@field_cache()
def json(self) -> Dict:
from .jm_toolkit import safe_parse_json # 导入清理函数
try:
# 仅对API响应进行清理(保留原始JmJsonResp的逻辑不变)
clean_text = safe_parse_json(self.resp.text)
return clean_text
except Exception as e:
ExceptionTool.raises_resp(f'API json解析失败: {e}', self, JsonResolveFailException)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix ImportError and make JSON parsing robust (pipeline red).

safe_parse_json is not available and the import occurs before the try/except, causing an ImportError and test failure. Also avoid catching bare Exception and address Ruff RUF003 punctuation nits.

Apply this diff:

-    # 重写json()方法,专门处理API响应的清理
+    # 重写json()方法, 专门处理API响应的清理
     @field_cache()
     def json(self) -> Dict:
-        from .jm_toolkit import safe_parse_json  # 导入清理函数
-        try:
-            # 仅对API响应进行清理(保留原始JmJsonResp的逻辑不变)
-            clean_text = safe_parse_json(self.resp.text)
-            return clean_text
-        except Exception as e:
-            ExceptionTool.raises_resp(f'API json解析失败: {e}', self, JsonResolveFailException)
+        # 延迟导入, 若不存在则优雅降级到 resp.json()
+        try:
+            from .jm_toolkit import safe_parse_json  # type: ignore
+        except Exception:
+            safe_parse_json = None  # type: ignore[assignment]
+
+        try:
+            # 仅对API响应进行清理 (保留原始 JmJsonResp 的逻辑不变)
+            text = self.resp.text
+            data = safe_parse_json(text) if callable(safe_parse_json) else self.resp.json()
+            if not isinstance(data, dict):
+                raise TypeError(f"Expected dict, got {type(data).__name__}")
+            return data
+        except (ValueError, TypeError) as e:
+            ExceptionTool.raises_resp(f'API json解析失败: {e}', self, JsonResolveFailException)

Add this import at the top of the file to silence F405 on Dict:

from typing import Dict
🧰 Tools
🪛 Ruff (0.12.2)

104-104: Comment contains ambiguous (FULLWIDTH COMMA). Did you mean , (COMMA)?

(RUF003)


105-105: field_cache may be undefined, or defined from star imports

(F405)


106-106: Dict may be undefined, or defined from star imports

(F405)


109-109: Comment contains ambiguous (FULLWIDTH LEFT PARENTHESIS). Did you mean ( (LEFT PARENTHESIS)?

(RUF003)


109-109: Comment contains ambiguous (FULLWIDTH RIGHT PARENTHESIS). Did you mean ) (RIGHT PARENTHESIS)?

(RUF003)


111-111: Consider moving this statement to an else block

(TRY300)


112-112: Do not catch blind exception: Exception

(BLE001)


113-113: ExceptionTool may be undefined, or defined from star imports

(F405)


113-113: JsonResolveFailException may be undefined, or defined from star imports

(F405)

🪛 GitHub Actions: Run Test (API)

[error] 107-107: Command 'python -m unittest' failed due to ImportError: cannot import name 'safe_parse_json' from 'jmcomic.jm_toolkit' (/home/runner/work/JMComic-Crawler-Python/JMComic-Crawler-Python/src/jmcomic/jm_toolkit.py).

@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
Copy Markdown

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
Copy Markdown

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)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
Loading