UNPKG

@wahaha216/koishi-plugin-jmcomic

Version:

下载JM本子,无需python。支持pdf、zip加密。

1,560 lines (1,533 loc) 56.4 kB
var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; var __export = (target, all) => { for (var name2 in all) __defProp(target, name2, { get: all[name2], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/locales/zh-CN.yml var require_zh_CN = __commonJS({ "src/locales/zh-CN.yml"(exports2, module2) { module2.exports = { commands: { jm: { description: "下载JM漫画,无需python!", examples: "jm album 本子数字ID\njm album info 本子数字ID\njm photo 本子章节数字ID", album: { examples: "jm album 数字ID", messages: { addedToQueue: "已将 {id} 添加到处理队列,请稍候。", queueFirst: "已将 {id} 添加到处理队列,即将开始处理", queuePosition: "已将 {id} 添加到处理队列\n前面还有 {ahead} 个任务等待或正在处理", queueProcessing: "已将 {id} 添加到处理队列\n当前任务状态:{status}", notExistError: "找不到该ID对应的本子", mysqlError: "已尝试所有备用地址,但是JM坏掉了" }, info: { examples: "jm album info 本子数字ID", messages: { notExistError: "找不到该ID对应的本子", mysqlError: "已尝试所有备用地址,但是JM坏掉了" } } }, photo: { examples: "jm photo 本子章节数字ID", messages: { addedToQueue: "已将 {id} 添加到处理队列,请稍候。", queueFirst: "已将 {id} 添加到处理队列,即将开始处理", queuePosition: "已将 {id} 添加到处理队列\n前面还有 {ahead} 个任务等待或正在处理", queueProcessing: "已将 {id} 添加到处理队列\n当前任务状态:{status}", notExistError: "找不到该ID对应的章节", mysqlError: "已尝试所有备用地址,但是JM坏掉了" } }, queue: { examples: "jm queue", messages: { emptyQueue: "当前没有正在处理或者等待处理的任务", msgFormat: "ID: {id}, 类型: {type}, 状态: {status}\n", task: { pending: "等待中...", processing: "处理中...", failed: "发生未知错误", completed: "已完成", unknown: "未定义状态" }, type: { album: "本子", photo: "章节" } } }, search: { example: "jm search <关键词>", messages: { emptyKeywordError: "请输入搜索关键词", id: "JMID", name: "名称", author: "作者", category: "分类", description: "描述", pagination: "共 {total} 条, 当前第 {page} 页, 每页 {pageSize} 条" } } } }, _config: [{ $desc: "基础设置", sendMethod: "发送方式", fileMethod: "文件获取方式<br>`buffer`: 读取成buffer后发送给bot实现端<br>`file`: 以`file:///` 本地路径形式发送,如docker环境,请在bot实现端同时映射/koishi目录", password: "密码,留空则不加密", fileName: "文件名定义<br>`{{name}}`:标题<br>`{{id}}`:章节或者本子ID<br>`{{index}}`:多章节本子自动填充`1` 、 `2`" }, { level: "压缩级别,0~9,0为仅存储" }, { $desc: "限制相关设置", retryCount: "重试次数限制", concurrentDownloadLimit: "同时下载数量限制", concurrentDecodeLimit: "同时解密数量限制", concurrentQueueLimit: "同时处理数量限制" }, { $desc: "缓存设置", cache: "缓存文件" }, { autoDelete: "自动删除缓存,**依赖cron服务**" }, { cron: "5位cron表达式", deleteInStart: "启动时检测", keepDays: "缓存保留时间(天)" }, { $desc: "开发者选项", debug: "调试模式,输出更多信息" }] }; } }); // src/locales/en-US.yml var require_en_US = __commonJS({ "src/locales/en-US.yml"(exports2, module2) { module2.exports = { commands: { jm: { description: "Download JM comics without python!", examples: "jm album albumID\njm album info albumID\njm photo photoID", album: { examples: "jm album albumID", messages: { addedToQueue: "Album {id} has been added to the processing queue. Please wait.", queueFirst: "Added {id} to the processing queue, starting shortly.", queuePosition: "Added {id} to the processing queue.\nThere are {ahead} tasks ahead or currently processing.", queueProcessing: "Added {id} to the processing queue.\nCurrent task status: {status}", notExistError: "albumID not found", mysqlError: "All alternate addresses have been tried. But no response." }, info: { examples: "jm album info albumID", messages: { notExistError: "albumID not found", mysqlError: "All alternate addresses have been tried. But no response." } } }, photo: { examples: "jm photo photoID", messages: { addedToQueue: "Photo {id} has been added to the processing queue. Please wait.", queueFirst: "Added {id} to the processing queue, starting shortly.", queuePosition: "Added {id} to the processing queue.\nThere are {ahead} tasks ahead or currently processing.", queueProcessing: "Added {id} to the processing queue.\nCurrent task status: {status}", notExistError: "photoID not found", mysqlError: "All alternate addresses have been tried. But no response." } }, queue: { examples: "jm queue", messages: { emptyQueue: "There are currently no tasks being processed or waiting.", msgFormat: "ID: {id}, type: {type}, status: {status}\n", task: { Pending: "Pending...", Processing: "Processing...", failed: "Failed (Unknown Error)", completed: "Completed", unknown: "Unknown Status" }, type: { album: "Album", photo: "Photo" } } }, search: { example: "jm search <keyword>", messages: { emptyKeywordError: "Please enter search keywords", id: "JMID", name: "name", author: "author", category: "category", description: "description", pagination: "Total {total}, current {page} page, each page {pageSize}" } } } }, _config: [{ $desc: "Basic settings", sendMethod: "Send method", fileMethod: "File acquisition method<br>`buffer`: Read as buffer and send it to the bot implementation.<br>`file`: Send it in the local path of `file:///`. For example, if in the docker environment, please map the `/koishi` directory at the bot implementation.", retryCount: "Retry limit", password: "Password, leave blank without encryption", fileName: "File name definition<br>`{{name}}`: Title<br>`{{id}}`: Chapter or Book ID<br>`{{index}}`: Multi-chapter book auto-filling `_1` `_2`" }, { $desc: "Limit settings", retryCount: "Retry limit", concurrentDownloadLimit: "Concurrent Download Limit", concurrentDecodeLimit: "Concurrent Decode Limit", concurrentQueueLimit: "Concurrent Queue Limit" }, { level: "Compression level, 0~9, 0 is only stores" }, { $desc: "Cache settings", cache: "Cache files" }, { autoDelete: "Automatically delete cache, **need cron services**" }, { cron: "5-bit cron expression", deleteInStart: "Detection on startup", keepDays: "Cache retention time (days)" }, { $desc: "Developer Options", debug: "Debug mode, output more information" }] }; } }); // src/index.ts var src_exports = {}; __export(src_exports, { Config: () => Config, apply: () => apply, inject: () => inject, name: () => name }); module.exports = __toCommonJS(src_exports); var import_koishi2 = require("koishi"); var import_path3 = require("path"); // src/utils/Utils.ts var import_fs = require("fs"); var import_promises = require("fs/promises"); // src/utils/Regexp.ts var JM_SCRAMBLE_ID = /var scramble_id = (\d+);/; // src/utils/Const.ts var JM_CLIENT_URL_LIST = [ "www.cdnmhwscc.vip", "www.cdnblackmyth.club", "www.cdnmhws.cc", "www.cdnuc.vip" ]; var JM_IMAGE_URL_LIST = [ "cdn-msp.jmapiproxy1.cc", "cdn-msp.jmapiproxy2.cc", "cdn-msp2.jmapiproxy2.cc", "cdn-msp3.jmapiproxy2.cc", "cdn-msp.jmapinodeudzn.net", "cdn-msp3.jmapinodeudzn.net" ]; // src/utils/Utils.ts var import_path = require("path"); // src/error/mysql.error.ts var MySqlError = class extends Error { static { __name(this, "MySqlError"); } constructor(message) { super(message); this.name = "MySqlError"; } }; // src/error/overRetry.error.ts var OverRetryError = class extends Error { static { __name(this, "OverRetryError"); } constructor(message) { super(message); this.name = "OverRetryError"; } }; // src/error/emptybuffer.error.ts var EmptyBufferError = class extends Error { static { __name(this, "EmptyBufferError"); } constructor(message) { super(message); this.name = "EmptyBufferError"; } }; // src/utils/Utils.ts function fileExistsAsync(path) { try { (0, import_fs.accessSync)(path, import_fs.constants.F_OK); return true; } catch (err) { return false; } } __name(fileExistsAsync, "fileExistsAsync"); function fileSizeAsync(path) { try { const stats = (0, import_fs.statSync)(path); return stats.size; } catch (err) { return 0; } } __name(fileSizeAsync, "fileSizeAsync"); function sanitizeFileName(fileName) { const forbiddenAndShellSpecialChars = /[<>:"/\\|?*()[\]{}!#$&;'`~]/g; let sanitizedFileName = fileName.replace(forbiddenAndShellSpecialChars, "_"); sanitizedFileName = sanitizedFileName.replace(/\s+/g, "_"); sanitizedFileName = sanitizedFileName.replace(/_+/g, "_"); sanitizedFileName = sanitizedFileName.replace(/^[._]+|[._]+$/g, ""); let byteLength = Buffer.byteLength(sanitizedFileName, "utf-8"); while (byteLength > 200) { const length = sanitizedFileName.length; sanitizedFileName = sanitizedFileName.substring(0, length - 1); byteLength = Buffer.byteLength(sanitizedFileName, "utf8"); } if (sanitizedFileName.trim() === "") { return "untitled_document"; } return sanitizedFileName; } __name(sanitizeFileName, "sanitizeFileName"); async function limitPromiseAll(promises, limit) { const results = new Array(promises.length); const executing = []; for (let i = 0; i < promises.length; i++) { const promiseFn = promises[i]; while (executing.length >= limit) { await Promise.race(executing); } const p = promiseFn().then((res) => { results[i] = res; }).finally(() => { const index = executing.indexOf(p); if (index !== -1) { executing.splice(index, 1); } }); executing.push(p); } await Promise.all(executing); return results; } __name(limitPromiseAll, "limitPromiseAll"); async function requestWithRetry(url, method, config = {}, http, pluginsConfig, logger, retryIndex = 0) { try { const res = await http(method, url, config); if (typeof res.data === "string" && res.data.includes("Could not connect to mysql")) { throw new MySqlError(); } return res.data; } catch (error) { if (error instanceof MySqlError) { throw new MySqlError(); } else if (retryIndex < pluginsConfig.retryCount) { logger.info( `${url} 请求失败,正在重试... ${retryIndex + 1}/${pluginsConfig.retryCount}` ); return await requestWithRetry( url, method, config, http, pluginsConfig, logger, retryIndex + 1 ); } else { throw new OverRetryError(`请求失败,超过最大重试次数: ${url}`); } } } __name(requestWithRetry, "requestWithRetry"); async function requestWithUrlSwitch(url, method, config = {}, http, pluginsConfig, logger, type = "CLIENT", urlIndex = 0) { const list = type === "CLIENT" ? JM_CLIENT_URL_LIST : JM_IMAGE_URL_LIST; const urlCount = list.length; const url_bak = url; if (url.startsWith("/")) { url = `https://${list[urlIndex]}${url}`; } try { if (urlIndex < urlCount) { const res = await requestWithRetry( url, method, config, http, pluginsConfig, logger ); if (res instanceof ArrayBuffer && res.byteLength === 0) { throw new EmptyBufferError(); } return res; } else { throw new Error("所有域名请求失败"); } } catch (error) { const isMysqlError = error instanceof MySqlError; const isEmptyBuffer = error instanceof EmptyBufferError; const isOverRetryError = error instanceof OverRetryError; if (isMysqlError || isEmptyBuffer || isOverRetryError) { logger.info(`请求失败,尝试切换域名... ${urlIndex + 1}/${urlCount}`); return await requestWithUrlSwitch( url_bak, method, config, http, pluginsConfig, logger, type, urlIndex + 1 ); } throw new Error(error); } } __name(requestWithUrlSwitch, "requestWithUrlSwitch"); function getFileInfo(filePath) { const normalizedPath = (0, import_path.normalize)(filePath); const parsePath = (0, import_path.parse)(normalizedPath); const ext = parsePath.ext.slice(1); const fileName = parsePath.name; const dir = parsePath.dir; return { fileName, ext, dir }; } __name(getFileInfo, "getFileInfo"); async function deleteFewDaysAgoFolders(path, days) { const dirEntries = await (0, import_promises.readdir)(path, { withFileTypes: true }); const subfolderNames = dirEntries.filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name); for (const folder of subfolderNames) { const s = await (0, import_promises.stat)(`${path}/${folder}`); const creationTime = s.birthtime || s.ctime; const now = /* @__PURE__ */ new Date(); const diffTime = Math.abs(now.getTime() - creationTime.getTime()); const diffDays = Math.floor(diffTime / (1e3 * 3600 * 24)); if (diffDays >= days) { (0, import_promises.rm)(`${path}/${folder}`, { recursive: true }); } } } __name(deleteFewDaysAgoFolders, "deleteFewDaysAgoFolders"); function formatFileName(originName, name2, id, index) { return originName.replaceAll("{{name}}", name2).replaceAll("{{id}}", id).replaceAll("{{index}}", index ? `${index}` : "").trim(); } __name(formatFileName, "formatFileName"); // src/entity/JMAppClient.ts var import_crypto3 = require("crypto"); var import_form_data = __toESM(require("form-data")); // src/abstract/JMClientAbstract.ts var import_crypto = __toESM(require("crypto")); var JMClientAbstract = class { static { __name(this, "JMClientAbstract"); } root; constructor(root) { this.root = root; } setRoot(root) { this.root = root; } getRoot() { return this.root; } /** * 使用MD5将字符串加密成十六进制 * @param key 要计算MD5的字符串 * @returns 十六进制MD5 */ md5Hex(key, inputEncoding = "utf-8") { return import_crypto.default.createHash("md5").update(key, inputEncoding).digest("hex"); } }; // src/abstract/JMPhotoAbstract.ts var import_crypto2 = __toESM(require("crypto")); var JMPhotoAbstract = class _JMPhotoAbstract { static { __name(this, "JMPhotoAbstract"); } static SCRAMBLE_268850 = 268850; static SCRAMBLE_421926 = 421926; id; images; image_names; splitNumbers; setId(id) { this.id = id; } getId() { return this.id; } setImages(images) { this.images = images; } getImages() { return this.images; } setImageNames(imageNames) { this.image_names = imageNames; } getImageNames() { return this.image_names; } setSplitNumbers(splitNumbers) { this.splitNumbers = splitNumbers; } getSplitNumbers() { return this.splitNumbers; } /** * 生成图片分割数 */ generateSplitNumbers(scramble_id) { const splitNumbers = this.image_names.map((name2) => { if (this.id < scramble_id) { return 0; } else if (this.id < _JMPhotoAbstract.SCRAMBLE_268850) { return 10; } else { const x = this.id < _JMPhotoAbstract.SCRAMBLE_421926 ? 10 : 8; const s = `${this.id}${name2}`; const md5 = import_crypto2.default.createHash("md5"); const hash = md5.update(s).digest("hex"); const lastChar = hash[hash.length - 1]; let num = lastChar.charCodeAt(0) % x; num = num * 2 + 2; return num; } }); this.splitNumbers = splitNumbers; } }; // src/entity/JMAppPhoto.ts var JMAppPhoto = class _JMAppPhoto extends JMPhotoAbstract { static { __name(this, "JMAppPhoto"); } series; tags; name; addtime; series_id; is_favorite; liked; constructor(json) { super(); for (const key in json) { if (Object.prototype.hasOwnProperty.call(this, key)) { this[key] = json[key]; } } } setSeries(series) { this.series = series; } getSeries() { return this.series; } setTags(tags) { this.tags = tags; } getTags() { return this.tags; } setName(name2) { this.name = name2; } getName() { return this.name; } setAddtime(addtime) { this.addtime = addtime; } getAddtime() { return this.addtime; } setSeriesId(seriesId) { this.series_id = seriesId; } getSeriesId() { return this.series_id; } setIsFavorite(isFavorite) { this.is_favorite = isFavorite; } getIsFavorite() { return this.is_favorite; } setLiked(liked) { this.liked = liked; } getLiked() { return this.liked; } /** * 从JSON数据返回JMPhoto实体类 * @param json JMPhoto JSON数据 * @returns */ static fromJson(json) { return new _JMAppPhoto(json); } }; // src/abstract/JMAlbumAbstract.ts var JMAlbumAbstract = class { static { __name(this, "JMAlbumAbstract"); } /** * 本子ID */ id; /** * 名称 */ name; /** * 章节列表 */ series = []; /** * 作品 */ works; /** * 登场人物 */ actors; /** * 标签 */ tags; /** * 作者 */ authors; /** * 描述 */ description; /** * 点赞数 */ likes; /** * 观看次数 */ total_views; /** * 章节信息 */ photos; setId(id) { this.id = id; } getId() { return this.id; } setName(name2) { this.name = name2; } getName() { return this.name; } setSeries(series) { this.series = series; } getSeries() { return this.series; } setWorks(works) { this.works = works; } getWorks() { return this.works; } setActors(actors) { this.actors = actors; } getActors() { return this.actors; } setTags(tags) { this.tags = tags; } getTags() { return this.tags; } setAuthors(authors) { this.authors = authors; } getAuthors() { return this.authors; } setDescription(description) { this.description = description; } getDescription() { return this.description; } setLikes(likes) { this.likes = likes; } getLikes() { return this.likes; } setTotalViews(totalViews) { this.total_views = totalViews; } getTotalViews() { return this.total_views; } setPhotos(photos) { this.photos = photos; } getPhotos() { return this.photos; } }; // src/entity/JMAppAlbum.ts var JMAppAlbum = class _JMAppAlbum extends JMAlbumAbstract { static { __name(this, "JMAppAlbum"); } images; addtime; series_id; comment_total; related_list; liked; is_favorite; is_aids; price; purchased; constructor(json) { super(); for (const key in json) { if (Object.prototype.hasOwnProperty.call(this, key)) { this[key] = json[key]; } } } setImages(images) { this.images = images; } getImages() { return this.images; } setAddtime(addtime) { this.addtime = addtime; } getAddtime() { return this.addtime; } setSeriesId(seriesId) { this.series_id = seriesId; } getSeriesId() { return this.series_id; } setCommentTotal(commentTotal) { this.comment_total = commentTotal; } getCommentTotal() { return this.comment_total; } setRelatedList(relatedList) { this.related_list = relatedList; } getRelatedList() { return this.related_list; } setLiked(liked) { this.liked = liked; } getLiked() { return this.liked; } setIsFavorite(isFavorite) { this.is_favorite = isFavorite; } getIsFavorite() { return this.is_favorite; } setIsAids(isAids) { this.is_aids = isAids; } getIsAids() { return this.is_aids; } setPrice(price) { this.price = price; } getPrice() { return this.price; } setPurchased(purchased) { this.purchased = purchased; } getPurchased() { return this.purchased; } getPhotos() { return super.getPhotos(); } static fromJson(json) { return new _JMAppAlbum(json); } }; // src/entity/JMAppClient.ts var import_promises2 = require("fs/promises"); // src/utils/Image.ts var import_fs2 = __toESM(require("fs")); var import_sharp = __toESM(require("sharp")); var import_archiver = __toESM(require("archiver")); var import_archiver_zip_encrypted = __toESM(require("archiver-zip-encrypted")); async function decodeImage(imageBuffer, num, path) { if (num > 0) { const metadata = await (0, import_sharp.default)(Buffer.from(imageBuffer)).metadata(); const height = metadata.height || 0; const width = metadata.width || 0; if (height < num) { await (0, import_sharp.default)(Buffer.from(imageBuffer)).toFile(path); return; } const over = height % num; const move = Math.floor(height / num); let decodedImageInstance = (0, import_sharp.default)({ create: { width, height, channels: 3, background: { r: 0, g: 0, b: 0 } } }).webp(); const croppeds = []; for (let i = 0; i < num; i++) { let currentMove = move; let ySrc = height - move * (i + 1) - over; let yDst = move * i; if (i === 0) { currentMove += over; } else { yDst += over; } const cropped = await (0, import_sharp.default)(imageBuffer).extract({ left: 0, top: ySrc, width, height: currentMove }).toBuffer(); croppeds.push({ top: yDst, cropped }); } decodedImageInstance = decodedImageInstance.composite( croppeds.map((c) => ({ input: c.cropped, top: c.top, left: 0 })) ); await decodedImageInstance.toFile(path); } else { await (0, import_sharp.default)(Buffer.from(imageBuffer)).toFile(path); } } __name(decodeImage, "decodeImage"); async function saveImage(imageBuffer, path) { if (!imageBuffer.byteLength) return; const buffer = Buffer.from(imageBuffer); await (0, import_sharp.default)(buffer).toFile(path); } __name(saveImage, "saveImage"); async function archiverImage(directorys, outputPath, password, level = 9) { if (!import_archiver.default.isRegisteredFormat("zip-encrypted")) { import_archiver.default.registerFormat("zip-encrypted", import_archiver_zip_encrypted.default); } const output = import_fs2.default.createWriteStream(outputPath); const options = { zlib: { level } // 压缩级别 }; if (password) { options["encryptionMethod"] = "aes256"; options["password"] = password; } const archive = import_archiver.default.create(password ? "zip-encrypted" : "zip", options); archive.pipe(output); directorys.forEach(({ directory, destpath }) => { archive.directory(directory, destpath); }); await archive.finalize(); } __name(archiverImage, "archiverImage"); // src/entity/JMAppClient.ts var import_path2 = require("path"); var import_sharp2 = __toESM(require("sharp")); var import_muhammara = require("muhammara"); // src/error/albumNotExist.error.ts var AlbumNotExistError = class extends Error { static { __name(this, "AlbumNotExistError"); } constructor(message) { super(message); this.name = "AlbumNotExistError"; } }; // src/error/photoNotExist.error.ts var PhotoNotExistError = class extends Error { static { __name(this, "PhotoNotExistError"); } constructor(message) { super(message); this.name = "PhotoNotExistError"; } }; // src/entity/JMAppClient.ts var JMAppClient = class _JMAppClient extends JMClientAbstract { static { __name(this, "JMAppClient"); } static APP_VERSION = "1.7.9"; static APP_TOKEN_SECRET = "18comicAPP"; static APP_TOKEN_SECRET_2 = "18comicAPPContent"; static APP_DATA_SECRET = "185Hcomic3PAPP7R"; /** * koishi 配置项 */ config; /** * koishi 日志 */ logger; /** * koishi http */ http; constructor(root, http, config, logger) { super(root); this.config = config; this.logger = logger; this.http = http; } /** * 登录,未完成 * @param username 用户名 * @param password 密码 * @returns 用户信息 */ async login(username, password) { const timestamp = this.getTimeStamp(); const { token, tokenparam } = this.getTokenAndTokenParam(timestamp); const headers = { "Accept-Encoding": "gzip, deflate", "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", token, tokenparam }; const formData = new import_form_data.default(); formData.append("username", username); formData.append("password", password); const res = await this.http.post( "https://www.cdnmhws.cc/login", formData, { headers, responseType: "json" } ); return this.decodeBase64(res.data, timestamp); } async search(keyword) { if (this.config.debug) this.logger.info(`搜索本子: ${keyword}`); const timestamp = this.getTimeStamp(); const { token, tokenparam } = this.getTokenAndTokenParam(timestamp); const searchRes = await requestWithUrlSwitch( "/search", "POST", { params: { search_query: keyword }, headers: { token, tokenparam }, responseType: "json" }, this.http, this.config, this.logger ); return this.decodeBase64(searchRes.data, timestamp); } async getAlbumById(id) { if (this.config.debug) this.logger.info(`获取本子(${id})信息`); const timestamp = this.getTimeStamp(); const { token, tokenparam } = this.getTokenAndTokenParam(timestamp); const res = await requestWithUrlSwitch( "/album", "POST", { params: { id }, headers: { token, tokenparam }, responseType: "json" }, this.http, this.config, this.logger ); const album_json = this.decodeBase64(res.data, timestamp); if (!album_json.name) throw new AlbumNotExistError(); const album = JMAppAlbum.fromJson(album_json); const series = album.getSeries(); const photos = []; if (series.length) { for (const s of series) { const photo = await this.getPhotoById(s.id); photos.push(photo); } } else { const photo = await this.getPhotoById(id); photos.push(photo); } album.setPhotos(photos); return album; } async getPhotoById(id) { if (this.config.debug) this.logger.info(`获取章节(${id})信息`); const timestamp = this.getTimeStamp(); const { token, tokenparam } = this.getTokenAndTokenParam(timestamp); const res = await requestWithUrlSwitch( "/chapter", "POST", { params: { id }, headers: { token, tokenparam }, responseType: "json" }, this.http, this.config, this.logger ); const photo_json = this.decodeBase64(res.data, timestamp); if (!photo_json.name) throw new PhotoNotExistError(); const photo = JMAppPhoto.fromJson(photo_json); const images = photo.getImages(); const image_ids = images.map((image) => image.split(".")[0]); photo.setImageNames(image_ids); return photo; } async downloadByAlbum(album) { const id = album.getId(); const path = `${this.root}/album/${id}`; await (0, import_promises2.mkdir)(path, { recursive: true }); const photos = album.getPhotos(); for (const photo of photos) { await this.downloadByPhoto(photo, "album", id, photos.length === 1); } } async downloadByPhoto(photo, type = "photo", albumId = "", single = false) { const images = photo.getImages(); const id = photo.getId(); let path = `${this.root}/${type}/${id}/origin`; if (this.config.debug) { this.logger.info(`开始下载: ${id}`); if (type === "album") { this.logger.info(`单章节: ${single ? "是" : "否"}`); this.logger.info(`子章节: ${albumId ? "是" : "否"}`); this.logger.info(`本子ID: ${albumId}`); } } if (type === "album") { if (single) { path = `${this.root}/${type}/${albumId}/origin`; } else { path = `${this.root}/${type}/${albumId}/origin/${id}`; } } if (this.config.debug) this.logger.info(`存储目录:${path}`); await (0, import_promises2.mkdir)(path, { recursive: true }); await limitPromiseAll( images.filter((image) => { const imagePath = `${path}/${image}`; const fileExists = fileExistsAsync(imagePath); const fileSize = fileSizeAsync(imagePath); return !fileExists || !fileSize; }).map((image) => async () => { const url = `/media/photos/${id}/${image}`; if (this.config.debug) this.logger.info(`下载图片:${url}`); const res = await requestWithUrlSwitch( url, "GET", { responseType: "arraybuffer" }, this.http, this.config, this.logger, "IMAGE" ); await saveImage(res, `${path}/${image}`); }), this.config.concurrentDownloadLimit ); if (this.config.debug) this.logger.info(`${id} 下载完成,开始解密图片`); await this.decodeByPhoto(photo, type, albumId, single); } async decodeByPhoto(photo, type = "photo", albumId = "", single = false) { const images = photo.getImages(); const id = photo.getId(); const scramble_id = await this.requestScrambleId(id); photo.generateSplitNumbers(scramble_id); const splitNumbers = photo.getSplitNumbers(); let path = `${this.root}/${type}/${id}/origin`; let decodedPath = `${this.root}/${type}/${id}/decoded`; if (type === "album" && !single) { path = `${this.root}/${type}/${albumId}/origin/${id}`; decodedPath = `${this.root}/${type}/${albumId}/decoded/${id}`; } await (0, import_promises2.mkdir)(path, { recursive: true }); await (0, import_promises2.mkdir)(decodedPath, { recursive: true }); await limitPromiseAll( images.filter((image) => { const imagePath = `${decodedPath}/${image}`; const fileExists = fileExistsAsync(imagePath); const fileSize = fileSizeAsync(imagePath); return !fileExists || !fileSize; }).map((image, index) => async () => { const imagePath = `${path}/${image}`; if (this.config.debug) this.logger.info(`解密图片:${imagePath}`); const decodedImagePath = `${decodedPath}/${image}`; const imageBuffer = await (0, import_promises2.readFile)(imagePath); await decodeImage(imageBuffer, splitNumbers[index], decodedImagePath); }), this.config.concurrentDecodeLimit ); this.logger.info(`${id} 解密完成`); } async albumToPdf(album, password) { const id = album.getId(); const photos = album.getPhotos(); if (photos.length === 1) { const photo = photos[0]; return await this.photoToPdf( photo, `${photo.getName()}`, "album", id, true, password ); } else { let paths = []; for (const [i, photo] of photos.entries()) { const path = await this.photoToPdf( photo, `${photo.getName()}_${i + 1}`, "album", id, false, password ); paths.push(path); } return paths; } } async photoToPdf(photo, pdfName, type = "photo", albumId = "", single = false, password) { const images = photo.getImages(); const id = photo.getId(); pdfName = sanitizeFileName(pdfName); if (this.config.debug) this.logger.info(`开始生成PDF ${pdfName}.pdf`); let path = (0, import_path2.join)(this.root, type, `${id}`); if (type === "album") { path = (0, import_path2.join)(this.root, type, `${albumId}`); } const pdfPath = (0, import_path2.join)(path, `${pdfName}.pdf`); let pdfDoc; try { pdfDoc = new import_muhammara.Recipe("new", pdfPath, { version: 1.6 }); } catch (error) { throw new Error(error); } const tempPath = (0, import_path2.join)(path, `temp_${id}`); await (0, import_promises2.mkdir)(tempPath); for (const image of images) { let imagePath = (0, import_path2.join)(path, "decoded", image); if (type === "album" && !single) { imagePath = (0, import_path2.join)(path, "decoded", `${id}`, image); } const buffer = await (0, import_promises2.readFile)(imagePath); const ext = (0, import_path2.extname)(imagePath); const jpgName = image.replace(ext, ".jpg"); const jpgPath = (0, import_path2.join)(tempPath, jpgName); const sharpInstance = (0, import_sharp2.default)(buffer); await sharpInstance.jpeg().toFile(jpgPath); const metadata = await sharpInstance.metadata(); pdfDoc.createPage(metadata.width, metadata.height).image(jpgPath, 0, 0).endPage(); } if (password) { pdfDoc.encrypt({ userPassword: password, ownerPassword: password, userProtectionFlag: 4 }); } try { pdfDoc.endPDF(() => { if (this.config.debug) this.logger.info(`PDF ${pdfName}.pdf 生成完成`); }); } catch (error) { throw new Error(error); } finally { await (0, import_promises2.rm)(tempPath, { recursive: true }); } return pdfPath; } async albumToZip(album, password, level = 6) { const id = album.getId(); const series = album.getSeries(); const zipName = sanitizeFileName(album.getName()); if (this.config.debug) this.logger.info(`开始生成ZIP ${zipName}.zip`); const path = (0, import_path2.join)(this.root, "album", `${id}`); const directorys = []; if (series.length > 1) { for (const s of series) { directorys.push({ directory: (0, import_path2.join)(path, "decoded", s.id), destpath: `第${s.sort}章` }); } } else { directorys.push({ directory: (0, import_path2.join)(path, "decoded"), destpath: false }); } const zipPath = (0, import_path2.join)(path, `${zipName}.zip`); await archiverImage(directorys, zipPath, password, level); if (this.config.debug) this.logger.info(`ZIP ${zipName}.zip 生成完成`); return zipPath; } async photoToZip(photo, zipName, password, level = 6) { const id = photo.getId(); zipName = sanitizeFileName(zipName); if (this.config.debug) this.logger.info(`开始生成ZIP ${zipName}.zip`); const path = (0, import_path2.join)(this.root, "photo", `${id}`); const zipPath = (0, import_path2.join)(path, `${zipName}.zip`); await archiverImage( [{ directory: (0, import_path2.join)(path, "decoded"), destpath: false }], zipPath, password, level ); if (this.config.debug) this.logger.info(`ZIP ${zipName}.zip 生成完成`); return zipPath; } /** * 获取时间戳 * @returns 时间戳 */ getTimeStamp() { const date = /* @__PURE__ */ new Date(); return date.getTime(); } /** * 获取Scramble ID * @param id JM本子ID */ async requestScrambleId(id) { const timestamp = this.getTimeStamp(); const { token, tokenparam } = this.getTokenAndTokenParam( timestamp, _JMAppClient.APP_TOKEN_SECRET_2 ); const html = await requestWithUrlSwitch( "/chapter_view_template", "POST", { params: { id }, headers: { token, tokenparam }, responseType: "text" }, this.http, this.config, this.logger ); return parseInt(html.match(JM_SCRAMBLE_ID)[1]); } /** * 获取请求时所需的token和tokenparam * @param timestamp 时间戳 * @param version APP版本 * @param secret 密钥 * @returns 请求时所需的token和tokenparam */ getTokenAndTokenParam(timestamp, secret = _JMAppClient.APP_TOKEN_SECRET, version = _JMAppClient.APP_VERSION) { const key = `${timestamp}${secret}`; const token = (0, import_crypto3.createHash)("md5").update(key).digest("hex"); const tokenparam = `${timestamp},${version}`; return { token, tokenparam }; } /** * 解密加密字符串 * @param timestamp 请求时传递的时间戳 * @param base64 待解密的字符串 * @param secret * @returns 解密结果,JSON */ decodeBase64(base64, timestamp, secret = _JMAppClient.APP_DATA_SECRET) { const dataB64 = Buffer.from(base64, "base64"); const md5 = this.md5Hex(`${timestamp}${secret}`); const key = Buffer.from(md5); const decipher = (0, import_crypto3.createDecipheriv)("aes-256-ecb", key, null); let dataAES = decipher.update(dataB64); let decrypted = Buffer.concat([dataAES, decipher.final()]); const decodedString = decrypted.toString("utf-8"); return JSON.parse(decodedString); } }; // src/processors/jmProcessor.ts var import_koishi = require("koishi"); var import_promises3 = require("node:fs/promises"); var createJmProcessor = /* @__PURE__ */ __name((processorConfig, http, config, logger) => { const { root, sendMethod, password, level, fileName, fileMethod, cache, debug } = processorConfig; return async (payload) => { const { id, session, messageId, scope } = payload; try { const jmClient = new JMAppClient(root, http, config, logger); let filePath; let name2; let ext; let dir = ""; if (payload.type === "album") { const album = await jmClient.getAlbumById(id); await jmClient.downloadByAlbum(album); if (sendMethod === "zip") { filePath = await jmClient.albumToZip(album, password, level); } else { filePath = await jmClient.albumToPdf(album, password); } if (typeof filePath === "string") { const fileInfo = getFileInfo(filePath); name2 = formatFileName(fileName, fileInfo.fileName, id); ext = fileInfo.ext; dir = fileInfo.dir; } else { const firstPath = filePath[0]; const fileInfo = getFileInfo(firstPath); name2 = formatFileName(fileName, fileInfo.fileName, id); ext = fileInfo.ext; dir = fileInfo.dir; } } else if (payload.type === "photo") { const photo = await jmClient.getPhotoById(id); await jmClient.downloadByPhoto(photo); const photoName = photo.getName(); if (sendMethod === "zip") { filePath = await jmClient.photoToZip( photo, photoName, password, level ); } else { filePath = await jmClient.photoToPdf(photo, photoName); } const fileInfo = getFileInfo(filePath); name2 = formatFileName(fileName, fileInfo.fileName, id); ext = fileInfo.ext; dir = fileInfo.dir; } else { throw new Error(`未知任务类型: ${payload.type}`); } if (typeof filePath === "string") { const { fileName: baseFileName, ext: fileExt, dir: fileDir } = getFileInfo(filePath); const finalName = formatFileName(fileName, baseFileName, id); if (debug) logger.info(`文件名:${finalName}.${fileExt}`); if (fileMethod === "buffer") { const buffer = await (0, import_promises3.readFile)(filePath); await session.send([ import_koishi.h.file(buffer, fileExt, { title: `${finalName}.${fileExt}` }) ]); } else { await session.send([ import_koishi.h.file(`file:///${filePath}`, { title: `${finalName}.${fileExt}` }) ]); } if (!cache) (0, import_promises3.rm)(fileDir, { recursive: true }); } else { let currentFileDir = ""; for (const [index, p] of filePath.entries()) { const { fileName: baseFileName, ext: fileExt, dir: fileDir } = getFileInfo(p); const finalName = formatFileName( fileName, baseFileName, id, index + 1 ); if (debug) logger.info(`文件名:${finalName}.${fileExt}`); if (fileMethod === "buffer") { const buffer = await (0, import_promises3.readFile)(p); await session.send([ import_koishi.h.file(buffer, fileExt, { title: `${finalName}.${fileExt}` }) ]); } else { await session.send([ import_koishi.h.file(`file:///${p}`, { title: `${finalName}.${fileExt}` }) ]); } currentFileDir = fileDir; } if (!cache) { if (currentFileDir) { (0, import_promises3.rm)(currentFileDir, { recursive: true }); } else { logger.warn(`无法删除目录,fileDir 未定义。任务 ID: ${id}`); } } } } catch (error) { if (error instanceof AlbumNotExistError || error instanceof PhotoNotExistError) { await session.send([ import_koishi.h.quote(messageId), import_koishi.h.text(session.text(`${scope}.notExistError`)) // 假设 .notExistError 可以通用 ]); } else if (error instanceof MySqlError) { await session.send([ import_koishi.h.quote(messageId), import_koishi.h.text(session.text(`${scope}.mysqlError`)) ]); } else { await session.send([ import_koishi.h.quote(messageId), import_koishi.h.text(`处理 ID 为 ${id} 的本子/章节时发生错误`) ]); throw error; } } }; }, "createJmProcessor"); // src/utils/Queue.ts var import_crypto4 = require("crypto"); var Queue = class { static { __name(this, "Queue"); } tasks = []; processor; concurrency; activeTasks = 0; /** * koishi 配置项 */ config; /** * koishi 日志 */ logger; constructor(processor, options = {}, config, logger) { this.config = config; this.logger = logger; this.processor = processor; this.concurrency = options.concurrency || 1; } /** * 向队列添加一个新任务 */ add(payload) { const task = { id: (0, import_crypto4.randomUUID)(), payload, status: "pending", createdAt: (/* @__PURE__ */ new Date()).toISOString() }; this.tasks.push(task); if (this.config.debug) this.logger.info( `[任务添加] 任务ID: ${payload} 类型: ${payload.type} ID: ${payload.id}` ); const { pendingAhead, queuePosition } = this.getTaskQueuePosition(task.id); setTimeout(() => this._processQueue(), 0); return { task, pendingAhead, queuePosition }; } /** * 获取任务状态 */ getTask(id) { return this.tasks.find((t) => t.id === id); } /** * 获取所有任务的只读列表 */ getAllTasks() { return this.tasks; } /** * 获取指定任务在队列中的位置信息 * @param taskId 任务ID * @returns {pendingAhead: number, queuePosition: number} * pendingAhead: 在此任务之前有多少个待处理任务 * queuePosition: 此任务在所有待处理任务中的位置(从1开始) */ getTaskQueuePosition(taskId) { let pendingAhead = 0; let queuePosition = 0; let found = false; for (let i = 0; i < this.tasks.length; i++) { const currentTask = this.tasks[i]; if (currentTask.id === taskId) { found = true; if (currentTask.status === "pending" || currentTask.status === "processing") { queuePosition = pendingAhead + 1; } break; } if (currentTask.status === "pending" || currentTask.status === "processing") { pendingAhead++; } } if (!found) { return { pendingAhead: -1, queuePosition: -1 }; } return { pendingAhead, queuePosition }; } /** * 检查并处理队列中的任务 */ _processQueue() { while (this.activeTasks < this.concurrency) { const task = this.tasks.find((t) => t.status === "pending"); if (!task) break; this.activeTasks++; task.status = "processing"; if (this.config.debug) this.logger.info( `[任务开始] 任务ID: ${task.id} 类型: ${task.payload.type} ID: ${task.payload.id}` ); this._runTask(task); } } /** * 执行单个任务 */ async _runTask(task) { try { await this.processor(task.payload); task.status = "completed"; if (this.config.debug) this.logger.info( `[任务成功] 任务ID: ${task.id} 类型: ${task.payload.type} ID: ${task.payload.id}` ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (this.config.debug) this.logger.error( `[任务失败] 任务ID: ${task.id} 类型: ${task.payload.type} ID: ${task.payload.id}, 错误: ${errorMessage}` ); task.status = "failed"; task.error = errorMessage; } finally { task.processedAt = (/* @__PURE__ */ new Date()).toISOString(); this.activeTasks--; this._processQueue(); } } }; // src/index.ts var name = "jmcomic"; var Config = import_koishi2.Schema.intersect([ import_koishi2.Schema.object({ fileMethod: import_koishi2.Schema.union(["buffer", "file"]).default("buffer"), password: import_koishi2.Schema.string(), fileName: import_koishi2.Schema.string().default("{{name}} ({{id}})_{{index}}"), sendMethod: import_koishi2.Schema.union(["zip", "pdf"]).default("pdf") }), import_koishi2.Schema.union([ import_koishi2.Schema.object({ sendMethod: import_koishi2.Schema.const("zip").required(), level: import_koishi2.Schema.number().min(0).max(9).default(6).role("slider") }), import_koishi2.Schema.object({}) ]), import_koishi2.Schema.object({ retryCount: import_koishi2.Schema.number().min(1).max(5).default(5), concurrentDownloadLimit: import_koishi2.Schema.number().min(0).max(20).default(10), concurrentDecodeLimit: import_koishi2.Schema.number().min(0).max(20).default(5), concurrentQueueLimit: import_koishi2.Schema.number().min(0).max(10).default(1) }), import_koishi2.Schema.object({ cache: import_koishi2.Schema.boolean().default(false) }), import_koishi2.Schema.union([ import_koishi2.Schema.object({ cache: import_koishi2.Schema.const(true).required(), autoDelete: import_koishi2.Schema.boolean().default(false) }), import_koishi2.Schema.object({}) ]), import_koishi2.Schema.union([ import_koishi2.Schema.object({ cache: import_koishi2.Schema.const(true).required(), autoDelete: import_koishi2.Schema.const(true).required(), cron: import_koishi2.Schema.string().default("0 0 * * *"), deleteInStart: import_koishi2.Schema.boolean().default(false), keepDays: import_koishi2.Schema.number().min(1).default(7) }), import_koishi2.Schema.object({}) ]), import_koishi2.Schema.object({ debug: import_koishi2.Schema.boolean().default(false) }) ]).i18n({ "zh-CN": require_zh_CN()._config, "en-US": require_en_US()._config }); var inject = { required: ["http"], optional: ["notifier", "cron"] }; async function apply(ctx, config) { ctx.i18n.define("en-US", require_en_US()); ctx.i18n.define("zh-CN", require_zh_CN()); const logger = ctx.logger("jmcomic"); const root = (0, import_path3.join)(ctx.baseDir, "data", "jmcomic"); const scheduleFn = /* @__PURE__ */ __name(async () => { const albumPath = (0, import_path3.join)(ctx.baseDir, "data", "jmcomic", "album"); await deleteFewDaysAgoFolders(albumPath, config.keepDays); const photoPath = (0, import_path3.join)(ctx.baseDir, "data", "jmcomic", "photo"); await deleteFewDaysAgoFolders(photoPath, config.keepDays); }, "scheduleFn"); if (config.autoDelete && ctx.cron) { ctx.cron(config.cron, scheduleFn); } if (config.autoDelete && config.deleteInStart) scheduleFn(); if (ctx.notifier) { ctx.notifier.create({ type: "warning", content: "据JMComic-Crawler-Python源码可知JM图片还有gif形式,目前尚未支持" }); } const processorConfig = { root, sendMethod: config.sendMethod, password: config.password, level: config.level, fileName: config.fileName, fileMethod: config.fileMethod, cache: config.cache, debug: config.debug || false }; const jmProcessor = createJmProcessor( processorConfig, ctx.http, config, logger ); const queue = new Queue( jmProcessor, { concurrency: config.concurrentQueueLimit || 1 }, config, logger ); const handleAlbumOrPhoto = /* @__PURE__ */ __name(async (session, id, type) => { const messageId = session.messageId; if (!/^\d+$/.test(id)) { await session.send([ import_koishi2.h.quote(messageId), import_koishi2.h.text("输入的ID不合法,请检查") ]); return; } const { task, pendingAhead, queuePosition } = queue.add({ type, id, session, messageId, scope: session.scope }); const params = { id, ahead: pendingAhead, pos: queuePosition, status: task.status }; const msg = [import_koishi2.h.quote(messageId)]; if (pendingAhead === 0 && queuePosition === 1) { msg.push(import_koishi2.h.text(session.text(".queueFirst", params))); } else if (pendingAhead > 0) { msg.push(import_koishi2.h.text(session.text(".queuePosition", params))); } else { msg.push(import_koishi2.h.text(session.text(".queueProcessing", params))); } await session.send(msg); }, "handleAlbumOrPhoto"); ctx.command("jm.album <albumId:string>").alias("本子").action(async ({ session, options }, albumId) => { await handleAlbumOrPhoto(session, albumId, "album"); }); ctx.command("jm.photo <photoId:string>").alias("本子章节").action(async ({ session }, photoId) => { await handleAlbumOrPhoto(session, photoId, "photo"); }); ctx.command("jm.album.info <albumId:string>").alias("本子信息").action(async ({ session, options }, albumId) => { const messageId = session.messageId; if (!/^\d+$/.test(albumId)) { await session.send([ import_koishi2.h.quote(messageId), import_koishi2.h.text("输入的ID不合法,请检查") ]); return; } try { const jmClient = new JMAppClient(root, ctx.http, config, logger); const album = await jmClient.getAlbumById(albumId);