/ clis / bilibili / utils.js
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  }