feat: 将 47 个 Operit TypeScript 脚本全面翻译为 Python
- 新增 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 专属功能已降级处理(保留接口,返回不支持提示)
This commit is contained in:
1 parent
6fa759665d
commit
dc77dfd304
48 files changed
+22273
No files matched your search
@@ -0,0 +1,702 @@
|
||||
# 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
|
||||
Reference in new issue
Block a user