UNPKG

karin-plugin-kkk

Version:

Karin 的「抖音」「B 站」视频解析/动态推送插件

1,405 lines (1,404 loc) 267 kB
import { Root } from "../root.js"; import fs from "node:fs"; import { C as Client, b as bilibiliErrorCodeMap, g as getBilibiliData, a as bilibiliApiUrls, w as wbi_sign, c as getDouyinData, h as heicConvertExports, d as browserExports } from "./vendor-BfPxWvnG.js"; import karin, { logger, config, segment, createNotFoundResponse, copyConfigSync, filesByExt, watch, requireFileSync, ffmpeg, ffprobe, render, common, defineConfig, components, karinPathRoot, mkdirSync } from "node-karin"; import axios, { AxiosError } from "node-karin/axios"; import { pipeline } from "stream/promises"; import path, { join } from "node:path"; import { g as getDouyinDB, a as getBilibiliDB, c as cleanOldDynamicCache, b as bilibiliDBInstance, d as douyinDBInstance } from "./db-39zy-DOZ.js"; import template from "node-karin/template"; import _ from "node-karin/lodash"; import "node:child_process"; import "playwright"; import { markdown } from "@karinjs/md-html"; import { karinPathTemp, karinPathBase } from "node-karin/root"; import YAML from "node-karin/yaml"; class Base { e; headers; amagi; constructor(e) { this.e = e; this.headers = baseHeaders; const client = Client({ cookies: { douyin: Config.cookies.douyin, bilibili: Config.cookies.bilibili, kuaishou: Config.cookies.kuaishou }, request: { timeout: Config.request.timeout, headers: { "User-Agent": Config.request["User-Agent"] }, proxy: Config.request.proxy?.switch ? Config.request.proxy : false } }); this.amagi = new Proxy(client, { get(target, prop) { const method = target[prop]; if (typeof method === "function") { return async (...args) => { const result = await Function.prototype.apply.call(method, target, args); if (!result) { logger.warn(`Amagi API调用 (${String(prop)}) 返回了空值`); return result; } if (prop === "getDouyinData" && result.code !== 200) { const err = result; const img2 = await Render("apiError/index", err.error); if (Object.keys(e).length === 0) { const botId = statBotId(Config.pushlist); const list = config.master(); let master = list[0]; if (master === "console") { master = list[1]; } await karin.sendMaster(botId.douyin.botId, master, [segment.text("推送任务出错!请即时解决以消除警告"), img2[0]]); throw new Error(err.data.amagiMessage); } await e.reply(img2); throw new Error(err.data.amagiMessage); } if (prop === "getBilibiliData" && result.code in bilibiliErrorCodeMap) { const err = result; const img2 = await Render("apiError/index", err.error); if (Object.keys(e).length === 0) { const botId = statBotId(Config.pushlist); const list = config.master(); let master = list[0]; if (master === "console") { master = list[1]; } await karin.sendMaster(botId.bilibili.botId, master, [segment.text("推送任务出错!请即时解决以消除警告"), img2[0]]); throw new Error(err.data.amagiMessage); } await e.reply(img2); throw new Error(err.data.amagiMessage); } return result; }; } return method; } }); } } const statBotId = (pushList) => { const platformBotCount = { douyin: /* @__PURE__ */ new Map(), bilibili: /* @__PURE__ */ new Map() }; pushList.douyin.forEach((item) => { item.group_id.forEach((gid) => { const botId = gid.split(":")[1]; platformBotCount.douyin.set(botId, (platformBotCount.douyin.get(botId) ?? 0) + 1); }); }); pushList.bilibili.forEach((item) => { item.group_id.forEach((gid) => { const botId = gid.split(":")[1]; platformBotCount.bilibili.set(botId, (platformBotCount.bilibili.get(botId) ?? 0) + 1); }); }); let douyinMaxCount = 0; let douyinMostFrequentBot = ""; platformBotCount.douyin.forEach((count, botId) => { if (count > douyinMaxCount) { douyinMaxCount = count; douyinMostFrequentBot = botId; } }); let biliMaxCount = 0; let biliMostFrequentBot = ""; platformBotCount.bilibili.forEach((count, botId) => { if (count > biliMaxCount) { biliMaxCount = count; biliMostFrequentBot = botId; } }); return { douyin: { botId: douyinMostFrequentBot, count: douyinMaxCount }, bilibili: { botId: biliMostFrequentBot, count: biliMaxCount } }; }; const Count = (count) => { if (count > 1e4) { return (count / 1e4).toFixed(1) + "万"; } else { return count?.toString() ?? "无法获取"; } }; const uploadFile = async (event, file, videoUrl, options) => { let sendStatus = true; let File; let newFileSize = file.totalBytes; let selfId; let contact; if (options?.active) { selfId = options?.activeOption?.uin; contact = karin.contactGroup(options?.activeOption?.group_id); } else { selfId = event.selfId; contact = event.contact; } if (Config.upload.compress && file.totalBytes > Config.upload.compresstrigger) { const Duration = await mergeFile("获取指定视频文件时长", { path: file.filepath }); logger.warn(logger.yellow(`视频大小 (${file.totalBytes} MB) 触发压缩条件(设定值:${Config.upload.compresstrigger} MB),正在进行压缩至${Config.upload.compressvalue} MB...`)); const message = [ segment.text(`视频大小 (${file.totalBytes} MB) 触发压缩条件(设定值:${Config.upload.compresstrigger} MB),正在进行压缩至${Config.upload.compressvalue} MB...`), options?.message_id ? segment.reply(options.message_id) : segment.text("") ]; const msg1 = await karin.sendMsg(selfId, contact, message); const targetBitrate = Common.calculateBitrate(Config.upload.compresstrigger, Duration) * 0.75; const startTime = Date.now(); file.filepath = await mergeFile("压缩视频", { path: file.filepath, targetBitrate, resultPath: `${Common.tempDri.video}tmp_${Date.now()}.mp4` }); const endTime = Date.now(); newFileSize = await Common.getVideoFileSize(file.filepath); logger.debug(`原始视频大小为: ${file.totalBytes.toFixed(1)} MB, ${logger.green(`经 FFmpeg 压缩后最终视频大小为: ${newFileSize.toFixed(1)} MB,原视频文件已删除`)}`); const message2 = [ segment.text(`压缩后最终视频大小为: ${newFileSize.toFixed(1)} MB,压缩耗时:${((endTime - startTime) / 1e3).toFixed(1)} 秒`), segment.reply(msg1.messageId) ]; await karin.sendMsg(selfId, contact, message2); } if (options) { options.useGroupFile = Config.upload.usegroupfile && newFileSize > Config.upload.groupfilevalue; } if (Config.upload.sendbase64 && !options?.useGroupFile) { const videoBuffer = await fs.promises.readFile(file.filepath); File = `base64://${videoBuffer.toString("base64")}`; logger.mark(`已开启视频文件 base64转换 正在进行${logger.yellow("base64转换中")}...`); } else File = options?.useGroupFile ? file.filepath : `file://${file.filepath}`; try { if (options?.active) { if (options.useGroupFile) { const bot = karin.getBot(String(options.activeOption?.uin)); logger.mark(`${logger.blue("主动消息:")} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow("bot.uploadFile")}回复...`); await bot.uploadFile(contact, File, file.originTitle ? `${file.originTitle}.mp4` : `${File.split("/").pop()}`); } else { logger.mark(`${logger.blue("主动消息:")} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow("karin.sendMsg")}回复...`); const status = await karin.sendMsg(selfId, contact, [segment.video(File)]); status.messageId ? sendStatus = true : sendStatus = false; } } else { if (options?.useGroupFile) { logger.mark(`${logger.blue("被动消息:")} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow("e.bot.uploadFile")}回复...`); await event.bot.uploadFile(event.contact, File, file.originTitle ? `${file.originTitle}.mp4` : `${File.split("/").pop()}`); } else { logger.mark(`${logger.blue("被动消息:")} 视频大小: ${newFileSize.toFixed(1)}MB 正在通过${logger.yellow("e.reply")}回复...`); const status = await event.reply(segment.video(File) || videoUrl); status.messageId ? sendStatus = true : sendStatus = false; } } return sendStatus; } catch (error) { if (options && options.active === false) { await event.reply("视频文件上传失败" + JSON.stringify(error, null, 2)); } logger.error("视频文件上传错误," + String(error)); return false; } finally { const filePath = file.filepath; logger.mark(`临时预览地址:http://localhost:${process.env.HTTP_PORT}/api/kkk/video/${encodeURIComponent(filePath.split("/").pop() ?? "")}`); Config.app.rmmp4 && logger.info(`文件 ${filePath} 将在 10 分钟后删除`); setTimeout(async () => { await Common.removeFile(filePath); }, 10 * 60 * 1e3); } }; const downloadVideo = async (event, downloadOpt, uploadOpt) => { const fileHeaders = await new Networks({ url: downloadOpt.video_url, headers: downloadOpt.headers ?? baseHeaders }).getHeaders(); const fileSizeContent = fileHeaders["content-range"]?.match(/\/(\d+)/) ? parseInt(fileHeaders["content-range"]?.match(/\/(\d+)/)[1], 10) : 0; const fileSizeInMB = (fileSizeContent / (1024 * 1024)).toFixed(2); const fileSize = parseInt(parseFloat(fileSizeInMB).toFixed(2)); if (Config.upload.usefilelimit && fileSize > Config.upload.filelimit) { const message = segment.text(`视频:「${downloadOpt.title.originTitle ?? "Error: 文件名获取失败"}」大小 (${fileSizeInMB} MB) 超出最大限制(设定值:${Config.upload.filelimit} MB),已取消上传`); const selfId = event.selfId || uploadOpt?.activeOption?.uin; const contact = event.contact || karin.contactGroup(uploadOpt?.activeOption?.group_id) || karin.contactFriend(selfId); await karin.sendMsg(selfId, contact, message); return false; } let res = await downloadFile(downloadOpt.video_url, { title: Config.app.rmmp4 ? downloadOpt.title.timestampTitle : processFilename(downloadOpt.title.originTitle, 50), headers: downloadOpt.headers ?? baseHeaders }); res = { ...res, ...downloadOpt.title }; res.totalBytes = Number((res.totalBytes / (1024 * 1024)).toFixed(2)); return await uploadFile(event, res, downloadOpt.video_url, uploadOpt); }; const downloadFile = async (videoUrl, opt) => { const startTime = Date.now(); const { filepath, totalBytes } = await new Networks({ url: videoUrl, // 视频地址 headers: opt.headers ?? baseHeaders, // 请求头 filepath: Common.tempDri.video + opt.title, // 文件保存路径 timeout: 3e4 // 设置 30 秒超时 }).downloadStream((downloadedBytes, totalBytes2) => { const barLength = 45; function generateProgressBar(progressPercentage2) { const filledLength = Math.floor(progressPercentage2 / 100 * barLength); let progress = ""; progress += "█".repeat(filledLength); progress += "░".repeat(Math.max(0, barLength - filledLength - 1)); return `[${progress}]`; } const progressPercentage = downloadedBytes / totalBytes2 * 100; const red = Math.floor(255 - 255 * progressPercentage / 100); const coloredPercentage = logger.chalk.rgb(red, 255, 0)(`${progressPercentage.toFixed(1)}%`); const elapsedTime = (Date.now() - startTime) / 1e3; const speed = downloadedBytes / elapsedTime; const formattedSpeed = (speed / 1048576).toFixed(1) + " MB/s"; const remainingBytes = totalBytes2 - downloadedBytes; const remainingTime = remainingBytes / speed; const formattedRemainingTime = remainingTime > 60 ? `${Math.floor(remainingTime / 60)}min ${Math.floor(remainingTime % 60)}s` : `${remainingTime.toFixed(0)}s`; const downloadedSizeMB = (downloadedBytes / 1048576).toFixed(1); const totalSizeMB = (totalBytes2 / 1048576).toFixed(1); console.log( `⬇️ ${opt.title} ${generateProgressBar(progressPercentage)} ${coloredPercentage} ${downloadedSizeMB}/${totalSizeMB} MB | ${formattedSpeed} 剩余: ${formattedRemainingTime}\r` ); }, 3); return { filepath, totalBytes }; }; const processFilename = (filename, maxLength = 50) => { const lastDotIndex = filename.lastIndexOf("."); const hasExtension = lastDotIndex > 0 && lastDotIndex < filename.length - 1; if (!hasExtension) { return filename.substring(0, maxLength).replace(/[\\/:*?"<>|\r\n\s]/g, " "); } const nameWithoutExt = filename.substring(0, lastDotIndex); const extension = filename.substring(lastDotIndex); const processedName = nameWithoutExt.substring(0, maxLength).replace(/[\\/:*?"<>|\r\n\s]/g, " "); return processedName + "..." + extension; }; class Tools { /** * 插件缓存目录 */ tempDri; constructor() { this.tempDri = { /** 插件缓存目录 */ default: `${karinPathTemp}/${Root.pluginName}/`.replace(/\\/g, "/"), /** 视频缓存文件 */ video: `${karinPathTemp}/${Root.pluginName}/kkkdownload/video/`.replace(/\\/g, "/"), /** 图片缓存文件 */ images: `${karinPathTemp}/${Root.pluginName}/kkkdownload/images/`.replace(/\\/g, "/") }; } /** * 获取引用消息 * @param e event 消息事件 * @returns 被引用的消息 */ async getReplyMessage(e) { if (e.replyId) { const reply = await e.bot.getMsg(e.contact, e.replyId); for (const v of reply.elements) { if (v.type === "text") { return v.text; } else if (v.type === "json") { return v.data; } } } return ""; } /** * 将中文数字转换为阿拉伯数字的函数 * @param chineseNumber 数字的中文 * @returns 中文数字对应的阿拉伯数字映射 */ chineseToArabic(chineseNumber) { const chineseToArabicMap = { 零: 0, 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 }; const units = { 十: 10, 百: 100, 千: 1e3, 万: 1e4, 亿: 1e8 }; let result = 0; let temp = 0; let unit = 1; for (let i = chineseNumber.length - 1; i >= 0; i--) { const char = chineseNumber[i]; if (units[char] !== void 0) { unit = units[char]; if (unit === 1e4 || unit === 1e8) { result += temp * unit; temp = 0; } } else { const num = chineseToArabicMap[char]; if (unit > 1) { temp += num * unit; } else { temp += num; } unit = 1; } } return result + temp; } /** * 格式化cookie字符串 * @param cookies cookie数组 * @returns 格式化后的cookie字符串 */ formatCookies(cookies) { return cookies.map((cookie) => { const [nameValue] = cookie.split(";").map((part) => part.trim()); const [name, value] = nameValue.split("="); return `${name}=${value}`; }).join("; "); } /** * 计算目标视频平均码率(单位:Kbps) * @param targetSizeMB 目标视频大小(MB) * @param duration 视频时长(秒) * @returns */ calculateBitrate(targetSizeMB, duration) { const targetSizeBytes = targetSizeMB * 1024 * 1024; return targetSizeBytes * 8 / duration / 1024; } /** * 获取视频文件大小(单位MB) * @param filePath 视频文件绝对路径 * @returns */ async getVideoFileSize(filePath) { try { const stats = await fs.promises.stat(filePath); const fileSizeInBytes = stats.size; const fileSizeInMB = fileSizeInBytes / (1024 * 1024); return fileSizeInMB; } catch (error) { console.error("获取文件大小时发生错误:", error); throw error; } } /** * 根据配置文件的配置项,删除缓存文件 * @param path 文件的绝对路径 * @param force 是否强制删除,默认 `false` * @returns */ async removeFile(path2, force = false) { path2 = path2.replace(/\\/g, "/"); if (Config.app.rmmp4) { try { await fs.promises.unlink(path2); logger.mark("缓存文件: ", path2 + " 删除成功!"); return true; } catch (err) { logger.error("缓存文件: ", path2 + " 删除失败!", err); return false; } } else if (force) { try { await fs.promises.unlink(path2); logger.mark("缓存文件: ", path2 + " 删除成功!"); return true; } catch (err) { logger.error("缓存文件: ", path2 + " 删除失败!", err); return false; } } return true; } /** * 将时间戳转换为日期时间字符串 * @param timestamp 时间戳 * @returns 格式为YYYY-MM-DD HH:MM的日期时间字符串 */ convertTimestampToDateTime(timestamp) { const date = new Date(timestamp * 1e3); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day} ${hours}:${minutes}`; } /** * 获取当前时间:年-月-日 时:分:秒 * @returns */ getCurrentTime() { const now = /* @__PURE__ */ new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; const day = now.getDate(); const hour = now.getHours(); const minute = now.getMinutes(); const second = now.getSeconds(); const formattedMonth = month < 10 ? "0" + month : "" + month; const formattedDay = day < 10 ? "0" + day : "" + day; const formattedHour = hour < 10 ? "0" + hour : "" + hour; const formattedMinute = minute < 10 ? "0" + minute : "" + minute; const formattedSecond = second < 10 ? "0" + second : "" + second; return `${year}-${formattedMonth}-${formattedDay} ${formattedHour}:${formattedMinute}:${formattedSecond}`; } /** * 评论图、推送图是否使用深色模式 * @returns */ useDarkTheme() { let dark = true; const configTheme = Config.app.Theme; if (configTheme === 0) { const date = (/* @__PURE__ */ new Date()).getHours(); if (date >= 6 && date < 18) { dark = false; } } else if (configTheme === 1) { dark = false; } else if (configTheme === 2) { dark = true; } return dark; } /** * 传入一个时间戳(单位:毫秒),返回距离当前时间的相对的时间字符串 * @param timestamp 时间戳 * @returns 距离这个时间戳过去的多久的字符串 */ timeSince(timestamp) { const now = Date.now(); const elapsed = now - timestamp; const seconds = Math.floor(elapsed / 1e3); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const remainingSeconds = seconds % 60; const remainingMinutes = minutes % 60; if (hours > 0) { return `${hours}小时${remainingMinutes}分钟${remainingSeconds}秒`; } else if (minutes > 0) { return `${minutes}分钟${remainingSeconds}秒`; } else { return `${seconds}秒`; } } /** * 验证视频请求 * @param filename 文件名 * @param res 响应对象 * @returns 返回安全解析后的路径 */ validateVideoRequest(filename, res) { if (!filename) { createNotFoundResponse(res, "无效的文件名"); return null; } const intendedBaseDir = path.resolve(Common.tempDri.video); const requestedPath = path.join(intendedBaseDir, filename); const resolvedPath = path.normalize(requestedPath); if (!resolvedPath.startsWith(intendedBaseDir + path.sep) || filename.includes("/") || filename.includes("\\")) { logger.warn(`潜在的路径穿越尝试或无效文件名: ${filename}, 解析路径: ${resolvedPath}`); createNotFoundResponse(res, "无效的文件名或路径"); return null; } if (path.basename(filename) !== filename) { logger.warn(`文件名包含路径分隔符: ${filename}`); createNotFoundResponse(res, "无效的文件名"); return null; } if (!fs.existsSync(resolvedPath)) { createNotFoundResponse(res, "视频文件未找到"); return null; } return resolvedPath; } } const Common = new Tools(); class Cfg { /** 用户配置文件路径 */ dirCfgPath; /** 默认配置文件路径 */ defCfgPath; constructor() { this.dirCfgPath = `${karinPathBase}/${Root.pluginName}/config`; this.defCfgPath = `${Root.pluginPath}/config/default_config/`; } /** 初始化配置 */ initCfg() { copyConfigSync(this.defCfgPath, this.dirCfgPath); const files = filesByExt(this.dirCfgPath, ".yaml", "name"); for (const file of files) { const config2 = YAML.parseDocument(fs.readFileSync(`${this.dirCfgPath}/${file}`, "utf8")); const defConfig = YAML.parseDocument(fs.readFileSync(`${this.defCfgPath}/${file}`, "utf8")); const { differences, result } = this.mergeObjectsWithPriority(config2, defConfig); let needsUpdate = differences; if (file === "pushlist.yaml") { const updated = this.addSwitchFieldToPushlist(result); if (updated) { needsUpdate = true; } } if (needsUpdate) { fs.writeFileSync(`${this.dirCfgPath}/${file}`, result.toString({ lineWidth: -1 })); } } setTimeout(() => { const list = filesByExt(this.dirCfgPath, ".yaml", "abs"); list.forEach((file) => watch(file, (_old, _now) => { })); }, 2e3); return this; } /** * 获取默认配置和用户配置 * @param name 配置文件名 * @returns 返回合并后的配置 */ getDefOrConfig(name) { const def = this.getYaml("default_config", name); const config2 = this.getYaml("config", name); return { ...def, ...config2 }; } /** 获取所有配置文件 */ async All() { const douyinDB = await getDouyinDB(); const bilibiliDB = await getBilibiliDB(); const allConfig = {}; const files = fs.readdirSync(this.defCfgPath); for (const file of files) { const fileName = path.basename(file, ".yaml"); allConfig[fileName] = this.getDefOrConfig(fileName) || {}; } if (allConfig.pushlist) { try { if (allConfig.pushlist.douyin) { for (const item of allConfig.pushlist.douyin) { const filterWords = await douyinDB.getFilterWords(item.sec_uid); const filterTags = await douyinDB.getFilterTags(item.sec_uid); const userInfo = await douyinDB.getDouyinUser(item.sec_uid); if (userInfo) { item.filterMode = userInfo.filterMode || "blacklist"; } item.Keywords = filterWords; item.Tags = filterTags; } } if (allConfig.pushlist.bilibili) { for (const item of allConfig.pushlist.bilibili) { const filterWords = await bilibiliDB.getFilterWords(item.host_mid); const filterTags = await bilibiliDB.getFilterTags(item.host_mid); const userInfo = await bilibiliDB.getOrCreateBilibiliUser(item.host_mid); if (userInfo) { item.filterMode = userInfo.filterMode || "blacklist"; } item.Keywords = filterWords; item.Tags = filterTags; } } } catch (error) { logger.error(`从数据库获取过滤配置时出错: ${error}`); } } return allConfig; } /** * 获取 YAML 文件内容 * @param type 配置文件类型 * @param name 配置文件名 * @returns 返回 YAML 文件内容 */ getYaml(type, name) { const file = type === "config" ? `${this.dirCfgPath}/${name}.yaml` : `${this.defCfgPath}/${name}.yaml`; return requireFileSync(file, { force: true }); } /** * 修改配置文件 * @param name 文件名 * @param key 键 * @param value 值 * @param type 配置文件类型,默认为用户配置文件 `config` */ Modify(name, key, value, type = "config") { const path2 = type === "config" ? `${this.dirCfgPath}/${name}.yaml` : `${this.defCfgPath}/${name}.yaml`; const yamlData = YAML.parseDocument(fs.readFileSync(path2, "utf8")); const keys = key.split("."); this.setNestedValue(yamlData.contents, keys, value); fs.writeFileSync(path2, yamlData.toString({ lineWidth: -1 }), "utf8"); } /** * 修改整个配置文件,保留注释 * @param name 文件名 * @param config 完整的配置对象 * @param type 配置文件类型,默认为用户配置文件 `config` */ async ModifyPro(name, config2, type = "config") { const douyinDB = await getDouyinDB(); const bilibiliDB = await getBilibiliDB(); const filePath = type === "config" ? `${this.dirCfgPath}/${name}.yaml` : `${this.defCfgPath}/${name}.yaml`; try { const existingContent = fs.readFileSync(filePath, "utf8"); const doc = YAML.parseDocument(existingContent); let filterCfg = config2; if (name === "pushlist" && ("douyin" in config2 || "bilibili" in config2)) { const cleanedConfig = { ...config2 }; if ("douyin" in cleanedConfig) { cleanedConfig.douyin = cleanedConfig.douyin.map((item) => { const { Keywords, Tags, filterMode, ...rest } = item; return rest; }); } if ("bilibili" in cleanedConfig) { cleanedConfig.bilibili = cleanedConfig.bilibili.map((item) => { const { Keywords, Tags, filterMode, ...rest } = item; return rest; }); } filterCfg = cleanedConfig; } const newConfigNode = YAML.parseDocument(YAML.stringify(filterCfg)).contents; this.deepMergeYaml(doc.contents, newConfigNode); fs.writeFileSync(filePath, doc.toString({ lineWidth: -1 }), "utf8"); if ("douyin" in config2) { await this.syncFilterConfigToDb(config2.douyin, douyinDB, "sec_uid"); logger.debug("已同步抖音过滤配置到数据库"); } if ("bilibili" in config2) { await this.syncFilterConfigToDb(config2.bilibili, bilibiliDB, "host_mid"); logger.debug("已同步B站过滤配置到数据库"); } return true; } catch (error) { logger.error(`修改配置文件时发生错误:${error}`); return false; } } /** * 同步过滤配置到数据库 * @param items 推送项列表 * @param db 数据库实例 * @param idField ID字段名称 */ async syncFilterConfigToDb(items, db, idField) { for (const item of items) { const id = item[idField]; if (!id) continue; if (item.filterMode) { await db.updateFilterMode(id, item.filterMode); } if (item.Keywords && Array.isArray(item.Keywords)) { const existingWords = await db.getFilterWords(id); for (const word of existingWords) { if (!item.Keywords.includes(word)) { await db.removeFilterWord(id, word); } } for (const word of item.Keywords) { if (!existingWords.includes(word)) { await db.addFilterWord(id, word); } } } if (item.Tags && Array.isArray(item.Tags)) { const existingTags = await db.getFilterTags(id); for (const tag of existingTags) { if (!item.Tags.includes(tag)) { await db.removeFilterTag(id, tag); } } for (const tag of item.Tags) { if (!existingTags.includes(tag)) { await db.addFilterTag(id, tag); } } } } } /** * 深度合并YAML节点(保留目标注释) * @param target 目标节点(保留注释的原始节点) * @param source 源节点(提供新值的节点) */ deepMergeYaml(target, source) { if (YAML.isMap(target) && YAML.isMap(source)) { for (const pair of source.items) { const key = pair.key; const sourceVal = pair.value; const targetVal = target.get(key); if (targetVal === void 0) { target.set(key, sourceVal); } else if (YAML.isMap(targetVal) && YAML.isMap(sourceVal)) { this.deepMergeYaml(targetVal, sourceVal); } else if (YAML.isSeq(targetVal) && YAML.isSeq(sourceVal)) { targetVal.items = sourceVal.items; targetVal.flow = sourceVal.flow; } else { target.set(key, sourceVal); } } } } /** * 在YAML映射中设置嵌套值 * * 该函数用于在给定的YAML映射(map)中,根据指定的键路径(keys)设置值(value) * 如果键路径不存在,该函数会创建必要的嵌套映射结构并设置值 * * @param map YAML映射,作为设置值的目标 * @param keys 键路径,表示要设置的值的位置 * @param value 要设置的值 */ setNestedValue(map, keys, value) { if (keys.length === 1) { map.set(keys[0], value); return; } const subKey = keys[0]; let subMap = map.get(subKey); if (!subMap || !YAML.isMap(subMap)) { subMap = new YAML.YAMLMap(); map.set(subKey, subMap); } this.setNestedValue(subMap, keys.slice(1), value); } /** * 为推送列表配置添加switch字段兼容 * @param doc YAML文档 * @returns 是否有更新 */ addSwitchFieldToPushlist(doc) { let hasUpdates = false; const contents = doc.contents; const douyinList = contents.get("douyin"); if (YAML.isSeq(douyinList)) { for (const item of douyinList.items) { if (YAML.isMap(item)) { const switchField = item.get("switch"); if (switchField === void 0) { item.set("switch", true); hasUpdates = true; } } } } const bilibiliList = contents.get("bilibili"); if (YAML.isSeq(bilibiliList)) { for (const item of bilibiliList.items) { if (YAML.isMap(item)) { const switchField = item.get("switch"); if (switchField === void 0) { item.set("switch", true); hasUpdates = true; } } } } return hasUpdates; } mergeObjectsWithPriority(userDoc, defaultDoc) { let differences = false; const mergeYamlNodes = (target, source) => { if (YAML.isMap(target) && YAML.isMap(source)) { for (const pair of source.items) { const key = pair.key; const value = pair.value; const existing = target.get(key); if (existing === void 0) { differences = true; target.set(key, value); } else if (YAML.isMap(value) && YAML.isMap(existing)) { mergeYamlNodes(existing, value); } else if (existing !== value) { differences = true; target.set(key, value); } } } }; mergeYamlNodes(defaultDoc.contents, userDoc.contents); return { differences, result: defaultDoc }; } /** * 同步配置到数据库 * 这个方法应该在所有模块都初始化完成后调用 */ async syncConfigToDatabase() { try { const pushCfg = this.getYaml("config", "pushlist"); const douyinDB = await getDouyinDB(); const bilibiliDB = await getBilibiliDB(); if (pushCfg.bilibili) { await bilibiliDB.syncConfigSubscriptions(pushCfg.bilibili); } if (pushCfg.douyin) { await douyinDB.syncConfigSubscriptions(pushCfg.douyin); } logger.debug("[BilibiliDB] + [DouyinDB] 配置已同步到数据库"); } catch (error) { logger.error("同步配置到数据库失败:", error); } } } const Config = new Proxy(new Cfg().initCfg(), { get(target, prop) { if (prop in target) return target[prop]; return target.getDefOrConfig(prop); } }); const mergeFile = async (type, options) => { return await new FFmpeg(type).FFmpeg(options); }; class FFmpeg { type; constructor(type) { this.type = type; } async FFmpeg(opt) { switch (this.type) { case "二合一(视频 + 音频)": { const result = await ffmpeg(`-y -i ${opt.path} -i ${opt.path2} -c copy ${opt.resultPath}`); result.status ? logger.mark(`视频合成成功!文件地址:${opt.resultPath}`) : logger.error(result); await opt.callback(result.status, opt.resultPath); return result; } case "视频*3 + 音频": { const result = await ffmpeg(`-y -stream_loop 2 -i ${opt.path} -i ${opt.path2} -filter_complex "[0:v]setpts=N/FRAME_RATE/TB[v];[0:a][1:a]amix=inputs=2:duration=shortest:dropout_transition=3[aout]" -map "[v]" -map "[aout]" -c:v libx264 -c:a aac -b:a 192k -shortest ${opt.resultPath}`); result ? logger.mark(`视频合成成功!文件地址:${opt.resultPath}`) : logger.error(result); await opt.callback(result.status, opt.resultPath); return result; } case "获取指定视频文件时长": { const { stdout } = await ffprobe(`-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${opt.path}`); return parseFloat(parseFloat(stdout.trim()).toFixed(2)); } case "压缩视频": { const result = await ffmpeg(`-y -i "${opt.path}" -b:v ${opt.targetBitrate}k -maxrate ${opt.maxRate ?? opt.targetBitrate * 1.5}k -bufsize ${opt.bufSize ?? opt.targetBitrate * 2}k -crf ${opt.crf ?? 35} -preset medium -c:v libx264 -vf "scale='if(gte(iw/ih,16/9),1280,-1)':'if(gte(iw/ih,16/9),-1,720)',scale=ceil(iw/2)*2:ceil(ih/2)*2" "${opt.resultPath}"`); if (result.status) { logger.mark(`视频已压缩并保存到: ${opt.resultPath}`); Common.removeFile(opt.path); } else { logger.error(opt.path + " 压缩失败!"); logger.error(result); } return opt.resultPath; } } } } const baseHeaders = { Accept: "*/*", "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edg/137.0.0.0" }; class Networks { url; method; headers; type; body; axiosInstance; timeout; filepath; maxRetries; constructor(data) { this.headers = data.headers ? Object.fromEntries( Object.entries(data.headers).map(([key, value]) => [key, String(value)]) ) : {}; this.url = data.url ?? ""; this.type = data.type ?? "json"; this.method = data.method ?? "GET"; this.body = data.body ?? null; this.timeout = data.timeout ?? 15e3; this.filepath = data.filepath ?? ""; this.maxRetries = 0; this.axiosInstance = axios.create({ timeout: this.timeout, headers: this.headers, maxRedirects: 5, validateStatus: (status) => { return status >= 200 && status < 300 || status === 406 || status >= 500; } }); } get config() { const config2 = { url: this.url, method: this.method, headers: this.headers, responseType: this.type }; if (this.method === "POST" && this.body) { config2.data = this.body; } return config2; } /** * 异步下载流方法 * * @param progressCallback 下载进度回调函数,接收已下载字节数和总字节数作为参数 * @param retryCount 重试次数,默认为0 * @returns 返回一个Promise,解析为包含文件路径和总字节数的对象 * * 此函数通过axios库发送HTTP请求,下载指定URL的资源,并将下载的资源流保存到本地文件系统中 * 它还提供了一个回调函数来报告下载进度,并在下载失败时根据配置自动重试 */ async downloadStream(progressCallback, retryCount = 0) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await axios({ ...this.config, url: this.url, responseType: "stream", signal: controller.signal }); clearTimeout(timeoutId); if (!(response.status >= 200 && response.status < 300)) { throw new Error(`无法获取 ${this.url}。状态: ${response.status} ${response.statusText}`); } const totalBytes = parseInt(response.headers["content-length"] ?? "0", 10); if (isNaN(totalBytes)) { throw new Error("无效的 content-length 头"); } let downloadedBytes = 0; let lastPrintedPercentage = -1; const writer = fs.createWriteStream(this.filepath); const printProgress = () => { const progressPercentage = Math.floor(downloadedBytes / totalBytes * 100); if (progressPercentage !== lastPrintedPercentage) { progressCallback(downloadedBytes, totalBytes); lastPrintedPercentage = progressPercentage; } }; const interval = totalBytes < 10 * 1024 * 1024 ? 1e3 : 500; const intervalId = setInterval(printProgress, interval); const onData = (chunk) => { downloadedBytes += chunk.length; }; response.data.on("data", onData); await pipeline( response.data, writer ); clearInterval(intervalId); response.data.off("data", onData); writer.end(); return { filepath: this.filepath, totalBytes }; } catch (error) { clearTimeout(timeoutId); if (error instanceof AxiosError) { logger.error(`请求在 ${this.timeout / 1e3} 秒后超时`); } else { logger.error("下载失败:", error); } if (retryCount < this.maxRetries) { const delay = Math.min(Math.pow(2, retryCount) * 1e3, 1e3); logger.warn(`正在重试下载... (${retryCount + 1}/${this.maxRetries}),将在 ${delay / 1e3} 秒后重试`); await new Promise((resolve) => setTimeout(resolve, delay)); return this.downloadStream(progressCallback, retryCount + 1); } else { throw new Error(`在 ${this.maxRetries} 次尝试后下载失败: ${error}`); } } } async getfetch() { try { const result = await this.returnResult(); if (result.status === 504) { return result; } return result; } catch (error) { logger.info(error); return false; } } async returnResult() { let response = {}; try { response = await this.axiosInstance(this.config); } catch (error) { logger.error(error); } return response; } /** 最终地址(跟随重定向) */ async getLongLink(url = "") { let errorMsg = `获取链接重定向失败: ${this.url || url}`; try { const response = await this.axiosInstance.head(this.url || url); return response.request.res.responseUrl; } catch (error) { const axiosError = error; if (axiosError.response) { if (axiosError.response.status === 302) { const redirectUrl = axiosError.response.headers.location; logger.info(`检测到302重定向,目标地址: ${redirectUrl}`); return await this.getLongLink(redirectUrl); } else if (axiosError.response.status === 403) { errorMsg = `403 Forbidden 禁止访问!${this.url || url}`; logger.error(errorMsg); return errorMsg; } } logger.error(errorMsg); return errorMsg; } } /** 获取首个302链接 */ async getLocation() { try { const response = await this.axiosInstance({ method: "GET", url: this.url, maxRedirects: 0, // 禁止跟随重定向 validateStatus: (status) => status >= 300 && status < 400 // 仅处理3xx响应 }); return response.headers.location; } catch (error) { if (error instanceof AxiosError) { logger.error(`获取 ${this.url} 重定向地址失败,接口响应状态码: ${error.response?.status}`); throw new Error(error.stack); } } } /** 获取数据并处理数据的格式化,默认json */ async getData() { try { const result = await this.returnResult(); if (result.status === 504) { return result; } if (result.status === 429) { logger.error("HTTP 响应状态码: 429"); throw new Error("ratelimit triggered, 触发 https://www.douyin.com/ 的速率限制!!!"); } return result.data; } catch (error) { if (error instanceof AxiosError) { throw new Error(error.stack ?? error.message); } return false; } } /** * 获取响应头信息(仅首个字节) * 适用于获取视频流的完整大小 * @returns 返回响应头信息 */ async getHeaders() { try { const response = await this.axiosInstance({ ...this.config, method: "GET", headers: { ...this.config.headers, Range: "bytes=0-0" } }); return response.headers; } catch (error) { logger.error(error); throw error; } } /** * 获取响应头信息(完整) * @returns */ async getHeadersFull() { try { const response = await this.axiosInstance({ ...this.config, method: "GET" }); return response.headers; } catch (error) { logger.error(error); throw error; } } } function scale(pct = 1) { const scale2 = Math.min(2, Math.max(0.5, Number(Config.app.renderScale) / 100)); pct = pct * scale2; return `style=transform:scale(${pct})`; } async function Render(path2, params) { const basePaths = { douyin: "douyin/html", bilibili: "bilibili/html", admin: "admin/html", kuaishou: "kuaishou/html", help: "help/html", apiError: "apiError/html" }; const platform = Object.keys(basePaths).find((key) => path2.startsWith(key)); let newPath = path2.substring(platform.length); if (newPath.startsWith("/")) { newPath = newPath.substring(1); } path2 = `${basePaths[platform]}/${newPath}`; const renderOpt = { pageGotoParams: { waitUntil: "load", timeout: Config.app.RenderWaitTime * 1e3 }, name: `${Root.pluginName}/${platform}/${newPath}/`.replace(/\\/g, "/"), file: `${Root.pluginPath}/resources/template/${path2}.html`, type: "jpeg" }; const img2 = await render.render({ ...renderOpt, multiPage: 12e3, encoding: "base64", data: { ...params, _res_path: (join(Root.pluginPath, "/resources") + "/").replace(/\\/g, "/"), _layout_path: (join(Root.pluginPath, "/resources", "template", "extend") + "/").replace(/\\/g, "/"), defaultLayout: (join(Root.pluginPath, "/resources", "template", "extend", "html") + "/default.html").replace(/\\/g, "/"), sys: { scale: scale(params?.scale ?? 1) }, pluResPath: `${Root.pluginPath}/resources/`, copyright: Config.app.RemoveWatermark ? "" : `<span class="name">kkk</span><span class="version">${Root.pluginVersion} ${releaseType()}</span> Powered By <span class="name">Karin</span>`, useDarkTheme: Common.useDarkTheme() }, screensEval: "#container" }); const ret = []; for (const imgae of img2) { ret.push(segment.image("base64://" + imgae)); } return ret; } const releaseType = () => { const versionPattern = /^\d+\.\d+\.\d+$/; if (versionPattern.test(Root.pluginVersion)) { return "Stable"; } else { return "Preview"; } }; let img$1; class Bilibili extends Base { e; type; STATUS; isVIP; Type; islogin; downloadfilename; get botadapter() { return this.e.bot?.adapter?.name; } constructor(e, data) { super(e); this.e = e; this.isVIP = false; this.Type = data?.type; this.islogin = data?.USER?.STATUS === "isLogin"; this.downloadfilename = ""; this.headers.Referer = "https://api.bilibili.com/"; this.headers.Cookie = Config.cookies.bilibili; } async RESOURCES(iddata) { Config.app.EmojiReply && await this.e.bot.setMsgReaction(this.e.contact, this.e.messageId, Config.app.EmojiReplyID, true); Config.bilibili.tip && await this.e.reply("检测到B站链接,开始解析"); switch (this.Type) { case "one_video": { const infoData = await this.amagi.getBilibiliData("单个视频作品数据", { bvid: iddata.bvid, typeMode: "strict" }); const playUrlData = await this.amagi.getBilibiliData("单个视频下载信息数据", { avid: infoData.data.data.aid, cid: iddata.p ? infoData.data.data.pages[iddata.p - 1]?.cid ?? infoData.data.data.cid : infoData.data.data.cid, typeMode: "strict" }); this.islogin = (await checkCk()).Status === "isLogin"; const { owner, pic, title, stat, desc } = infoData.data.data; const { name } = owner; const { coin, like, share, view, favorite, danmaku } = stat; this.downloadfilename = title.substring(0, 50).replace(/[\\/:*?"<>|\r\n\s]/g, " "); const nockData = await new Networks({ url: bilibiliApiUrls.视频流信息({ avid: infoData.data.data.aid, cid: iddata.p ? infoData.data.data.pages[iddata.p - 1]?.cid ?? infoData.data.data.cid : infoData.data.data.cid }) + "&platform=html5", headers: this.headers }).getData(); const replyContent = []; if (Config.bilibili.displayContent && Config.bilibili.displayContent.length > 0) { const contentMap = { cover: segment.image(pic), title: segment.text(` 📺 标题: ${title} `), author: segment.text(` 👤 作者: ${name} `), stats: segment.text(this.formatVideoStats(view, danmaku, like, coin, share, favorite)), desc: segment.text(` 📝 简介: ${desc}`) }; const fixedOrder = ["cover", "title", "author", "stats", "desc"]; fixedOrder.forEach((item) => { if (Config.bilibili.displayContent.includes(item) && contentMap[item]) { replyContent.push(contentMap[item]); } }); if (replyContent.length > 0) { await this.e.reply(replyContent); } } let videoSize = ""; let correctList; if (this.islogin) { const simplify = playUrlData.data.data.dash.video.filter((item, index, self) => { return self.findIndex((t) => { return t.id === item.id; }) === index; }); playUrlData.data.data.dash.video = simplify; correctList = await bilibiliProcessVideos({ accept_description: playUrlData.data.data.accept_description, bvid: infoData.data.data.bvid }, simplify, playUrlData.data.data.dash.audio[0].base_url); playUrlData.data.data.dash.video = correctList.videoList; playUrlData.data.data.accept_description = correctList.accept_description; videoSize = await getvideosize(correctList.videoList[0].base_url, playUrlData.data.data.dash.audio[0].base_url, infoData.data.data.bvid); } else { videoSize = (playUrlData.data.data.durl[0].size / (1024 * 1024)).toFixed(2); } if (Config.bilibili.comment) { const commentsData = await this.amagi.getBilibiliData("评论数据", { number: Config.bilibili.numcomment, type: 1, oid: infoData.data.data.aid.toString(), typeMode: "strict" }); const commentsdata = bilibiliComments(commentsData.data); if (!commentsdata?.length) { await this.e.reply("这个视频没有评论 ~"); } else { img$1 = await Render("bilibili/comment", { Type: "视频", CommentsData: commentsdata, CommentLength: Config.bilibili.realCommentCount ? Count(infoData.data.data.stat.reply) : String(commentsdata.length), share_url: "https://b23.tv/" + infoData.data.data.bvid, Clarity: Config.bilibili.videopriority === true ? nockData.data.data.accept_description[0] : '"流畅 360P"', VideoSize: Config.bilibili.videopriority === true ? (nockData.data.data.durl[0].size / (1024 * 1024)).toFixed(2) : videoSize, ImageLength: 0, shareurl: "https://b23.tv/" + infoData.data.data.bvid }); await this.e.reply(img$1); } } if (Config.upload.usefilelimit && Number(videoSize) > Number(Config.upload.filelimit) && !Config.upload.compress) { await this.e.reply(`设定的最大上传大小为 ${Config.upload.filelimit}MB 当前解析到的视频大小为 ${Number(videoSize)}MB 视频太大了,还是去B站看吧~`, { reply: true }); } else { await this.getvideo( Config.bilibili.videopriority === true ? { playUrlData: nockData.data } : { infoData: infoData.data, playUrlData: playUrlData.data } ); } break; } case "bangumi_video_info": { const videoInfo = await this.amagi.getBilibiliData("番剧基本信息数据", { [iddata.isEpid ? "ep_id" : "season_id"]: iddata.realid, typeMode: "strict" }); this.islogin = (await checkCk()).Status === "isLogin"; this.isVIP = (await checkCk()).isVIP; const barray = []; const msg = []; for (let i = 0; i < videoInfo.data.result.episodes.length; i++) { const totalEpisodes = videoInfo.data.result.episodes.length; const long_title = videoInfo.data.result.episodes[i].long_title; const badge = videoInfo.data.result.episodes[i].badge; const short_link = videoInfo.data.result.episodes[i].short_link; barray.push({ id: i + 1, totalEpisodes, long_title, badge: badge === "" ? "暂无" : badge, short_link }); msg.push([ ` > ## 第${i + 1}集`, ` > 标题: ${long_title}`, ` > 类型: ${badge !== "预告" ? "正片" : "预告"}`, ` > 🔒 播放要求: ${badge === "预告" || badge === "" ? "暂无" : badge}`, this.botadapter !== "QQBot" ? ` > 🔗 分享链接: [🔗点击查看](${short_link})\r\r` : "" ]); } img$1 = await Render("bilibili/bangumi", { saveId: "bangumi", bangumiData: barray, title: videoInfo.data.result.title }); await this.e.reply([...img$1, segment.text("请在120秒内输入 第?集 选择集数")]); await this.e.reply(segment.text("请在120秒内输入 第?集 选择集数")); const context = await karin.ctx(this.e, { reply: true }); const regex = /第([一二三四五六七八九十百千万0-9]+)集/.exec(context.msg); let Episode; if (regex && regex[1]) { Episode = regex[1]; if (/^[一二三四五六七八九十百千万]+$/.test(Episode)) { Episode = Common.chineseToArabic(Episode).toString(); } this.downloadfilename = videoInfo.data.result.episodes[Number(Episode) - 1].share_copy.substring(0, 50).replace(/[\\/:*?"<>|\r\n\s]/g, " "); this.e.reply(`收到请求,第${Episode}集 ${this.downloadfilename} 正在下载中`); } else { logger.debug(Episode); this.e.reply("匹配内容失败,请重新发送链接再次解析"); return true; } const bangumidataBASEURL = bilibiliApiUrls.番剧视频流信息({ cid: videoInfo.data.result.episodes[Number(Episode) - 1].cid, ep_id: videoInfo.data.result.episodes[Number(Episode) - 1].ep_id.toString() }); const Params = await genParams(bangumidataBASEURL); if (!this.islogin) await this.e.reply("B站ck未配置或已失效,无法获取视频流,可尝试【#B站登录】以配置新ck"); const playUrlData = await new Networks({ url: bangumidataBASEURL + Params, headers: this.headers }).getData(); if (videoInfo.data.result.episodes[Number(Episode) - 1].badge === "会员" && !this.isVIP) { logger.warn("该CK不是大会员,无法获取视频流"); return true; } if (Config.bilibili.videoQuality === 0) { const simplify = playUrlData.result.dash.video.filter((item, index, self) => { return self.findIndex((t) => { return t.id === item.id; }) === index; }); playUrlData.result.dash.video = simplify; const correctList = await bilibiliProcessVideos({ accept_description: playUrlData.result.accept_description, bvid: videoInfo.data.result.season_id.toString() }, simplify, playUrlData.result.dash.a