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
2 changes: 1 addition & 1 deletion .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
no_proxy: '*'
run: python usage/benchmark_async_vs_sync.py

- name: Publish Report to Step Summary
- name: Publish Conclusion-first Report
if: always()
run: |
if [ -f PERFORMANCE_REPORT.md ]; then
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,4 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
.agent
gemini.md
AGENTS.md
24 changes: 3 additions & 21 deletions assets/docs/sources/tutorial/12_domain_strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,10 @@ op = create_option('option.yml')
cl = op.new_jm_client()
```

### 2.2 通过 GitHub 兜底获取域名
> `get_html_domain_all_via_github` 已废弃。原 GitHub 仓库不再提供禁漫域名,兼容入口目前会转发到
> `get_html_domain_all`,并将在未来版本移除。

如果连禁漫的发布页本身都被墙了无法访问,可以请求禁漫官方放在 GitHub 的仓库来解析最新域名。

```python
from jmcomic import *

# 该请求发往 github.com,在大多数常规网络中均能保持连通
domains = JmModuleConfig.get_html_domain_all_via_github()

op = JmOption.default()
# 可以结合重试机制,允许失败时轮换多次
op.client.retry_times = 3

# 应用域名池新建包含该域名的 Client (记得指定 impl='html')
# 将新建的 client 赋值回 op,使其在后续的下载中生效
op.client = op.new_jm_client(domain_list=domains, impl='html')

download_album('438696', op)
```

### 2.3 获取单个跳转域名
### 2.2 获取单个跳转域名

除了获取全部域名,也可以通过访问永久跳转页获取单个重定向用的新域名。

Expand Down
37 changes: 31 additions & 6 deletions assets/docs/sources/tutorial/14_async_usage.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# 异步使用指南

> 关于`async``sync`版本的性能对比可以查看 → [actions-benchmark](https://github.com/hect0x7/JMComic-Crawler-Python/actions/workflows/benchmark.yml)
>
> 简单来说,下载场景`async`**快10%↑**,纯查询无下载场景`async`**快30%↑**
本章节介绍项目中提供的异步接口。章节结构与 `0_common_usage.md` 基本对应,可作为从同步迁移到异步代码的对照参考。

---
Expand All @@ -14,7 +18,10 @@ import jmcomic

async def main():
# 异步下载单个本子
await jmcomic.download_album_async('438696')
album, downloader = await jmcomic.download_album_async('438696')

# 返回的 downloader 已释放网络连接和线程池,只用于读取下载结果
print(downloader.download_failed_image)

# 异步下载单章节
await jmcomic.download_photo_async('438696')
Expand Down Expand Up @@ -63,6 +70,16 @@ async with JmOption.default().new_jm_async_client() as cl:

无论哪种写法都只会初始化一次,你不需要自己去调用任何初始化代码,直接用就行。

如果不使用 `async with`,使用完成后需要显式关闭客户端:

```python
cl = JmOption.default().new_jm_async_client()
try:
album = await cl.get_album_detail(123)
finally:
await cl.close()
```

---

### 并发请求示例
Expand Down Expand Up @@ -169,25 +186,33 @@ asyncio.run(main())

## 7. 异步分类 / 排行榜

分类和排行榜本质上都是过滤请求,可以使用 `categories_filter` 异步方法获取分页。
分类和排行榜本质上都是过滤请求,可以使用 `categories_filter` 获取单页,或使用
`categories_filter_gen` 异步生成器自动翻页。

```python
import asyncio
from jmcomic import JmOption
from jmcomic import JmMagicConstants, JmOption

async def main():
async with JmOption.default().new_jm_async_client() as cl:
# 获取全部时间、全部分类下,按观看数排序的第一页本子
page = await cl.categories_filter(
page=1,
time='a', # JmMagicConstants.TIME_ALL
category='all', # JmMagicConstants.CATEGORY_ALL
order_by='mv', # JmMagicConstants.ORDER_BY_VIEW
time=JmMagicConstants.TIME_ALL,
category=JmMagicConstants.CATEGORY_ALL,
order_by=JmMagicConstants.ORDER_BY_VIEW,
)

for aid, atitle in page:
print(aid, atitle)

async for page in cl.categories_filter_gen(
time=JmMagicConstants.TIME_ALL,
category=JmMagicConstants.CATEGORY_ALL,
order_by=JmMagicConstants.ORDER_BY_VIEW,
):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
print(page.page)

asyncio.run(main())
```

Expand Down
24 changes: 3 additions & 21 deletions assets/docs/sources/tutorial/8_pick_domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,8 @@ disable_jm_log()


def get_all_domain():
template = 'https://jmcmomic.github.io/go/{}.html'
url_ls = [
template.format(i)
for i in range(300, 309)
]
domain_set: Set[str] = set()

def fetch_domain(url):
from curl_cffi import requests as postman
text = postman.get(url, allow_redirects=False, **meta_data).text
for domain in JmcomicText.analyse_jm_pub_html(text):
if domain.startswith('jm365.work'):
continue
domain_set.add(domain)

multi_thread_launcher(
iter_objs=url_ls,
apply_each_obj_func=fetch_domain,
)
return domain_set
postman = JmModuleConfig.new_postman(**meta_data)
return set(JmModuleConfig.get_html_domain_all(postman))


domain_set = get_all_domain()
Expand Down Expand Up @@ -78,4 +60,4 @@ for domain, status in domain_status_dict.items():
jmcomic1.me: ok
jmcomic.me: ok
18comic-palworld.club: ok
```
```
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.7.1'
__version__ = '2.7.2'

from .api import *
from .jm_plugin import *
Expand Down
29 changes: 24 additions & 5 deletions src/jmcomic/api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import asyncio
import inspect

from .jm_downloader import *

__DOWNLOAD_API_RET = DownloadResult


async def _invoke_async_callback(callback, entity, downloader):
if callback is None:
return None

is_async_callback = (
inspect.iscoroutinefunction(callback)
or inspect.iscoroutinefunction(getattr(callback, '__call__', None))
)
if is_async_callback:
return await callback(entity, downloader)

result = await asyncio.to_thread(callback, entity, downloader)
if inspect.isawaitable(result):
return await result
return result


def download_batch(download_api,
jm_id_iter: Union[Iterable, Generator],
option=None,
Expand Down Expand Up @@ -167,7 +185,8 @@ async def download_album_async(jm_album_id,
异步下载一个本子(album),包含其所有的章节(photo)。

- 支持批量下载(当 jm_album_id 为可迭代对象时)
- 返回 (album, downloader) 元组
- callback 支持同步函数和异步函数
- 返回 (album, downloader) 元组,其中 downloader 的网络和线程池资源已关闭,仅用于读取下载结果
"""
if not isinstance(jm_album_id, (str, int)):
return await download_batch_async(download_album_async,
Expand All @@ -181,8 +200,7 @@ async def download_album_async(jm_album_id,
dler.add_features(extra, 'download_album')
album = await dler.download_album(jm_album_id)

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

Expand All @@ -198,6 +216,8 @@ async def download_photo_async(jm_photo_id,
):
"""
异步下载一个章节(photo)。
callback 支持同步函数和异步函数。
返回的 downloader 已关闭网络和线程池资源,仅用于读取下载结果。
"""
if not isinstance(jm_photo_id, (str, int)):
return await download_batch_async(download_photo_async,
Expand All @@ -211,8 +231,7 @@ async def download_photo_async(jm_photo_id,
dler.add_features(extra, 'download_photo')
photo = await dler.download_photo(jm_photo_id)

if callback is not None:
callback(photo, dler)
await _invoke_async_callback(callback, photo, dler)
if check_exception:
dler.raise_if_has_exception()

Expand Down
97 changes: 67 additions & 30 deletions src/jmcomic/jm_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import asyncio
import json
from typing import Sequence
from urllib.parse import urlencode

from curl_cffi.requests import AsyncSession
Expand Down Expand Up @@ -51,12 +52,15 @@ class AsyncJmApiClient(AsyncJmcomicClient):
_SENTINEL = object()

# 类级别初始化标记与锁,防止并发更新域名
_has_setup_domain_and_cookies = False
_has_setup_domain = False
_setup_lock = asyncio.Lock()

def __init__(self, option: JmOption, max_clients=None, **kwargs):
def __init__(self, option: JmOption, max_clients=None, domain_list=None, **kwargs):
if 'domain_retry_strategy' in kwargs:
raise TypeError('Async client does not support domain_retry_strategy')

self.option = option
self._domain_list = self._resolve_domain_list()
self._domain_list = self._resolve_domain_list(domain_list)
retry_times = option.client.get('retry_times')
self._retry_times = retry_times if retry_times is not None else 5
self._timeout = option.client.get('timeout', 30) or 30
Expand Down Expand Up @@ -84,22 +88,41 @@ def __init__(self, option: JmOption, max_clients=None, **kwargs):
# 域名管理
# ======================================================================

def _resolve_domain_list(self) -> list[str]:
@staticmethod
def _normalize_domain_list(domain_list) -> list[str]:
if isinstance(domain_list, str):
return [domain.strip() for domain in domain_list.splitlines() if domain.strip()]

if isinstance(domain_list, Sequence):
result = []
for domain in domain_list:
if not isinstance(domain, str):
raise TypeError(f'domain must be str, got {type(domain)}')
domain = domain.strip()
if domain:
result.append(domain)
return result

raise TypeError('domain_list must be str, Sequence[str], or None')

def _resolve_domain_list(self, domain_list=None) -> list[str]:
"""解析并返回可用的 API 域名列表"""
updated = JmModuleConfig.DOMAIN_API_UPDATED_LIST
if updated:
return list(updated)
if domain_list is not None:
resolved = self._normalize_domain_list(domain_list)
if resolved:
return resolved

domain = self.option.client.domain
if hasattr(domain, 'get'):
domain_list = domain.get('api', [])
elif isinstance(domain, list):
domain_list = domain
elif isinstance(domain, str):
domain_list = [d.strip() for d in domain.split('\n') if d.strip()]
else:
domain_list = []
if domain_list:
return domain_list
domain_list = domain

if domain_list is not None:
resolved = self._normalize_domain_list(domain_list)
if resolved:
return resolved

return list(JmModuleConfig.DOMAIN_API_LIST)

def get_domain_list(self) -> list[str]:
Expand All @@ -108,6 +131,20 @@ def get_domain_list(self) -> list[str]:
def set_domain_list(self, domain_list: list[str]):
self._domain_list = domain_list

def update_old_api_domain(self, new_server_list: list[str]):
if not new_server_list:
return

if sorted(self._domain_list) != sorted(JmModuleConfig.DOMAIN_API_LIST):
return

old_server_list = self._domain_list
self._domain_list = list(new_server_list)
jm_log(
'api.update_domain.replace',
f'替换异步客户端的内置API域名:(old){old_server_list} ---→ (new){self._domain_list}',
)

# ======================================================================
# 缓存
# ======================================================================
Expand Down Expand Up @@ -659,8 +696,7 @@ async def auto_update_domain(self):
return

if JmModuleConfig.DOMAIN_API_UPDATED_LIST is not None:
if JmModuleConfig.DOMAIN_API_UPDATED_LIST:
self._domain_list = list(JmModuleConfig.DOMAIN_API_UPDATED_LIST)
self.update_old_api_domain(JmModuleConfig.DOMAIN_API_UPDATED_LIST)
return

# 尝试从域名服务器获取最新域名
Expand All @@ -683,8 +719,7 @@ async def auto_update_domain(self):
jm_log('api.update_domain.success',
f'获取到最新的API域名: {new_server_list}')
JmModuleConfig.DOMAIN_API_UPDATED_LIST = new_server_list
if sorted(self._domain_list) == sorted(JmModuleConfig.DOMAIN_API_LIST):
self._domain_list = new_server_list
self.update_old_api_domain(new_server_list)
return
except Exception as e:
jm_log('api.update_domain.error', f'通过[{url}]自动更新API域名失败: {e}')
Expand All @@ -708,20 +743,22 @@ async def setup(self):

cls = self.__class__
async with cls._setup_lock:
if not cls._has_setup_domain_and_cookies:
if self._has_setup:
return

if not cls._has_setup_domain:
await self.auto_update_domain()
if JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES:
await self.ensure_have_cookies()
cls._has_setup_domain_and_cookies = True
cls._has_setup_domain = True
else:
# 即使已经初始化过域名和 cookie,也需要将已保存的全局 DOMAIN 和 COOKIES 赋值到当前 client
if JmModuleConfig.DOMAIN_API_UPDATED_LIST:
self._domain_list = list(JmModuleConfig.DOMAIN_API_UPDATED_LIST)
if JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES and JmModuleConfig.APP_COOKIES:
# noinspection PyUnresolvedReferences
self._session.cookies.update(JmModuleConfig.APP_COOKIES)

self._has_setup = True
# 即使已经初始化过域名,也需要将已保存的全局 DOMAIN 赋值到当前 client
if JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN:
self.update_old_api_domain(JmModuleConfig.DOMAIN_API_UPDATED_LIST)

# Cookie 属于 session 状态,每个 client 都需要独立确认。
if JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES:
await self.ensure_have_cookies()

self._has_setup = True

# ======================================================================
# 生命周期
Expand Down
Loading
Loading