- 新增 operit_runtime.py: 跨平台 Python 运行时 - 实现 Tools.System/Files/Net/UI/Memory/FFmpeg/Workflow/Chat/SoftwareSettings/Tasker - 实现 OkHttp 链式 HTTP 客户端兼容层 - 实现 complete()/exports/console 全局对象 - 实现 OperitScriptLoader 脚本加载器 - 支持 Windows/macOS/Linux 三平台 - 新增 operit_py/ 目录: 47 个 Python 翻译脚本 - 搜索类: duckduckgo, google_search, tavily, zhipu_search, various_search, crossref - 绘图类: openai_draw, qwen_draw, xai_draw, minimax_draw, siliconflow_draw, zhipu_draw, nanobanana_draw - 媒体类: ffmpeg, file_converter, douyin_download, bilibili_tools, jmcomic - 文件类: extended_file_tools, hex_editor, reader, beeimg_image_uploader - 网络类: extended_http_tools, network_test - 自动化类: browser, automatic_ui_base, automatic_ui_subagent, automatic_bilibili_assistant, automatic_xiaohongshu_assistant, automatic_baidu_map_assistant - 系统类: system_tools, super_admin, daily_life, operit_editor - 对话类: ai_chat, extended_chat, qq_intelligent - 工作流类: workflow, vflow_trigger, tasker - 工具类: time, code_runner, quick_start, extended_memory_tools, baidu_map, 12306, pdf_vision_parser - 测试结果: 47/47 语法检查通过, 38 脚本加载成功, 280 个工具注册 - Android 专属功能已降级处理(保留接口,返回不支持提示)
703 lines
28 KiB
Python
703 lines
28 KiB
Python
# METADATA
|
|
# {
|
|
# "name": "jmcomic_downloader",
|
|
# "display_name": {"zh": "JMComic 下载器", "en": "JMComic Downloader"},
|
|
# "description": {"zh": "提供JMComic漫画下载功能,支持搜索、获取信息和下载,包括对新漫画的图片反爬解码。", "en": "JMComic downloader: search comics, fetch details, and download albums. Includes anti-crawling image decoding for newer comics."},
|
|
# "category": "Media",
|
|
# "tools": [
|
|
# {"name": "main", "description": {"zh": "运行一个内置的测试函数,以验证JMComic工具的基本功能(搜索和获取信息)是否正常工作。", "en": "Run a built-in test to verify basic JMComic functionality (search and info retrieval)."}, "parameters": []},
|
|
# {"name": "search_comics", "description": {"zh": "搜索JMComic漫画", "en": "Search JMComic comics."}, "parameters": [
|
|
# {"name": "query", "description": {"zh": "搜索关键词", "en": "Search keyword."}, "type": "string", "required": True},
|
|
# {"name": "page", "description": {"zh": "页码 (默认: 1)", "en": "Page number (default: 1)."}, "type": "number", "required": False},
|
|
# {"name": "order_by", "description": {"zh": "排序方式 (latest, view, picture, like, 默认: view)", "en": "Sort mode: latest/view/picture/like (default: view)."}, "type": "string", "required": False},
|
|
# {"name": "time", "description": {"zh": "时间范围 (today, week, month, all, 默认: all)", "en": "Time range: today/week/month/all (default: all)."}, "type": "string", "required": False}
|
|
# ]},
|
|
# {"name": "get_album_info", "description": {"zh": "获取漫画(本子)的详细信息", "en": "Get detailed information for a comic album."}, "parameters": [
|
|
# {"name": "album_id", "description": {"zh": "漫画ID", "en": "Album ID."}, "type": "string", "required": True}
|
|
# ]},
|
|
# {"name": "download_album", "description": {"zh": "下载指定ID的单本漫画,包含图片解码功能。", "en": "Download a single comic album by ID, including image decoding."}, "parameters": [
|
|
# {"name": "album_id", "description": {"zh": "要下载的漫画ID", "en": "Album ID to download."}, "type": "string", "required": True},
|
|
# {"name": "download_dir", "description": {"zh": "下载目录 (可选, 默认: /sdcard/Download/Operit/plugins/jmcomic_downloader/downloads) (Android-only 路径)", "en": "Download directory (optional)."}, "type": "string", "required": False}
|
|
# ]},
|
|
# {"name": "batch_download_albums", "description": {"zh": "批量下载多本漫画,包含图片解码功能。", "en": "Batch download multiple comic albums, including image decoding."}, "parameters": [
|
|
# {"name": "album_ids", "description": {"zh": "要下载的漫画ID列表,用逗号分隔", "en": "Comma-separated list of album IDs to download."}, "type": "string", "required": True},
|
|
# {"name": "download_dir", "description": {"zh": "下载目录 (可选)", "en": "Download directory (optional)."}, "type": "string", "required": False}
|
|
# ]}
|
|
# ],
|
|
# "enabledByDefault": False
|
|
# }
|
|
|
|
import json
|
|
import re
|
|
import time
|
|
import math
|
|
import random
|
|
import hashlib
|
|
import base64
|
|
import asyncio
|
|
from urllib.parse import quote, unquote
|
|
|
|
Error = Exception
|
|
|
|
# AES 解密依赖(跨平台)
|
|
try:
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
from cryptography.hazmat.primitives import padding as _padding
|
|
_HAS_CRYPTO = True
|
|
except ImportError:
|
|
_HAS_CRYPTO = False
|
|
|
|
# 图片处理依赖(跨平台,替代 Jimp)
|
|
try:
|
|
from PIL import Image
|
|
import io as _io
|
|
_HAS_PIL = True
|
|
except ImportError:
|
|
_HAS_PIL = False
|
|
|
|
DEFAULT_DOWNLOAD_DIR = f"{getPluginConfigDir('jmcomic_downloader')}/downloads"
|
|
TEST_DOWNLOAD_DIR = f"{getPluginConfigDir('jmcomic_downloader')}/test_downloads"
|
|
|
|
__version__ = '2.6.4-py-adapted'
|
|
|
|
|
|
def shuffleDomains(domains):
|
|
shuffled = list(domains)
|
|
for i in range(len(shuffled) - 1, 0, -1):
|
|
j = int(random.random() * (i + 1))
|
|
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
|
|
return shuffled
|
|
|
|
|
|
JmMagicConstants = {
|
|
"APP_TOKEN_SECRET": '18comicAPP',
|
|
"APP_TOKEN_SECRET_2": '18comicAPPContent',
|
|
"APP_DATA_SECRET": '185Hcomic3PAPP7R',
|
|
"APP_VERSION": '1.8.0',
|
|
"SCRAMBLE_220980": 220980,
|
|
"SCRAMBLE_268850": 268850,
|
|
"SCRAMBLE_421926": 421926,
|
|
}
|
|
|
|
JmModuleConfig = {
|
|
"PROT": 'https://',
|
|
"DOMAIN_API_LIST": shuffleDomains([
|
|
'www.cdnmhwscc.vip',
|
|
'www.cdnplaystation6.club',
|
|
'www.cdnplaystation6.org',
|
|
'www.cdnuc.vip',
|
|
'www.cdn-mspjmapiproxy.xyz',
|
|
]),
|
|
"DOMAIN_IMAGE_LIST": shuffleDomains([
|
|
'cdn-msp.jmapiproxy1.cc',
|
|
'cdn-msp.jmapiproxy2.cc',
|
|
'cdn-msp2.jmapiproxy2.cc',
|
|
'cdn-msp3.jmapiproxy2.cc',
|
|
]),
|
|
"APP_HEADERS_TEMPLATE": {
|
|
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
'X-Requested-With': 'com.jiaohua_browser',
|
|
'user-agent': 'Mozilla/5.0 (Linux; Android 9; V1938CT Build/PQ3A.190705.11211812; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36',
|
|
},
|
|
"APP_HEADERS_IMAGE": {
|
|
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
|
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
'X-Requested-With': 'com.jiaohua_browser',
|
|
},
|
|
}
|
|
|
|
|
|
def joinPath(*segments):
|
|
return re.sub(r'/+', '/', '/'.join(segments))
|
|
|
|
|
|
def dirname(filePath):
|
|
lastSlashPos = filePath.rfind('/')
|
|
if lastSlashPos == -1:
|
|
return "."
|
|
if lastSlashPos == 0:
|
|
return "/"
|
|
return filePath[:lastSlashPos]
|
|
|
|
|
|
def basename(filePath):
|
|
return filePath[filePath.rfind('/') + 1:]
|
|
|
|
|
|
async def ensureDirExists(dirPath):
|
|
if not dirPath or dirPath == '/' or dirPath == '.':
|
|
return
|
|
dirExists = await Tools.Files.exists(dirPath)
|
|
if dirExists.exists:
|
|
return
|
|
parentDir = dirname(dirPath)
|
|
await ensureDirExists(parentDir)
|
|
dirStillNotExists = await Tools.Files.exists(dirPath)
|
|
if not dirStillNotExists.exists:
|
|
await Tools.Files.mkdir(dirPath)
|
|
|
|
|
|
async def runTasksWithConcurrency(tasks, limit):
|
|
results = [None] * len(tasks)
|
|
currentIndex = [0]
|
|
|
|
async def runner():
|
|
while currentIndex[0] < len(tasks):
|
|
taskIndex = currentIndex[0]
|
|
currentIndex[0] += 1
|
|
if taskIndex < len(tasks):
|
|
try:
|
|
results[taskIndex] = await tasks[taskIndex]()
|
|
except Exception as e:
|
|
console.error(f"并发任务 {taskIndex} 执行失败: {str(e)}")
|
|
results[taskIndex] = e
|
|
|
|
numRunners = min(limit, len(tasks))
|
|
await asyncio.gather(*[runner() for _ in range(numRunners)])
|
|
return [r for r in results if not isinstance(r, Exception)]
|
|
|
|
|
|
class JmImageTool:
|
|
@staticmethod
|
|
def getNum(scrambleId, photoId, imageName):
|
|
scrambleIdNum = int(str(scrambleId))
|
|
photoIdNum = int(str(photoId))
|
|
|
|
if photoIdNum < scrambleIdNum:
|
|
return 0
|
|
elif photoIdNum < JmMagicConstants["SCRAMBLE_268850"]:
|
|
return 10
|
|
else:
|
|
x = 10 if photoIdNum < JmMagicConstants["SCRAMBLE_421926"] else 8
|
|
imageNameWithoutExt = JmImageTool.getFileNameFromUrl(imageName, True)
|
|
s = f"{photoIdNum}{imageNameWithoutExt}"
|
|
hash_str = hashlib.md5(s.encode('utf-8')).hexdigest()
|
|
lastChar = ord(hash_str[-1])
|
|
num = lastChar % x
|
|
return (num * 2) + 2
|
|
|
|
@staticmethod
|
|
def getFileNameFromUrl(url, withoutExtension=True):
|
|
queryIndex = url.find('?')
|
|
if queryIndex != -1:
|
|
url = url[:queryIndex]
|
|
filename = basename(url)
|
|
if withoutExtension:
|
|
lastDotIndex = filename.rfind('.')
|
|
return filename[:lastDotIndex] if lastDotIndex != -1 else filename
|
|
return filename
|
|
|
|
@staticmethod
|
|
async def decodeAndSave(num, imageBase64, decodedSavePath):
|
|
if num == 0:
|
|
await Tools.Files.writeBinary(decodedSavePath, imageBase64)
|
|
return
|
|
|
|
if not _HAS_PIL:
|
|
console.warn("PIL/Pillow 不可用,跳过图片解码,保存原始图片。")
|
|
await Tools.Files.writeBinary(decodedSavePath, imageBase64)
|
|
return
|
|
|
|
try:
|
|
raw_bytes = base64.b64decode(imageBase64)
|
|
srcImage = Image.open(_io.BytesIO(raw_bytes))
|
|
w, h = srcImage.size
|
|
over = h % num
|
|
resultImage = Image.new(srcImage.mode, (w, h))
|
|
|
|
for i in range(num):
|
|
move = int(h / num)
|
|
ySrc = h - (move * (i + 1)) - over
|
|
yDst = move * i
|
|
|
|
if i == 0:
|
|
move += over
|
|
else:
|
|
yDst += over
|
|
|
|
if ySrc < 0 or move <= 0 or (ySrc + move > h):
|
|
continue
|
|
|
|
strip = srcImage.crop((0, ySrc, w, ySrc + move))
|
|
resultImage.paste(strip, (0, yDst))
|
|
|
|
buf = _io.BytesIO()
|
|
resultImage.save(buf, format='JPEG')
|
|
pureBase64 = base64.b64encode(buf.getvalue()).decode('ascii')
|
|
await Tools.Files.writeBinary(decodedSavePath, pureBase64)
|
|
except Exception as e:
|
|
console.error(f"图片解码失败,将保存原始图片: {str(e)}")
|
|
await Tools.Files.writeBinary(decodedSavePath, imageBase64)
|
|
|
|
|
|
class JmCryptoTool:
|
|
@staticmethod
|
|
def md5hex(key):
|
|
return hashlib.md5(key.encode('utf-8')).hexdigest()
|
|
|
|
@staticmethod
|
|
def tokenAndTokenparam(ts, secret=None):
|
|
if secret is None:
|
|
secret = JmMagicConstants["APP_TOKEN_SECRET"]
|
|
tokenparam = f"{ts},{JmMagicConstants['APP_VERSION']}"
|
|
token = JmCryptoTool.md5hex(f"{ts}{secret}")
|
|
return [token, tokenparam]
|
|
|
|
@staticmethod
|
|
def decodeRespData(data, ts, secret=None):
|
|
if secret is None:
|
|
secret = JmMagicConstants["APP_DATA_SECRET"]
|
|
try:
|
|
keyHex = JmCryptoTool.md5hex(f"{ts}{secret}")
|
|
if not _HAS_CRYPTO:
|
|
raise Error("cryptography 库不可用,无法进行 AES 解密。")
|
|
key = bytes.fromhex(keyHex)
|
|
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
|
decryptor = cipher.decryptor()
|
|
# data 是 base64 编码的密文
|
|
ciphertext = base64.b64decode(data)
|
|
decrypted_padded = decryptor.update(ciphertext) + decryptor.finalize()
|
|
# 移除 PKCS7 填充
|
|
unpadder = _padding.PKCS7(128).unpadder()
|
|
decrypted = unpadder.update(decrypted_padded) + unpadder.finalize()
|
|
decryptedText = decrypted.decode('utf-8')
|
|
if not decryptedText:
|
|
raise Error("AES decryption returned an empty result.")
|
|
return decryptedText
|
|
except Exception as error:
|
|
if isinstance(error, Error):
|
|
raise error
|
|
raise Error(f"AES Decryption failed. Original error: {str(error)}")
|
|
|
|
|
|
class JmApiResp:
|
|
def __init__(self, resp, ts):
|
|
self.resp = resp
|
|
self.ts = ts
|
|
|
|
@property
|
|
def isSuccess(self):
|
|
return self.resp.isSuccessful()
|
|
|
|
@property
|
|
def json(self):
|
|
try:
|
|
return json.loads(self.resp.content)
|
|
except Exception as error:
|
|
raise Error(f"JSON解析失败: {str(error)}")
|
|
|
|
@property
|
|
def isSuccessful(self):
|
|
return self.isSuccess and self.json.get("code") == 200
|
|
|
|
@property
|
|
def encodedData(self):
|
|
return self.json.get("data")
|
|
|
|
@property
|
|
def decodedData(self):
|
|
return JmCryptoTool.decodeRespData(self.encodedData, self.ts)
|
|
|
|
@property
|
|
def resData(self):
|
|
if not self.isSuccessful:
|
|
raise Error(f"API请求失败: code={self.json.get('code')}")
|
|
decoded = self.decodedData
|
|
try:
|
|
if not isinstance(decoded, str) or not decoded:
|
|
raise Error(f"Cannot parse non-string or empty value. Type: {type(decoded).__name__}")
|
|
return json.loads(decoded)
|
|
except Exception as error:
|
|
if isinstance(error, Error):
|
|
raise error
|
|
preview = str(decoded or 'N/A')[:80]
|
|
raise Error(f"Failed to parse decrypted response. Error: {str(error)}.")
|
|
|
|
@property
|
|
def modelData(self):
|
|
return self.resData
|
|
|
|
|
|
class DirRuleImpl:
|
|
def __init__(self, baseDir):
|
|
self.baseDir = baseDir
|
|
|
|
def decideImageSaveDir(self, album, photo):
|
|
return joinPath(self.baseDir, self.sanitize(album["title"]))
|
|
|
|
def decideAlbumRootDir(self, album):
|
|
return joinPath(self.baseDir, self.sanitize(album["title"]))
|
|
|
|
def sanitize(self, name):
|
|
return re.sub(r'[\\?%*:|"<>]', '_', name)
|
|
|
|
|
|
class JmOptionImpl:
|
|
def __init__(self, baseDir=None):
|
|
if baseDir is None:
|
|
baseDir = DEFAULT_DOWNLOAD_DIR
|
|
self.dirRule = DirRuleImpl(baseDir)
|
|
|
|
@staticmethod
|
|
def default(baseDir=None):
|
|
return JmOptionImpl(baseDir)
|
|
|
|
def buildJmClient(self):
|
|
return JmApiClientImpl()
|
|
|
|
|
|
class JmApiClientImpl:
|
|
def __init__(self):
|
|
self.domainList = JmModuleConfig["DOMAIN_API_LIST"]
|
|
self.retryTimes = 3
|
|
self.client = OkHttp.newClient()
|
|
self.API_ALBUM = '/album'
|
|
self.API_CHAPTER = '/chapter'
|
|
self.API_SEARCH = '/search'
|
|
self.API_CATEGORIES_FILTER = '/categories/filter'
|
|
|
|
async def getAlbumDetail(self, albumId):
|
|
resp = await self.reqApi(f"{self.API_ALBUM}?id={albumId}")
|
|
data = resp.resData
|
|
if not data or not data.get("name"):
|
|
raise Error(f"本子 {albumId} 不存在或数据无效")
|
|
return self.parseAlbumData(albumId, data)
|
|
|
|
async def getPhotoDetail(self, photoId):
|
|
resp = await self.reqApi(f"{self.API_CHAPTER}?id={photoId}")
|
|
data = resp.resData
|
|
if not data or not data.get("name"):
|
|
raise Error(f"章节 {photoId} 不存在或数据无效")
|
|
return self.parsePhotoData(photoId, data)
|
|
|
|
async def searchComics(self, params):
|
|
query = params.get("query")
|
|
page = params.get("page", 1)
|
|
order_by = params.get("order_by", "view")
|
|
time_range = params.get("time", "all")
|
|
|
|
orderMap = {"latest": "mr", "view": "mv", "picture": "mp", "like": "tf"}
|
|
timeMap = {"today": "t", "week": "w", "month": "m", "all": "a"}
|
|
apiParams = {
|
|
"search_query": query,
|
|
"page": page,
|
|
"o": orderMap.get(order_by.lower(), orderMap["view"]),
|
|
"t": timeMap.get(time_range.lower(), timeMap["all"]),
|
|
}
|
|
resp = await self.reqApi(f"{self.API_SEARCH}?{self.toUrlSearchParams(apiParams)}")
|
|
data = resp.resData
|
|
results = [
|
|
{"id": str(item.get("id") or item.get("album_id")), "title": item.get("name") or item.get("title")}
|
|
for item in (data.get("content") or [])
|
|
]
|
|
return {
|
|
"search_params": params,
|
|
"results": results,
|
|
"total_results": len(results),
|
|
}
|
|
|
|
async def reqApi(self, url, method="GET", data=None):
|
|
ts = int(time.time())
|
|
for i in range(len(self.domainList)):
|
|
domain = self.domainList[i]
|
|
for retry in range(self.retryTimes):
|
|
try:
|
|
fullUrl = f"{JmModuleConfig['PROT']}{domain}{url}"
|
|
token, tokenparam = JmCryptoTool.tokenAndTokenparam(ts)
|
|
headers = dict(JmModuleConfig["APP_HEADERS_TEMPLATE"])
|
|
headers["token"] = token
|
|
headers["tokenparam"] = tokenparam
|
|
|
|
requestBuilder = self.client.newRequest().url(fullUrl).headers(headers)
|
|
if method == "POST":
|
|
requestBuilder.method("POST").jsonBody(data)
|
|
|
|
resp = await requestBuilder.build().execute()
|
|
|
|
if resp.isSuccessful():
|
|
return JmApiResp(resp, ts)
|
|
except Exception as error:
|
|
console.log(f"[API] 请求失败: {str(error)} 域名: {domain}")
|
|
if retry == self.retryTimes - 1 and i == len(self.domainList) - 1:
|
|
raise Error(f"所有域名和重试都失败: {str(error)}")
|
|
raise Error("请求失败")
|
|
|
|
async def downloadImage(self, imageUrl, savePath, scrambleId, photoId):
|
|
try:
|
|
response = await self.client.newRequest().url(imageUrl).headers(JmModuleConfig["APP_HEADERS_IMAGE"]).build().execute()
|
|
if not response.isSuccessful():
|
|
raise Error(f"HTTP error! status: {response.statusCode}")
|
|
imageBase64 = response.bodyAsBase64()
|
|
dir_ = dirname(savePath)
|
|
await ensureDirExists(dir_)
|
|
|
|
imageName = JmImageTool.getFileNameFromUrl(imageUrl, False)
|
|
num = JmImageTool.getNum(scrambleId, photoId, imageName)
|
|
await JmImageTool.decodeAndSave(num, imageBase64, savePath)
|
|
return True
|
|
except Exception as error:
|
|
console.error(f"[图片] 下载失败: {imageUrl}, 错误: {str(error)}")
|
|
return False
|
|
|
|
def toUrlSearchParams(self, obj):
|
|
return '&'.join([f"{quote(str(k))}={quote(str(obj[k]))}" for k in obj])
|
|
|
|
def parseAlbumData(self, albumId, data):
|
|
episodeList = data.get("series") if data.get("series") and len(data.get("series")) > 0 else [{"id": albumId, "title": data.get("name")}]
|
|
return {
|
|
"id": albumId,
|
|
"title": data.get("name") or f"本子 {albumId}",
|
|
"author": (data.get("author") and data["author"][0]) or "未知作者",
|
|
"episodeList": episodeList,
|
|
"scrambleId": data.get("scramble_id") or JmMagicConstants["SCRAMBLE_220980"],
|
|
"length": len(episodeList),
|
|
}
|
|
|
|
def parsePhotoData(self, photoId, data):
|
|
return {
|
|
"id": photoId,
|
|
"title": data.get("name") or f"章节 {photoId}",
|
|
"pageArr": data.get("images") or [],
|
|
"albumId": data.get("album_id") or photoId,
|
|
"scrambleId": data.get("scramble_id") or JmMagicConstants["SCRAMBLE_220980"],
|
|
"length": len(data.get("images") or []),
|
|
}
|
|
|
|
|
|
class JmDownloaderImpl:
|
|
def __init__(self, option):
|
|
self.option = option
|
|
self.client = option.buildJmClient()
|
|
|
|
async def downloadAlbum(self, albumId):
|
|
album = await self.client.getAlbumDetail(albumId)
|
|
await self.downloadByAlbumDetail(album)
|
|
return album
|
|
|
|
async def downloadByAlbumDetail(self, album):
|
|
albumDir = self.option.dirRule.decideAlbumRootDir(album)
|
|
await ensureDirExists(albumDir)
|
|
console.log(f"[专辑: {album['title']}] 发现 {album['episodeList'].__len__()} 个章节, 开始下载...")
|
|
|
|
chapterConcurrency = 5
|
|
|
|
def make_task(episode, i):
|
|
async def _task():
|
|
console.log(f" [章节 {i + 1}/{len(album['episodeList'])}] 开始下载: {episode['title']} ({episode['id']})")
|
|
try:
|
|
photo = await self.client.getPhotoDetail(episode["id"])
|
|
await self.downloadPhotoImages(photo, albumDir, album["id"])
|
|
console.log(f" [章节 {i + 1}/{len(album['episodeList'])}] 下载完成: {episode['title']}")
|
|
except Exception as e:
|
|
console.error(f" [章节 {i + 1}/{len(album['episodeList'])}] 下载失败: {episode['title']}, 错误: {str(e)}")
|
|
return _task
|
|
|
|
tasks = [make_task(episode, i) for i, episode in enumerate(album["episodeList"])]
|
|
await runTasksWithConcurrency(tasks, chapterConcurrency)
|
|
|
|
async def downloadPhotoImages(self, photo, albumDir, albumId):
|
|
if not photo["pageArr"] or len(photo["pageArr"]) == 0:
|
|
return
|
|
|
|
console.log(f" [图片集: {photo['title']}] 发现 {len(photo['pageArr'])} 张图片, 开始下载...")
|
|
concurrencyLimit = 10
|
|
|
|
def make_task(imageName, i):
|
|
async def _task():
|
|
finalFileName = f"{str(i + 1).zfill(5)}.jpg"
|
|
filePath = joinPath(albumDir, finalFileName)
|
|
fileExists = await Tools.Files.exists(filePath)
|
|
if fileExists.exists:
|
|
return
|
|
imageUrl = self.buildImageUrl(photo, imageName)
|
|
try:
|
|
await self.client.downloadImage(imageUrl, filePath, photo["scrambleId"], photo["id"])
|
|
except Exception as e:
|
|
console.error(f" [图片下载失败] {finalFileName} from {photo['title']}: {str(e)}")
|
|
return _task
|
|
|
|
tasks = [make_task(imageName, i) for i, imageName in enumerate(photo["pageArr"])]
|
|
await runTasksWithConcurrency(tasks, concurrencyLimit)
|
|
console.log(f" [图片集: {photo['title']}] 所有图片下载任务已处理。")
|
|
|
|
def buildImageUrl(self, photo, imageName):
|
|
domain = JmModuleConfig["DOMAIN_IMAGE_LIST"][int(random.random() * len(JmModuleConfig["DOMAIN_IMAGE_LIST"]))]
|
|
return f"{JmModuleConfig['PROT']}{domain}/media/photos/{photo['albumId']}/{imageName}"
|
|
|
|
|
|
class SimpleJMDownloader:
|
|
def __init__(self, downloadDir=None):
|
|
if downloadDir is None:
|
|
downloadDir = DEFAULT_DOWNLOAD_DIR
|
|
self.option = JmOptionImpl.default(downloadDir)
|
|
self.downloader = JmDownloaderImpl(self.option)
|
|
self.client = self.option.buildJmClient()
|
|
console.log(f"JM下载器初始化成功, 下载目录: {self.option.dirRule.baseDir}")
|
|
|
|
async def searchComics(self, params):
|
|
console.log(f"搜索漫画: {params.get('query')}")
|
|
return await self.client.searchComics(params)
|
|
|
|
async def getAlbumInfo(self, albumId):
|
|
album = await self.client.getAlbumDetail(albumId)
|
|
return {
|
|
"id": album["id"],
|
|
"title": album["title"],
|
|
"author": album["author"],
|
|
"chapterCount": album["length"],
|
|
"success": True,
|
|
}
|
|
|
|
async def downloadAlbum(self, albumId):
|
|
console.log(f"获取本子信息: {albumId}")
|
|
try:
|
|
info = await self.getAlbumInfo(albumId)
|
|
if not info["success"]:
|
|
return {"success": False, "albumId": albumId, "error": "获取信息失败"}
|
|
console.log(f"开始下载本子: {info['title']}")
|
|
await self.downloader.downloadAlbum(albumId)
|
|
downloadedFiles = await self._checkDownloadedFiles(info["title"])
|
|
return {
|
|
"success": True,
|
|
"albumId": albumId,
|
|
"title": info["title"],
|
|
"downloadedFiles": downloadedFiles,
|
|
}
|
|
except Exception as error:
|
|
return {"success": False, "albumId": albumId, "error": str(error)}
|
|
|
|
async def batchDownload(self, albumIds):
|
|
results = []
|
|
console.log(f"开始批量下载 {len(albumIds)} 个本子")
|
|
concurrencyLimit = 3
|
|
|
|
def make_task(albumId, i):
|
|
async def _task():
|
|
console.log(f"\n[{i + 1}/{len(albumIds)}] 开始处理本子: {albumId}")
|
|
result = await self.downloadAlbum(albumId)
|
|
if result["success"]:
|
|
console.log(f"[{i + 1}/{len(albumIds)}] 下载成功: {result.get('title')}")
|
|
else:
|
|
console.log(f"[{i + 1}/{len(albumIds)}] 下载失败: {albumId}, {result.get('error') or 'Unknown error'}")
|
|
return result
|
|
return _task
|
|
|
|
tasks = [make_task(albumId, i) for i, albumId in enumerate(albumIds)]
|
|
return await runTasksWithConcurrency(tasks, concurrencyLimit)
|
|
|
|
async def _checkDownloadedFiles(self, title):
|
|
albumDir = self.option.dirRule.decideAlbumRootDir({"title": title})
|
|
dirExists = await Tools.Files.exists(albumDir)
|
|
if dirExists.exists:
|
|
listResult = await Tools.Files.list(albumDir)
|
|
entries = getattr(listResult, "entries", None) or []
|
|
files = [e.name for e in entries]
|
|
return {
|
|
"directory": albumDir,
|
|
"fileCount": len(files),
|
|
"files": files[:10],
|
|
}
|
|
return {"directory": None, "fileCount": 0, "files": []}
|
|
|
|
|
|
async def main():
|
|
console.log("开始执行JMComic工具功能测试...")
|
|
downloader = SimpleJMDownloader(TEST_DOWNLOAD_DIR)
|
|
testQuery = "原神"
|
|
|
|
console.log(f'1. 测试搜索功能,关键词: "{testQuery}"')
|
|
searchResult = await downloader.searchComics({"query": testQuery})
|
|
|
|
if not searchResult or not searchResult.get("results") or len(searchResult["results"]) == 0:
|
|
raise Error(f'搜索测试失败: 未能找到关于 "{testQuery}" 的任何结果。')
|
|
|
|
console.log(f"搜索成功, 找到 {searchResult['total_results']} 个结果。")
|
|
firstAlbum = searchResult["results"][0]
|
|
console.log(f"2. 测试获取作品信息功能, 作品ID: {firstAlbum['id']} ({firstAlbum['title']})")
|
|
|
|
albumInfo = await downloader.getAlbumInfo(firstAlbum["id"])
|
|
|
|
if not albumInfo or not albumInfo["success"]:
|
|
raise Error(f"获取作品信息失败, ID: {firstAlbum['id']}")
|
|
|
|
console.log(f"作品信息获取成功:")
|
|
console.log(f" - 标题: {albumInfo['title']}")
|
|
console.log(f" - 作者: {albumInfo['author']}")
|
|
console.log(f" - 章节数: {albumInfo['chapterCount']}")
|
|
|
|
console.log(f"3. 测试下载功能, 作品ID: {firstAlbum['id']} ({firstAlbum['title']})")
|
|
downloadResult = await downloader.downloadAlbum(firstAlbum["id"])
|
|
|
|
if not downloadResult or not downloadResult["success"]:
|
|
raise Error(f"下载作品失败, ID: {firstAlbum['id']}")
|
|
|
|
console.log(f"下载成功:")
|
|
dl = downloadResult.get("downloadedFiles") or {}
|
|
console.log(f" - 保存目录: {dl.get('directory')}")
|
|
console.log(f" - 文件数量: {dl.get('fileCount')}")
|
|
|
|
summary = f"JMComic工具测试完成。成功搜索、获取信息并下载了作品《{albumInfo['title']}》。"
|
|
console.log(f"\n{summary}")
|
|
return summary
|
|
|
|
|
|
async def search_comics(params):
|
|
downloader = SimpleJMDownloader()
|
|
return await downloader.searchComics(params)
|
|
|
|
|
|
async def get_album_info(params):
|
|
downloader = SimpleJMDownloader()
|
|
return await downloader.getAlbumInfo(params.get("album_id"))
|
|
|
|
|
|
async def download_album(params):
|
|
downloader = SimpleJMDownloader(params.get("download_dir"))
|
|
return await downloader.downloadAlbum(params.get("album_id"))
|
|
|
|
|
|
async def batch_download_albums(params):
|
|
albumIds = [id_.strip() for id_ in params.get("album_ids", "").split(",") if id_.strip()]
|
|
if len(albumIds) == 0:
|
|
raise Error("album_ids不能为空")
|
|
downloader = SimpleJMDownloader(params.get("download_dir"))
|
|
return await downloader.batchDownload(albumIds)
|
|
|
|
|
|
async def jmcomic_wrap(func, params, successMessage, failMessage):
|
|
try:
|
|
console.log(f"开始执行: {func.__name__}")
|
|
result = await func(params)
|
|
complete({"success": True, "message": successMessage, "data": result})
|
|
except Exception as error:
|
|
import traceback
|
|
console.error(f"{func.__name__} 执行失败: {str(error)}")
|
|
complete({"success": False, "message": f"{failMessage}: {str(error)}", "error_stack": traceback.format_exc()})
|
|
|
|
|
|
async def _main_wrapper(params):
|
|
await jmcomic_wrap(main, params, '功能测试完成', '功能测试失败')
|
|
|
|
|
|
async def _search_comics_wrapper(params):
|
|
await jmcomic_wrap(search_comics, params, '搜索完成', '搜索失败')
|
|
|
|
|
|
async def _get_album_info_wrapper(params):
|
|
await jmcomic_wrap(get_album_info, params, '信息获取完成', '信息获取失败')
|
|
|
|
|
|
async def _download_album_wrapper(params):
|
|
await jmcomic_wrap(download_album, params, '下载完成', '下载失败')
|
|
|
|
|
|
async def _batch_download_albums_wrapper(params):
|
|
await jmcomic_wrap(batch_download_albums, params, '批量下载完成', '批量下载失败')
|
|
|
|
|
|
exports.main = _main_wrapper
|
|
exports.search_comics = _search_comics_wrapper
|
|
exports.get_album_info = _get_album_info_wrapper
|
|
exports.download_album = _download_album_wrapper
|
|
exports.batch_download_albums = _batch_download_albums_wrapper
|