utils.js
1 /** 2 * Bilibili shared helpers: WBI signing, authenticated fetch, nav data, UID resolution. 3 */ 4 import https from 'node:https'; 5 import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors'; 6 /** 7 * Resolve Bilibili short URL / short code to BV ID. 8 * Supports: BV1MV9NBtENN, XYzsqGa, b23.tv/XYzsqGa, https://b23.tv/XYzsqGa 9 */ 10 export function resolveBvid(input) { 11 const trimmed = String(input).trim(); 12 if (/^BV[A-Za-z0-9]+$/i.test(trimmed)) { 13 return Promise.resolve(trimmed); 14 } 15 const shortCode = trimmed.replace(/^https?:\/\//, '').replace(/^(www\.)?b23\.tv\//, ''); 16 const url = 'https://b23.tv/' + shortCode; 17 return new Promise((resolve, reject) => { 18 const req = https.get(url, (res) => { 19 const location = res.headers.location; 20 if (location) { 21 const match = location.match(/\/video\/(BV[A-Za-z0-9]+)/); 22 if (match) { 23 res.resume(); 24 resolve(match[1]); 25 return; 26 } 27 } 28 res.resume(); 29 reject(new Error(`Cannot resolve BV ID from short URL: ${trimmed}`)); 30 }); 31 req.on('error', reject); 32 req.setTimeout(5000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); }); 33 }); 34 } 35 const MIXIN_KEY_ENC_TAB = [ 36 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 37 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 38 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 39 36, 20, 34, 44, 52, 40 ]; 41 export function stripHtml(s) { 42 return s.replace(/<[^>]+>/g, '').replace(/&[a-z]+;/gi, ' ').trim(); 43 } 44 export function payloadData(payload) { 45 return payload?.data ?? payload; 46 } 47 async function getNavData(page) { 48 return page.evaluate(` 49 async () => { 50 const res = await fetch('https://api.bilibili.com/x/web-interface/nav', { credentials: 'include' }); 51 return await res.json(); 52 } 53 `); 54 } 55 async function getWbiKeys(page) { 56 const nav = await getNavData(page); 57 const wbiImg = nav?.data?.wbi_img ?? {}; 58 const imgUrl = wbiImg.img_url ?? ''; 59 const subUrl = wbiImg.sub_url ?? ''; 60 const imgKey = imgUrl.split('/').pop()?.split('.')[0] ?? ''; 61 const subKey = subUrl.split('/').pop()?.split('.')[0] ?? ''; 62 return { imgKey, subKey }; 63 } 64 function getMixinKey(imgKey, subKey) { 65 const raw = imgKey + subKey; 66 return MIXIN_KEY_ENC_TAB.map(i => raw[i] || '').join('').slice(0, 32); 67 } 68 async function md5(text) { 69 const { createHash } = await import('node:crypto'); 70 return createHash('md5').update(text).digest('hex'); 71 } 72 export async function wbiSign(page, params) { 73 const { imgKey, subKey } = await getWbiKeys(page); 74 const mixinKey = getMixinKey(imgKey, subKey); 75 const wts = Math.floor(Date.now() / 1000); 76 const sorted = {}; 77 const allParams = { ...params, wts: String(wts) }; 78 for (const key of Object.keys(allParams).sort()) { 79 sorted[key] = String(allParams[key]).replace(/[!'()*]/g, ''); 80 } 81 // Bilibili WBI verification expects %20 for spaces, not + (URLSearchParams default). 82 // Using + causes signature mismatch → CORS-blocked error response → TypeError: Failed to fetch. 83 const query = new URLSearchParams(sorted).toString().replace(/\+/g, '%20'); 84 const wRid = await md5(query + mixinKey); 85 sorted.w_rid = wRid; 86 return sorted; 87 } 88 export async function apiGet(page, path, opts = {}) { 89 const baseUrl = 'https://api.bilibili.com'; 90 let params = opts.params ?? {}; 91 if (opts.signed) { 92 params = await wbiSign(page, params); 93 } 94 const qs = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))).toString().replace(/\+/g, '%20'); 95 const url = `${baseUrl}${path}?${qs}`; 96 return fetchJson(page, url); 97 } 98 export async function fetchJson(page, url) { 99 const urlJs = JSON.stringify(url); 100 return page.evaluate(` 101 async () => { 102 const res = await fetch(${urlJs}, { credentials: "include" }); 103 return await res.json(); 104 } 105 `); 106 } 107 export async function getSelfUid(page) { 108 const nav = await getNavData(page); 109 const mid = nav?.data?.mid; 110 if (!mid) 111 throw new AuthRequiredError('bilibili.com'); 112 return String(mid); 113 } 114 export async function resolveUid(page, input) { 115 if (/^\d+$/.test(input)) 116 return input; 117 // Search for user by name 118 const payload = await apiGet(page, '/x/web-interface/wbi/search/type', { 119 params: { search_type: 'bili_user', keyword: input }, 120 signed: true, 121 }); 122 const results = payload?.data?.result ?? []; 123 if (results.length > 0) 124 return String(results[0].mid); 125 throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.'); 126 }