Skip to content
Closed
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ jmv 350234 -y
## 项目特点

- **绕过Cloudflare的反爬虫**
- **实现禁漫APP接口最新的加解密算法 (1.6.3)**
- **实现禁漫APP接口最新的加解密算法**
- 用法多样:

- GitHub
Expand Down
11 changes: 7 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,20 @@ classifiers=[
]
dependencies = [
"commonx>=0.6.38",
"curl-cffi",
"pillow",
"pycryptodome",
"pyyaml",
"curl-cffi>=0.5.10",
"pillow>=10.0.0",
"pycryptodome>=3.19.0",
"pyyaml>=6.0",
]
dynamic = ["version"]

[project.urls]
Homepage = "https://github.com/hect0x7/JMComic-Crawler-Python"
Documentation = "https://jmcomic.readthedocs.io"

[project.optional-dependencies]
extra = ["zhconv", "pyzipper", "py7zr", "img2pdf", "psutil"]

[project.scripts]
jmcomic = "jmcomic.cli:main"
jmv = "jmcomic.cli:view_main"
Expand Down
94 changes: 50 additions & 44 deletions src/jmcomic/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,31 +78,22 @@ def download_album(jm_album_id,
) -> Union[__DOWNLOAD_API_RET, Set[__DOWNLOAD_API_RET]]:
"""
下载一个本子(album),包含其所有的章节(photo)

当jm_album_id不是str或int时,视为批量下载,相当于调用 download_batch(download_album, jm_album_id, option, downloader)

当jm_album_id不是str或int时,视为批量下载
:param jm_album_id: 本子的禁漫车号
:param option: 下载选项
:param downloader: 下载器类
:param callback: 返回值回调函数,可以拿到 album 和 downloader
:param check_exception: 是否检查异常, 如果为True,会检查downloader是否有下载异常,并上抛PartialDownloadFailedException
:param extra: 下载特性(Feature),下载时动态挂载的附加行为上下文。会自动根据上下文(如 album/photo 来源)自适应参数行为。支持单个 Feature、FeatureChain、或列表
:return: 对于的本子实体类,下载器(如果是上述的批量情况,返回值为download_batch的返回值)
:param check_exception: 是否检查异常
:param extra: 下载特性(Feature)
:return: DownloadResult (如果是批量情况返回 BatchResult)
Comment on lines +81 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Avoid introducing RUF002 violations in these docstrings.

The newly added fullwidth punctuation is flagged by Ruff and can fail lint when RUF002 is enabled. Replace it with ASCII punctuation or deliberately suppress/configure this rule for Chinese documentation.

Also applies to: 107-114, 190-192, 216-218

🧰 Tools
🪛 Ruff (0.15.21)

[warning] 81-81: Docstring contains ambiguous (FULLWIDTH COMMA). Did you mean , (COMMA)?

(RUF002)


[warning] 85-85: Docstring contains ambiguous (FULLWIDTH COMMA). Did you mean , (COMMA)?

(RUF002)


[warning] 87-87: Docstring contains ambiguous (FULLWIDTH LEFT PARENTHESIS). Did you mean ( (LEFT PARENTHESIS)?

(RUF002)


[warning] 87-87: Docstring contains ambiguous (FULLWIDTH RIGHT PARENTHESIS). Did you mean ) (RIGHT PARENTHESIS)?

(RUF002)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jmcomic/api.py` around lines 81 - 88, Update the docstrings around the
batch-download API and the additional referenced sections to avoid RUF002
violations from fullwidth punctuation. Replace the newly introduced fullwidth
punctuation with ASCII punctuation while preserving the Chinese documentation
meaning; do not alter the API behavior.

Source: Linters/SAST tools

"""

if not isinstance(jm_album_id, (str, int)):
return download_batch(download_album, jm_album_id, option, downloader, extra=extra)

with new_downloader(option, downloader) as dler:
# 注册 Feature 及来源,由 downloader 在 after_album 钩子中自动执行
dler.add_features(extra, 'download_album')
album = dler.download_album(jm_album_id)

if callback is not None:
callback(album, dler)
if check_exception:
dler.raise_if_has_exception()
return DownloadResult(album, dler)
return _download_and_return(
jm_album_id, option, downloader, callback, check_exception, extra,
'download_album', lambda d: d.download_album(jm_album_id),
)


def download_photo(jm_photo_id,
Expand All @@ -113,21 +104,35 @@ def download_photo(jm_photo_id,
extra=None,
):
"""
下载一个章节(photo),参数同 download_album
下载一个章节(photo)
当jm_photo_id不是str或int时,视为批量下载
:param jm_photo_id: 章节的禁漫车号
:param option: 下载选项
:param downloader: 下载器类
:param callback: 返回值回调函数
:param check_exception: 是否检查异常
:param extra: 下载特性(Feature)
"""
if not isinstance(jm_photo_id, (str, int)):
return download_batch(download_photo, jm_photo_id, option, downloader, extra=extra)

return _download_and_return(
jm_photo_id, option, downloader, callback, check_exception, extra,
'download_photo', lambda d: d.download_photo(jm_photo_id),
)


def _download_and_return(jm_id, option, downloader, callback, check_exception, extra,
feature_source, download_fn):
with new_downloader(option, downloader) as dler:
# 注册 Feature 及来源,由 downloader 在 after_photo 钩子中自动执行
dler.add_features(extra, 'download_photo')
photo = dler.download_photo(jm_photo_id)
dler.add_features(extra, feature_source)
entity = download_fn(dler)

if callback is not None:
callback(photo, dler)
callback(entity, dler)
if check_exception:
dler.raise_if_has_exception()
return DownloadResult(photo, dler)
return DownloadResult(entity, dler)


def new_downloader(option=None, downloader=None) -> JmDownloader:
Expand Down Expand Up @@ -182,11 +187,9 @@ async def download_album_async(jm_album_id,
extra=None,
):
"""
异步下载一个本子(album),包含其所有的章节(photo)。

- 支持批量下载(当 jm_album_id 为可迭代对象时)
- callback 支持同步函数和异步函数
- 返回 (album, downloader) 元组,其中 downloader 的网络和线程池资源已关闭,仅用于读取下载结果
异步下载一个本子(album),包含其所有的章节(photo)
callback 支持同步函数和异步函数
返回的 downloader 已关闭网络和线程池资源,仅用于读取下载结果
"""
if not isinstance(jm_album_id, (str, int)):
return await download_batch_async(download_album_async,
Expand All @@ -196,15 +199,10 @@ async def download_album_async(jm_album_id,
extra=extra
)

async with new_async_downloader(option, downloader) as dler:
dler.add_features(extra, 'download_album')
album = await dler.download_album(jm_album_id)

await _invoke_async_callback(callback, album, dler)
if check_exception:
dler.raise_if_has_exception()

return DownloadResult(album, dler)
return await _download_async_and_return(
jm_album_id, option, downloader, callback, check_exception, extra,
'download_album', lambda d: d.download_album(jm_album_id),
)


async def download_photo_async(jm_photo_id,
Expand All @@ -215,9 +213,9 @@ async def download_photo_async(jm_photo_id,
extra=None,
):
"""
异步下载一个章节(photo)
callback 支持同步函数和异步函数
返回的 downloader 已关闭网络和线程池资源,仅用于读取下载结果
异步下载一个章节(photo)
callback 支持同步函数和异步函数
返回的 downloader 已关闭网络和线程池资源,仅用于读取下载结果
"""
if not isinstance(jm_photo_id, (str, int)):
return await download_batch_async(download_photo_async,
Expand All @@ -227,15 +225,23 @@ async def download_photo_async(jm_photo_id,
extra=extra
)

return await _download_async_and_return(
jm_photo_id, option, downloader, callback, check_exception, extra,
'download_photo', lambda d: d.download_photo(jm_photo_id),
)


async def _download_async_and_return(jm_id, option, downloader, callback, check_exception, extra,
feature_source, download_fn):
async with new_async_downloader(option, downloader) as dler:
dler.add_features(extra, 'download_photo')
photo = await dler.download_photo(jm_photo_id)
dler.add_features(extra, feature_source)
entity = await download_fn(dler)

await _invoke_async_callback(callback, photo, dler)
await _invoke_async_callback(callback, entity, dler)
if check_exception:
dler.raise_if_has_exception()

return DownloadResult(photo, dler)
return DownloadResult(entity, dler)


async def download_batch_async(download_api,
Expand Down
4 changes: 4 additions & 0 deletions src/jmcomic/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ def run(self, option):
from .api import download_album, download_photo
from common import MultiTaskLauncher

if len(self.album_id_list) == 0 and len(self.photo_id_list) == 0:
print('未指定任何 id,请提供 album 或 photo 的 id,例如: jmcomic 123')
return
Comment on lines +105 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exit before constructing the option.

This guard runs after main() loads the option and invokes after_init plugins. Move it immediately after parse_arg() so jmcomic with no IDs cannot trigger option/plugin side effects or failures.

🧰 Tools
🪛 Ruff (0.15.21)

[warning] 106-106: String contains ambiguous (FULLWIDTH COMMA). Did you mean , (COMMA)?

(RUF001)


[warning] 106-106: String contains ambiguous (FULLWIDTH COMMA). Did you mean , (COMMA)?

(RUF001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jmcomic/cli.py` around lines 105 - 107, Move the empty-ID guard in main()
to immediately after parse_arg() returns, before constructing/loading the option
or invoking after_init plugins. Preserve the existing message and early return,
using album_id_list and photo_id_list from the parsed arguments.


if len(self.album_id_list) == 0:
download_photo(self.photo_id_list, option)
elif len(self.photo_id_list) == 0:
Expand Down
5 changes: 3 additions & 2 deletions src/jmcomic/jm_async_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from __future__ import annotations

import asyncio
import os
from concurrent.futures import ThreadPoolExecutor

from .jm_downloader import BaseDownloader
Expand Down Expand Up @@ -50,6 +49,7 @@ def __init__(self,
self._photo_semaphore = asyncio.Semaphore(photo_concurrency)

# 解密线程池(CPU 密集操作卸载)
decode_worker = int(decode_worker if decode_worker is not None else option.download.threading.decode_worker)
self._decode_pool = ThreadPoolExecutor(max_workers=decode_worker, thread_name_prefix='jm-async-decode')

# ======================================================================
Expand Down Expand Up @@ -142,8 +142,9 @@ async def _download_single_image(self, image: JmImageDetail):
对齐 sync JmDownloader.download_by_image_detail 的逻辑。
"""
img_save_path = self.option.decide_image_filepath(image)
from common import file_exists
image.save_path = img_save_path
image.exists = os.path.exists(img_save_path)
image.exists = file_exists(img_save_path)
image.cache = self.option.decide_download_cache(image)

await self.before_image(image, img_save_path)
Expand Down
45 changes: 26 additions & 19 deletions src/jmcomic/jm_client_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,13 +437,7 @@ def favorite_folder(self,

return JmPageTool.parse_html_to_favorite_page(resp.text)

# noinspection PyTypeChecker
def get_username_from_cookies(self) -> str:
# cookies = self.get_meta_data('cookies', None)
# if not cookies:
# ExceptionTool.raises('未登录,无法获取到对应的用户名,请给favorite方法传入username参数')
# 解析cookies,可能需要用到 phpserialize,比较麻烦,暂不实现
pass


def get_jm_html(self, url, require_200=True, **kwargs):
"""
Expand Down Expand Up @@ -698,12 +692,23 @@ def get_scramble_id(self, photo_id, album_id=None):
if album_id is not None and album_id in cache:
return cache[album_id]

scramble_id = self.fetch_scramble_id(photo_id)
cache[photo_id] = scramble_id
if album_id is not None:
cache[album_id] = scramble_id
if JmModuleConfig.SCRAMBLE_CACHE_LOCK is None:
from threading import Lock
JmModuleConfig.SCRAMBLE_CACHE_LOCK = Lock()

return scramble_id
with JmModuleConfig.SCRAMBLE_CACHE_LOCK:
# double-check after acquiring lock
if photo_id in cache:
return cache[photo_id]
if album_id is not None and album_id in cache:
return cache[album_id]

scramble_id = self.fetch_scramble_id(photo_id)
cache[photo_id] = scramble_id
if album_id is not None:
cache[album_id] = scramble_id

return scramble_id

def fetch_detail_entity(self, jmid, clazz: Type[DetailType]) -> DetailType:
"""
Expand Down Expand Up @@ -963,16 +968,16 @@ def raise_if_resp_should_retry(self, resp, is_image):
msg = JmModuleConfig.JM_ERROR_STATUS_CODE.get(code, f'HTTP状态码: {code}')
ExceptionTool.raises_resp(f"禁漫API异常响应, {msg}", resp)

url = resp.request.url
url = getattr(resp, 'url', '') or getattr(getattr(resp, 'request', None), 'url', '')

if self.API_SCRAMBLE in url:
# /chapter_view_template 这个接口不是返回json数据,不做检查
return resp

text = resp.text
for char in text:
# 只检查前1024个字符,避免遍历大型HTML页面
for char in text[:1024]:
if char not in (' ', '\n', '\t'):
# 找到第一个有效字符
ExceptionTool.require_true(
char == '{',
f'请求不是json格式,强制重试!响应文本: [{JmcomicText.limit_text(text, 200)}]'
Expand Down Expand Up @@ -1099,10 +1104,12 @@ def __init__(self, future, after_done_callback):

def result(self):
if not self.done:
result = self.future.result()
self._result = result
self.done = True
self.future = None # help gc
try:
result = self.future.result()
self._result = result
finally:
self.done = True
self.future = None # help gc
self.after_done_callback()
Comment on lines +1107 to 1113

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Run the cache-removal callback in the finally block.

If future.result() raises, Line 1113 is skipped. The failed wrapper stays cached as done=True, so later callers return None instead of retrying or receiving the failure.

Proposed fix
         def result(self):
             if not self.done:
                 try:
                     result = self.future.result()
                     self._result = result
                 finally:
                     self.done = True
                     self.future = None  # help gc
-                self.after_done_callback()
+                    self.after_done_callback()
 
             return self._result
📝 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
try:
result = self.future.result()
self._result = result
finally:
self.done = True
self.future = None # help gc
self.after_done_callback()
try:
result = self.future.result()
self._result = result
finally:
self.done = True
self.future = None # help gc
self.after_done_callback()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jmcomic/jm_client_impl.py` around lines 1107 - 1113, Move the
self.after_done_callback() invocation into the existing finally block
surrounding self.future.result() in the wrapper completion logic. Ensure it runs
after setting self.done and clearing self.future regardless of whether result()
succeeds or raises, so failed cached wrappers are removed consistently.


return self._result
Expand Down
14 changes: 13 additions & 1 deletion src/jmcomic/jm_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ class JmModuleConfig:

# 图片分隔相关
SCRAMBLE_CACHE = {}
SCRAMBLE_CACHE_LOCK = None # threading.Lock 延迟初始化

# 当本子没有作者名字时,顶替作者名字
DEFAULT_AUTHOR = 'default_author'
Expand Down Expand Up @@ -465,6 +466,7 @@ def new_postman(cls, session=False, **kwargs):
'threading': {
'image': 30,
'photo': None,
'decode_worker': None,
},
},
'client': {
Expand Down Expand Up @@ -524,11 +526,14 @@ def option_default_dict(cls) -> dict:
# use system proxy by default
meta_data['proxies'] = cls.DEFAULT_PROXIES

# threading photo
# threading photo & decode_worker
dt = option_dict['download']['threading']
if dt['photo'] is None:
import os
dt['photo'] = os.cpu_count()
if dt['decode_worker'] is None:
import os
dt['decode_worker'] = min(4, os.cpu_count() or 1)

return option_dict

Expand Down Expand Up @@ -619,3 +624,10 @@ def enable_pretty_log():
handler.setFormatter(PrettyFormatter())
jm_logger.addHandler(handler)
jm_logger.setLevel(logging.INFO)


def format_album_url(aid, domain='18comic.vip'):
"""
把album_id变为可访问的URL,方便print打印后用浏览器访问
"""
return f'{JmModuleConfig.PROT}{domain}/album/{aid}/'
4 changes: 2 additions & 2 deletions src/jmcomic/jm_exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import NoReturn

from .jm_entity import *
from .jm_config import format_album_url


class JmcomicException(Exception):
Expand Down Expand Up @@ -156,8 +157,7 @@ def raise_missing(cls,
:param resp: 响应对象
:param jmid: 禁漫本子/章节id
"""
from .jm_toolkit import JmcomicText
url = JmcomicText.format_album_url(jmid)
url = format_album_url(jmid)

req_type = "本子" if "album" in url else "章节"
cls.raises(
Expand Down
Loading