UNPKG

koishi-plugin-bilibili-notify

Version:
627 lines (626 loc) 25 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); /* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable @typescript-eslint/no-namespace */ /* eslint-disable @typescript-eslint/no-explicit-any */ const koishi_1 = require("koishi"); const md5_1 = __importDefault(require("md5")); const crypto_1 = __importDefault(require("crypto")); const axios_1 = __importDefault(require("axios")); const tough_cookie_1 = require("tough-cookie"); const axios_cookiejar_support_1 = require("axios-cookiejar-support"); const jsdom_1 = require("jsdom"); const luxon_1 = require("luxon"); const mixinKeyEncTab = [ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52 ]; // 在getUserInfo中检测到番剧出差的UID时,要传回的数据: const bangumiTripData = { "code": 0, "data": { "live_room": { "roomid": 931774 } } }; const GET_USER_SPACE_DYNAMIC_LIST = 'https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space'; const GET_ALL_DYNAMIC_LIST = 'https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all'; const HAS_NEW_DYNAMIC = 'https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all/update'; const GET_COOKIES_INFO = 'https://passport.bilibili.com/x/passport-login/web/cookie/info'; const GET_USER_INFO = 'https://api.bilibili.com/x/space/wbi/acc/info'; const GET_MYSELF_INFO = 'https://api.bilibili.com/x/member/web/account'; const GET_LOGIN_QRCODE = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate'; const GET_LOGIN_STATUS = 'https://passport.bilibili.com/x/passport-login/web/qrcode/poll'; const GET_LIVE_ROOM_INFO = 'https://api.live.bilibili.com/room/v1/Room/get_info'; const GET_MASTER_INFO = 'https://api.live.bilibili.com/live_user/v1/Master/info'; const GET_TIME_NOW = 'https://api.bilibili.com/x/report/click/now'; const GET_SERVER_UTC_TIME = 'https://interface.bilibili.com/serverdate.js'; // 操作 const MODIFY_RELATION = 'https://api.bilibili.com/x/relation/modify'; const CREATE_GROUP = 'https://api.bilibili.com/x/relation/tag/create'; const MODIFY_GROUP_MEMBER = 'https://api.bilibili.com/x/relation/tags/addUsers'; const GET_ALL_GROUP = 'https://api.bilibili.com/x/relation/tags'; const COPY_USER_TO_GROUP = 'https://api.bilibili.com/x/relation/tags/copyUsers'; const GET_RELATION_GROUP_DETAIL = 'https://api.bilibili.com/x/relation/tag'; // 直播 const GET_LIVE_ROOM_INFO_STREAM_KEY = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo'; class BiliAPI extends koishi_1.Service { static inject = ['database', 'notifier']; jar; client; apiConfig; loginData; loginNotifier; refreshCookieTimer; loginInfoIsLoaded = false; constructor(ctx, config) { super(ctx, 'ba'); this.apiConfig = config; } start() { // 创建新的http客户端(axios) this.createNewClient(); // 从数据库加载cookies this.loadCookiesFromDatabase(); } // WBI签名 // 对 imgKey 和 subKey 进行字符顺序打乱编码 getMixinKey = (orig) => mixinKeyEncTab .map((n) => orig[n]) .join("") .slice(0, 32); // 为请求参数进行 wbi 签名 encWbi(params, img_key, sub_key) { const mixin_key = this.getMixinKey(img_key + sub_key), curr_time = Math.round(Date.now() / 1000), chr_filter = /[!'()*]/g; Object.assign(params, { wts: curr_time }); // 添加 wts 字段 // 按照 key 重排参数 const query = Object.keys(params) .sort() .map((key) => { // 过滤 value 中的 "!'()*" 字符 const value = params[key].toString().replace(chr_filter, ""); return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; }) .join("&"); const wbi_sign = (0, md5_1.default)(query + mixin_key); // 计算 w_rid return query + "&w_rid=" + wbi_sign; } async getWbi(params) { const web_keys = await this.getWbiKeys(); const img_key = web_keys.img_key, sub_key = web_keys.sub_key; const query = this.encWbi(params, img_key, sub_key); return query; } encrypt(text) { const iv = crypto_1.default.randomBytes(16); const cipher = crypto_1.default.createCipheriv('aes-256-cbc', Buffer.from(this.apiConfig.key), iv); const encrypted = Buffer.concat([cipher.update(text), cipher.final()]); return iv.toString('hex') + ':' + encrypted.toString('hex'); } decrypt(text) { const textParts = text.split(':'); const iv = Buffer.from(textParts.shift(), 'hex'); const encryptedText = Buffer.from(textParts.join(':'), 'hex'); const decipher = crypto_1.default.createDecipheriv('aes-256-cbc', Buffer.from(this.apiConfig.key), iv); const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]); return decrypted.toString(); } // BA API async getLiveRoomInfoStreamKey(roomId) { try { // 获取直播间信息流密钥 const { data } = await this.client.get(`${GET_LIVE_ROOM_INFO_STREAM_KEY}?id=${roomId}`); // 返回data return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getServerUTCTime() { try { const { data } = await this.client.get(GET_SERVER_UTC_TIME); const regex = /Date\.UTC\((.*?)\)/; const match = data.match(regex); if (match) { const timestamp = new Function(`return Date.UTC(${match[1]})`)(); return timestamp / 1000; } else { throw new Error('解析服务器时间失败!'); } } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getTimeNow() { try { const { data } = await this.client.get(GET_TIME_NOW); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getAllGroup() { try { const { data } = await this.client.get(GET_ALL_GROUP); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async removeUserFromGroup(mid) { // 获取csrf const csrf = this.getCSRF(); try { // 将用户mid添加到groupId const { data } = await this.client.post(MODIFY_GROUP_MEMBER, { fids: mid, tagids: 0, csrf }, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', } }); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async copyUserToGroup(mid, groupId) { // 获取csrf const csrf = this.getCSRF(); try { // 将用户mid添加到groupId const { data } = await this.client.post(COPY_USER_TO_GROUP, { fids: mid, tagids: groupId, csrf }, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', } }); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getUserSpaceDynamic(mid) { try { const { data } = await this.client.get(`${GET_USER_SPACE_DYNAMIC_LIST}?host_mid=${mid}`); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async createGroup(tag) { try { const { data } = await this.client.post(CREATE_GROUP, { tag, csrf: this.getCSRF() }, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', } }); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getAllDynamic(updateBaseline) { let url = GET_ALL_DYNAMIC_LIST; updateBaseline && (url += `?update_baseline=${updateBaseline}`); try { const { data } = await this.client.get(url); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async hasNewDynamic(updateBaseline) { try { const { data } = await this.client.get(`${HAS_NEW_DYNAMIC}?update_baseline=${updateBaseline}`); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async follow(fid) { try { const { data } = await this.client.post(MODIFY_RELATION, { fid, act: 1, re_src: 11, csrf: this.getCSRF() }, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', } }); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getRelationGroupDetail(tagid) { try { const { data } = await this.client.get(`${GET_RELATION_GROUP_DETAIL}?tagid=${tagid}`); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } // Check if Token need refresh async getCookieInfo(refreshToken) { try { const { data } = await this.client.get(`${GET_COOKIES_INFO}?csrf=${refreshToken}`); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getUserInfo(mid) { //如果为番剧出差的UID,则不从远程接口拉取数据,直接传回一段精简过的有效数据 if (mid === "11783021") { console.log("检测到番剧出差UID,跳过远程用户接口访问"); return bangumiTripData; } try { const wbi = await this.getWbi({ mid }); const { data } = await this.client.get(`${GET_USER_INFO}?${wbi}`); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } // 获取最新的 img_key 和 sub_key async getWbiKeys() { const { data } = await this.client.get('https://api.bilibili.com/x/web-interface/nav'); const { data: { wbi_img: { img_url, sub_url }, }, } = data; return { img_key: img_url.slice(img_url.lastIndexOf('/') + 1, img_url.lastIndexOf('.')), sub_key: sub_url.slice(sub_url.lastIndexOf('/') + 1, sub_url.lastIndexOf('.')) }; } async getMyselfInfo() { try { const { data } = await this.client.get(GET_MYSELF_INFO); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getLoginQRCode() { try { const { data } = await this.client.get(GET_LOGIN_QRCODE); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getLoginStatus(qrcodeKey) { try { const { data } = await this.client.get(`${GET_LOGIN_STATUS}?qrcode_key=${qrcodeKey}`); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getLiveRoomInfo(roomId) { try { const { data } = await this.client.get(`${GET_LIVE_ROOM_INFO}?room_id=${roomId}`); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } async getMasterInfo(mid) { try { const { data } = await this.client.get(`${GET_MASTER_INFO}?uid=${mid}`); return data; } catch (e) { throw new Error('网络异常,本次请求失败!'); } } disposeNotifier() { if (this.loginNotifier) this.loginNotifier.dispose(); } getRandomUserAgent() { const userAgents = [ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36' ]; const index = Math.floor(Math.random() * userAgents.length); return userAgents[index]; } createNewClient() { this.jar = new tough_cookie_1.CookieJar(); this.client = (0, axios_cookiejar_support_1.wrapper)(axios_1.default.create({ jar: this.jar, headers: { 'Content-Type': 'application/json', 'User-Agent': this.apiConfig.userAgent !== 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36' ? this.apiConfig.userAgent : this.getRandomUserAgent(), 'Origin': 'https://www.bilibili.com', 'Referer': 'https://www.bilibili.com/' } })); } getTimeOfUTC8() { return Math.floor(luxon_1.DateTime.now().setZone('UTC+8').toSeconds()); } getCookies() { const cookies = JSON.stringify(this.jar.serializeSync().cookies); return cookies; } getLoginInfoIsLoaded() { return this.loginInfoIsLoaded; } async getLoginInfoFromDB() { // 读取数据库获取cookies const data = (await this.ctx.database.get('loginBili', 1))[0]; // 判断是否登录 if (data === undefined) { // 没有数据则直接返回 // 未登录,在控制台提示 this.loginNotifier = this.ctx.notifier.create({ type: 'warning', content: '您尚未登录,将无法使用插件提供的指令' }); // 返回空值 return { cookies: null, refresh_token: null }; } // 尝试解密 try { // 解密数据 const decryptedCookies = this.decrypt(data.bili_cookies); // 解密refresh_token const decryptedRefreshToken = this.decrypt(data.bili_refresh_token); // 解析从数据库读到的cookies const cookies = JSON.parse(decryptedCookies); // 返回值 return { cookies, refresh_token: decryptedRefreshToken }; } catch (e) { // 数据库被篡改,在控制台提示 this.loginNotifier = this.ctx.notifier.create({ type: 'warning', content: '数据库被篡改,请重新登录' }); // 解密或解析失败,删除数据库登录信息 await this.ctx.database.remove('loginBili', [1]); // 返回空值 return { cookies: null, refresh_token: null }; } } getCSRF() { // 获取csrf return this.jar.serializeSync().cookies.find(cookie => cookie.key === 'bili_jct').value; } async loadCookiesFromDatabase() { // Get login info from db const { cookies, refresh_token } = await this.getLoginInfoFromDB(); // 判断是否有值 if (!cookies || !refresh_token) { // Login info is loaded this.loginInfoIsLoaded = true; return; } // 定义CSRF Token let csrf, expires, domain, path, secure, httpOnly, sameSite; cookies.forEach(cookieData => { // 获取key为bili_jct的值 if (cookieData.key === 'bili_jct') { csrf = cookieData.value; expires = new Date(cookieData.expires); domain = cookieData.domain; path = cookieData.path; secure = cookieData.secure; httpOnly = cookieData.httpOnly; sameSite = cookieData.sameSite; } // 创建一个完整的 Cookie 实例 const cookie = new tough_cookie_1.Cookie({ key: cookieData.key, value: cookieData.value, expires: new Date(cookieData.expires), domain: cookieData.domain, path: cookieData.path, secure: cookieData.secure, httpOnly: cookieData.httpOnly, sameSite: cookieData.sameSite }); this.jar.setCookieSync(cookie, `http${cookie.secure ? 's' : ''}://${cookie.domain}${cookie.path}`, {}); }); // 对于某些 IP 地址,需要在 Cookie 中提供任意非空的 buvid3 字段 const buvid3Cookie = new tough_cookie_1.Cookie({ key: 'buvid3', value: 'some_non_empty_value', // 设置任意非空值 expires, // 设置过期时间 domain, // 设置域名 path, // 设置路径 secure, // 设置是否为安全 cookie httpOnly, // 设置是否为 HttpOnly cookie sameSite // 设置 SameSite 属性 }); this.jar.setCookieSync(buvid3Cookie, `http${buvid3Cookie.secure ? 's' : ''}://${buvid3Cookie.domain}${buvid3Cookie.path}`, {}); // Login info is loaded this.loginInfoIsLoaded = true; // restart plugin check this.checkIfTokenNeedRefresh(refresh_token, csrf); // enable refresh cookies detect this.enableRefreshCookiesDetect(); } enableRefreshCookiesDetect() { // 判断之前是否启动检测 if (this.refreshCookieTimer) this.refreshCookieTimer(); // Open scheduled tasks and check if token need refresh this.refreshCookieTimer = this.ctx.setInterval(async () => { // 从数据库获取登录信息 const { cookies, refresh_token } = await this.getLoginInfoFromDB(); // 判断是否有值 if (!cookies || !refresh_token) return; // 获取csrf const csrf = cookies.find(cookie => { // 判断key是否为bili_jct if (cookie.key === 'bili_jct') return true; }).value; // 检查是否需要更新 this.checkIfTokenNeedRefresh(refresh_token, csrf); }, 3600000); } async checkIfTokenNeedRefresh(refreshToken, csrf, times = 3) { // 定义方法 const notifyAndError = (info) => { // 设置控制台通知 this.loginNotifier = this.ctx.notifier.create({ type: 'warning', content: info }); // 重置为未登录状态 this.createNewClient(); // 关闭定时器 this.refreshCookieTimer(); // 抛出错误 throw new Error(info); }; // 尝试获取Cookieinfo try { const { data } = await this.getCookieInfo(refreshToken); // 不需要刷新,直接返回 if (!data.refresh) return; } catch (e) { // 发送三次仍网络错误则直接刷新cookie if (times >= 1) { // 等待3秒再次尝试 this.ctx.setTimeout(() => { this.checkIfTokenNeedRefresh(refreshToken, csrf, times - 1); }, 3000); } // 如果请求失败,有可能是404,直接刷新cookie } // 定义Key const publicKey = await crypto_1.default.subtle.importKey("jwk", { kty: "RSA", n: "y4HdjgJHBlbaBN04VERG4qNBIFHP6a3GozCl75AihQloSWCXC5HDNgyinEnhaQ_4-gaMud_GF50elYXLlCToR9se9Z8z433U3KjM-3Yx7ptKkmQNAMggQwAVKgq3zYAoidNEWuxpkY_mAitTSRLnsJW-NCTa0bqBFF6Wm1MxgfE", e: "AQAB", }, { name: "RSA-OAEP", hash: "SHA-256" }, true, ["encrypt"]); // 定义获取CorrespondPath方法 async function getCorrespondPath(timestamp) { const data = new TextEncoder().encode(`refresh_${timestamp}`); const encrypted = new Uint8Array(await crypto_1.default.subtle.encrypt({ name: "RSA-OAEP" }, publicKey, data)); return encrypted.reduce((str, c) => str + c.toString(16).padStart(2, "0"), ""); } // 获取CorrespondPath const ts = Date.now(); const correspondPath = await getCorrespondPath(ts); // 获取refresh_csrf const { data: refreshCsrfHtml } = await this.client.get(`https://www.bilibili.com/correspond/1/${correspondPath}`); // 创建一个虚拟的DOM元素 const { document } = new jsdom_1.JSDOM(refreshCsrfHtml).window; // 提取标签name为1-name的内容 const targetElement = document.getElementById('1-name'); const refresh_csrf = targetElement ? targetElement.textContent : null; // 发送刷新请求 const { data: refreshData } = await this.client.post('https://passport.bilibili.com/x/passport-login/web/cookie/refresh', { csrf, refresh_csrf, source: 'main_web', refresh_token: refreshToken }, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', } }); // 检查是否有其他问题 switch (refreshData.code) { // 账号未登录 case -101: return this.createNewClient(); case -111: { await this.ctx.database.remove('loginBili', [1]); notifyAndError('csrf 校验错误,请重新登录'); break; } case 86095: { await this.ctx.database.remove('loginBili', [1]); notifyAndError('refresh_csrf 错误或 refresh_token 与 cookie 不匹配,请重新登录'); } } // 更新 新的cookies和refresh_token const encryptedCookies = this.encrypt(this.getCookies()); const encryptedRefreshToken = this.encrypt(refreshData.data.refresh_token); await this.ctx.database.upsert('loginBili', [{ id: 1, bili_cookies: encryptedCookies, bili_refresh_token: encryptedRefreshToken }]); // Get new csrf from cookies const newCsrf = this.jar.serializeSync().cookies.find(cookie => { if (cookie.key === 'bili_jct') return true; }).value; // Accept update const { data: aceeptData } = await this.client.post('https://passport.bilibili.com/x/passport-login/web/confirm/refresh', { csrf: newCsrf, refresh_token: refreshToken }, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', } }); // 检查是否有其他问题 switch (aceeptData.code) { case -111: { await this.ctx.database.remove('loginBili', [1]); notifyAndError('csrf 校验失败,请重新登录'); break; } case -400: throw new Error('请求错误'); } // 没有问题,cookies已更新完成 } } (function (BiliAPI) { BiliAPI.Config = koishi_1.Schema.object({ userAgent: koishi_1.Schema.string(), key: koishi_1.Schema.string() .pattern(/^[0-9a-f]{32}$/) .required() }); })(BiliAPI || (BiliAPI = {})); exports.default = BiliAPI;