koishi-plugin-pica-comics
Version:
一个用于搜索和下载 Pica 漫画的 Koishi 插件。
454 lines (452 loc) • 24.3 kB
JavaScript
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 __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/index.ts
var src_exports = {};
__export(src_exports, {
Config: () => Config,
apply: () => apply,
inject: () => inject,
name: () => name
});
module.exports = __toCommonJS(src_exports);
var import_koishi = require("koishi");
var path = __toESM(require("path"));
var import_promises = require("fs/promises");
var crypto = __toESM(require("crypto"));
var import_url = require("url");
var import_muhammara = require("muhammara");
var import_sharp = __toESM(require("sharp"));
var name = "pica-comics";
var inject = {
required: ["http"]
};
var logger = new import_koishi.Logger(name);
var Config = import_koishi.Schema.intersect([
import_koishi.Schema.object({
username: import_koishi.Schema.string().description("Pica 登录用户名(注意:不是昵称)。").required(),
password: import_koishi.Schema.string().description("Pica 登录密码。").role("secret").required()
}).description("账号设置"),
import_koishi.Schema.object({
useForwardForSearch: import_koishi.Schema.boolean().description("【QQ平台】是否默认使用合并转发的形式发送【搜索结果】。").default(true),
useForwardForImages: import_koishi.Schema.boolean().description("【QQ平台】当以图片形式发送漫画时,是否默认使用【合并转发】。").default(true),
showImageInSearch: import_koishi.Schema.boolean().description("是否在【搜索结果】中显示封面图片。").default(true)
}).description("消息发送设置"),
import_koishi.Schema.object({
downloadPath: import_koishi.Schema.string().description("PDF 文件和临时文件的保存目录。").default("./data/downloads/pica"),
defaultToPdf: import_koishi.Schema.boolean().description("是否默认将漫画下载为 PDF 文件。").default(true),
pdfPassword: import_koishi.Schema.string().role("secret").description("(可选)为生成的 PDF 文件设置一个打开密码。留空则不加密。"),
enableCompression: import_koishi.Schema.boolean().description("【PDF模式】是否启用图片压缩以减小 PDF 文件体积。").default(true),
compressionQuality: import_koishi.Schema.number().min(1).max(100).step(1).role("slider").default(80).description("【PDF模式】JPEG 图片质量 (1-100)。"),
pdfSendMethod: import_koishi.Schema.union([
import_koishi.Schema.const("buffer").description("Buffer (内存模式,最高兼容性)"),
import_koishi.Schema.const("file").description("File (文件路径模式,低兼容性)")
]).description("PDF 发送方式。如果 Koishi 与机器人客户端不在同一台设备或 Docker 环境中,必须选择“Buffer”。").default("buffer"),
downloadConcurrency: import_koishi.Schema.number().min(1).max(10).step(1).description("【图片/PDF模式】下载漫画图片时的并行下载数量。数值越低越稳定。").default(4),
downloadTimeout: import_koishi.Schema.number().min(1e3).description("【高级】单张图片下载的超时时间(毫秒)。").default(2e4),
downloadRetries: import_koishi.Schema.number().min(0).max(5).step(1).description("【高级】单张图片下载失败后的自动重试次数。").default(3)
}).description("PDF 与下载设置"),
import_koishi.Schema.object({
debug: import_koishi.Schema.boolean().description("是否在控制台输出详细的调试日志。用于排查问题。").default(false)
}).description("调试设置"),
import_koishi.Schema.object({
apiHost: import_koishi.Schema.string().description("Pica API 服务器地址。").default("https://picaapi.picacomic.com"),
apiKey: import_koishi.Schema.string().role("secret").description("Pica API Key。").default("C69BAF41DA5ABD1FFEDC6D2FEA56B"),
hmacKey: import_koishi.Schema.string().role("secret").description("Pica HMAC 签名密钥。").default("~d}$Q7$eIni=V)9\\RK/P.RM4;9[7|@/CA}b~OW!3?EV`:<>M7pddUBL5n|0/*Cn")
}).description("高级设置 (警告:除非你知道你在做什么,否则不要修改这些值!)")
]);
function apply(ctx, config) {
let token = null;
let tokenExpiry = 0;
function createSignature(path2, nonce, time, method) {
const raw = path2 + time + nonce + method + config.apiKey;
return crypto.createHmac("sha256", config.hmacKey).update(raw.toLowerCase()).digest("hex");
}
__name(createSignature, "createSignature");
function buildHeaders(method, path2, authToken) {
const time = Math.floor(Date.now() / 1e3).toString();
const nonce = crypto.randomUUID().replace(/-/g, "");
const signature = createSignature(path2, nonce, time, method);
return {
"api-key": config.apiKey,
"accept": "application/vnd.picacomic.com.v1+json",
"app-channel": "2",
"time": time,
"nonce": nonce,
"signature": signature,
"app-version": "2.2.1.3.3.4",
"app-uuid": "defaultUuid",
"image-quality": "original",
"app-platform": "android",
"app-build-version": "45",
"Content-Type": "application/json; charset=UTF-8",
"user-agent": "okhttp/3.8.1",
...authToken && { "authorization": authToken }
};
}
__name(buildHeaders, "buildHeaders");
async function login() {
const path2 = "auth/sign-in";
const headers = buildHeaders("POST", path2);
try {
const response = await ctx.http.post(`${config.apiHost}/${path2}`, {
email: config.username,
password: config.password
}, { headers });
if (response?.data?.token) {
token = response.data.token;
tokenExpiry = Date.now() + 24 * 60 * 60 * 1e3;
if (config.debug) logger.info("登录成功!");
} else {
logger.warn("登录失败,API 返回数据无效:", response?.data);
}
} catch (error) {
logger.error("登录请求网络失败:", error.response?.data || error.message);
}
}
__name(login, "login");
async function ensureToken() {
if (token && Date.now() < tokenExpiry) return token;
await login();
return token;
}
__name(ensureToken, "ensureToken");
async function getComicInfo(comicId) {
const authToken = await ensureToken();
if (!authToken) return null;
const path2 = `comics/${comicId}`;
const headers = buildHeaders("GET", path2, authToken);
try {
const response = await ctx.http.get(`${config.apiHost}/${path2}`, { headers });
return response?.data?.comic;
} catch (error) {
logger.warn(`[详情] 获取漫画信息失败。ID: ${comicId}`, { error: error.response?.data || error.message });
return null;
}
}
__name(getComicInfo, "getComicInfo");
async function getComicChapters(comicId) {
const authToken = await ensureToken();
if (!authToken) return [];
const path2 = `comics/${comicId}/eps`;
let allChapters = [];
let currentPage = 1;
let totalPages = 1;
do {
const requestPath = `${path2}?page=${currentPage}`;
const headers = buildHeaders("GET", requestPath, authToken);
const response = await ctx.http.get(`${config.apiHost}/${requestPath}`, { headers });
const chapterData = response.data?.eps;
if (!chapterData || !Array.isArray(chapterData.docs)) {
logger.warn(`[章节列表] 获取ID为 ${comicId} 的章节列表失败,API响应无效`, { responseData: response.data });
break;
}
if (currentPage === 1) {
totalPages = chapterData.pages;
}
const chaptersOnPage = chapterData.docs.map((doc) => ({ order: doc.order, id: doc._id }));
allChapters.push(...chaptersOnPage);
currentPage++;
if (currentPage <= totalPages) await (0, import_koishi.sleep)(500);
} while (currentPage <= totalPages);
return allChapters.sort((a, b) => a.order - b.order);
}
__name(getComicChapters, "getComicChapters");
async function downloadImage(url, index) {
for (let i = 0; i <= config.downloadRetries; i++) {
try {
const arrayBuffer = await ctx.http.get(url, {
timeout: config.downloadTimeout,
responseType: "arraybuffer"
});
return { index, buffer: Buffer.from(arrayBuffer) };
} catch (error) {
if (i < config.downloadRetries) {
if (config.debug) logger.warn(`[下载] 图片 ${index + 1} (${url}) 下载失败 (第 ${i + 1} 次), 2秒后重试...`);
await (0, import_koishi.sleep)(2e3);
} else {
logger.error(`[下载] 图片 ${index + 1} (${url}) 在重试 ${config.downloadRetries} 次后最终失败。`);
return { index, error };
}
}
}
}
__name(downloadImage, "downloadImage");
ctx.on("ready", () => login());
ctx.command("picasearch <keyword:text>", "Pica 漫画搜索 (仅展示前10个结果)").action(async ({ session }, keyword) => {
if (!keyword) return "请输入关键词。";
const statusMessage = await session.send((0, import_koishi.h)("quote", { id: session.messageId }) + "正在搜索...");
try {
if (config.debug) logger.info(`[搜索] 开始搜索,关键词: "${keyword}"`);
const authToken = await ensureToken();
if (!authToken) {
logger.warn(`[搜索] 获取 Token 失败,无法继续搜索。`);
return (0, import_koishi.h)("quote", { id: session.messageId }) + "登录失败,无法执行操作。";
}
const requestPath = `comics/search?page=1&q=${encodeURIComponent(keyword)}`;
const headers = buildHeaders("GET", requestPath, authToken);
const response = await ctx.http.get(`${config.apiHost}/${requestPath}`, { headers });
const result = response.data?.comics;
if (!result || !Array.isArray(result.docs) || result.docs.length === 0) {
if (config.debug) logger.info(`[搜索] 未找到关键词 "${keyword}" 的任何结果。`);
return (0, import_koishi.h)("quote", { id: session.messageId }) + "未找到任何结果。";
}
const top10Results = result.docs.slice(0, 10);
if (config.debug) logger.info(`[搜索] 成功!关键词 "${keyword}" 找到 ${result.total} 个结果,将展示 ${top10Results.length} 个。`);
const messageElements = [
(0, import_koishi.h)("p", `搜索到 ${result.total} 个结果,为您展示前 ${top10Results.length} 个:`)
];
const numberEmojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"];
for (const [index, comic] of top10Results.entries()) {
messageElements.push((0, import_koishi.h)("p", "──────────"));
const emoji = numberEmojis[index] || `${index + 1}.`;
const textInfo = `${emoji} [ID] ${comic._id}
[标题] ${comic.title}
[作者] ${comic.author}`;
messageElements.push((0, import_koishi.h)("p", textInfo));
if (config.showImageInSearch && comic.thumb?.fileServer && comic.thumb?.path) {
const imageUrl = `${comic.thumb.fileServer}/static/${comic.thumb.path}`;
const result2 = await downloadImage(imageUrl, index);
if ("buffer" in result2) {
const mime = imageUrl.endsWith(".png") ? "image/png" : "image/jpeg";
messageElements.push(import_koishi.h.image(result2.buffer, mime));
}
}
}
if (config.useForwardForSearch && ["qq", "onebot"].includes(session.platform)) {
if (config.debug) logger.info(`[搜索] 准备发送合并转发消息...`);
await session.send((0, import_koishi.h)("figure", {}, messageElements));
} else {
if (config.debug) logger.info(`[搜索] 准备发送普通消息...`);
await session.send(messageElements);
}
} catch (error) {
logger.error(`[搜索] 请求失败。关键词: "${keyword}"`, { error: error.response?.data || error.message });
return (0, import_koishi.h)("quote", { id: session.messageId }) + "搜索失败,请查看后台日志。";
} finally {
try {
await session.bot.deleteMessage(session.channelId, statusMessage[0]);
} catch (e) {
if (config.debug) logger.warn("撤回状态消息失败", e);
}
}
});
ctx.command("picaid <comicId:string> [chapter:string]", "Pica 漫画下载").option("output", "-o <type:string>").action(async ({ session, options }, comicId, chapter) => {
if (!comicId) return "请输入正确的漫画 ID。";
const statusMessage = await session.send((0, import_koishi.h)("quote", { id: session.messageId }) + `请求下载漫画 ${comicId}...`);
try {
const authToken = await ensureToken();
if (!authToken) {
return (0, import_koishi.h)("quote", { id: session.messageId }) + "登录失败,无法执行操作。";
}
const getImageUrlsForChapter = /* @__PURE__ */ __name(async (order) => {
const path2 = `comics/${comicId}/order/${order}/pages`;
let urls = [];
let currentPage = 1;
let totalPages = 1;
do {
const headers = buildHeaders("GET", `${path2}?page=${currentPage}`, authToken);
const response = await ctx.http.get(`${config.apiHost}/${path2}?page=${currentPage}`, { headers });
if (!response?.data?.pages?.docs || !Array.isArray(response.data.pages.docs)) {
throw new Error(`获取章节 ${order} 失败: API响应无效`);
}
const pageData = response.data.pages;
if (currentPage === 1) {
totalPages = pageData.pages;
if (pageData.total === 0) return [];
}
const validDocs = pageData.docs.filter((doc) => doc && doc.media && doc.media.fileServer && doc.media.path);
urls.push(...validDocs.map((doc) => `${doc.media.fileServer}/static/${doc.media.path}`));
currentPage++;
if (currentPage <= totalPages) await (0, import_koishi.sleep)(500);
} while (currentPage <= totalPages);
return urls;
}, "getImageUrlsForChapter");
let allImageUrls = [];
let isFullDownload = false;
let chapterForTitle = 1;
if (!chapter) {
if (config.debug) logger.info(`[下载] 未指定章节,默认下载第 1 话。ID: ${comicId}`);
allImageUrls = await getImageUrlsForChapter(1);
} else if (chapter.toLowerCase() === "full") {
if (config.debug) logger.info(`[下载] full 模式启动。ID: ${comicId}`);
isFullDownload = true;
const chapters = await getComicChapters(comicId);
if (chapters.length === 0) return "无法获取该漫画的任何章节信息。";
for (const [index, chap] of chapters.entries()) {
if (config.debug) logger.info(`[下载] [Full] 正在处理第 ${index + 1}/${chapters.length} 话 (章节序号: ${chap.order})`);
const urls = await getImageUrlsForChapter(chap.order);
allImageUrls.push(...urls);
}
} else if (/^\d+$/.test(chapter)) {
chapterForTitle = parseInt(chapter, 10);
if (config.debug) logger.info(`[下载] 指定下载第 ${chapterForTitle} 话。ID: ${comicId}`);
allImageUrls = await getImageUrlsForChapter(chapterForTitle);
} else {
return '章节参数不合法。请输入一个数字,或 "full"。';
}
if (allImageUrls.length === 0) {
return (0, import_koishi.h)("quote", { id: session.messageId }) + "未能获取到任何图片链接,任务中止。";
}
const reportFailures = /* @__PURE__ */ __name(async (failedIndexes) => {
if (failedIndexes.length > 0) {
const sortedIndexes = failedIndexes.map((i) => i + 1).sort((a, b) => a - b);
await session.send(`任务完成,但以下图片下载失败,已跳过:
第 ${sortedIndexes.join(", ")} 张。`);
}
}, "reportFailures");
const outputType = options.output || (config.defaultToPdf ? "pdf" : "image");
if (outputType === "pdf") {
if (config.debug) logger.info(`[下载] [PDF] 已获取 ${allImageUrls.length} 张图片,准备生成。`);
const comicInfo = await getComicInfo(comicId);
const comicTitle = comicInfo?.title || comicId;
const finalPart = isFullDownload ? "_全部章节" : `_第${chapterForTitle}话`;
const safeFilename = comicTitle.replace(/[\\/:\*\?"<>\|]/g, "_") + finalPart;
const downloadDir = path.resolve(ctx.app.baseDir, config.downloadPath);
const tempPdfPath = path.resolve(downloadDir, `${safeFilename}_${Date.now()}.pdf`);
const tempImageDir = path.resolve(downloadDir, `temp_${comicId}_${chapter || "full"}_${Date.now()}`);
await (0, import_promises.mkdir)(tempImageDir, { recursive: true });
let recipe;
const failedImageIndexes = [];
try {
recipe = new import_muhammara.Recipe("new", tempPdfPath, { version: 1.6 });
if (config.debug) logger.info(`[下载] [PDF] 开始下载并处理图片,并发数: ${config.downloadConcurrency}`);
const successfulDownloads = [];
for (let i = 0; i < allImageUrls.length; i += config.downloadConcurrency) {
const chunk = allImageUrls.slice(i, i + config.downloadConcurrency);
const chunkPromises = chunk.map((url, idx) => downloadImage(url, i + idx));
const chunkResults = await Promise.all(chunkPromises);
for (const result of chunkResults) {
if ("buffer" in result) {
successfulDownloads.push(result);
} else {
failedImageIndexes.push(result.index);
}
}
if (config.debug) logger.info(`[下载] [PDF] 已完成一批图片下载 (${successfulDownloads.length}/${allImageUrls.length}, 失败 ${failedImageIndexes.length} 张)`);
}
for (const { index, buffer } of successfulDownloads) {
const imagePath = path.resolve(tempImageDir, `${index + 1}.jpg`);
const sharpInstance = (0, import_sharp.default)(buffer);
const jpegOptions = {};
if (config.enableCompression) {
jpegOptions.quality = config.compressionQuality;
}
await sharpInstance.jpeg(jpegOptions).toFile(imagePath);
const metadata = await (0, import_sharp.default)(imagePath).metadata();
recipe.createPage(metadata.width, metadata.height).image(imagePath, 0, 0).endPage();
}
if (config.pdfPassword) {
recipe.encrypt({ userPassword: config.pdfPassword, ownerPassword: config.pdfPassword });
}
recipe.endPDF();
if (config.pdfSendMethod === "buffer") {
if (config.debug) logger.info(`[下载] [PDF] 使用 Buffer 模式发送文件...`);
const pdfBuffer = await (0, import_promises.readFile)(tempPdfPath);
await session.send(import_koishi.h.file(pdfBuffer, "application/pdf", { title: `${safeFilename}.pdf` }));
} else {
if (config.debug) logger.info(`[下载] [PDF] 使用 File 模式发送文件...`);
const fileUrl = (0, import_url.pathToFileURL)(tempPdfPath);
await session.send(import_koishi.h.file(fileUrl.href, { title: `${safeFilename}.pdf` }));
}
} finally {
try {
await (0, import_promises.unlink)(tempPdfPath);
} catch (e) {
}
try {
await (0, import_promises.rm)(tempImageDir, { recursive: true, force: true });
} catch (e) {
}
await reportFailures(failedImageIndexes);
}
} else {
if (isFullDownload) {
return "`full` 模式暂不支持以图片形式发送,请使用 PDF 模式。";
}
if (config.useForwardForImages && ["qq", "onebot"].includes(session.platform)) {
const forwardElements = [];
const failedImageIndexes = [];
if (config.debug) logger.info(`[下载] [Image] 转发模式启动,准备下载 ${allImageUrls.length} 张图片,并发数: ${config.downloadConcurrency}`);
let successfulCount = 0;
for (let i = 0; i < allImageUrls.length; i += config.downloadConcurrency) {
const chunk = allImageUrls.slice(i, i + config.downloadConcurrency);
const chunkPromises = chunk.map((url, idx) => downloadImage(url, i + idx));
const chunkResults = await Promise.all(chunkPromises);
for (const result of chunkResults) {
if ("buffer" in result) {
const mime = allImageUrls[result.index].endsWith(".png") ? "image/png" : "image/jpeg";
forwardElements.push(import_koishi.h.image(result.buffer, mime));
successfulCount++;
} else {
failedImageIndexes.push(result.index);
forwardElements.push((0, import_koishi.h)("p", `第 ${result.index + 1} 张图片下载失败`));
}
}
if (config.debug) logger.info(`[下载] [Image] 已完成一批图片下载 (${successfulCount}/${allImageUrls.length}, 失败 ${failedImageIndexes.length} 张)`);
}
if (forwardElements.length > 0) {
if (config.debug) logger.info(`[下载] [Image] 所有图片处理完毕,准备发送合并转发消息。`);
await session.send((0, import_koishi.h)("figure", {}, forwardElements));
} else {
await session.send("所有图片都下载失败了,无法发送。");
}
} else {
if (config.debug) logger.info(`[下载] [Image] 采用逐张发送模式,共 ${allImageUrls.length} 张图片。`);
for (const [index, imageUrl] of allImageUrls.entries()) {
try {
const message = (0, import_koishi.h)("p", `第 ${index + 1} / ${allImageUrls.length} 张`).toString() + import_koishi.h.image(imageUrl).toString();
await session.send(message);
} catch (error) {
logger.warn(`[下载] 发送单张图片失败。ID: ${comicId}, 章节: ${chapterForTitle}, 图片URL: ${imageUrl}`, { error });
await session.send(`发送第 ${index + 1} 张图片失败,已跳过。`);
}
await (0, import_koishi.sleep)(1500);
}
}
}
} catch (error) {
logger.error(`[下载] 任务失败。ID: ${comicId}, 章节: ${chapter}`, { error: error.message, stack: error.stack });
return (0, import_koishi.h)("quote", { id: session.messageId }) + `下载失败:${error.message}`;
} finally {
try {
await session.bot.deleteMessage(session.channelId, statusMessage[0]);
} catch (e) {
if (config.debug) logger.warn("撤回状态消息失败", e);
}
}
});
}
__name(apply, "apply");
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Config,
apply,
inject,
name
});