From de92c8bcd47d59ecc4586d20dc9024642deebaca Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 16 May 2026 22:29:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(bilibili):=20wbi-sign=20search=20params=20?= =?UTF-8?q?=E2=80=94=20legacy=20/search/type=20now=20anti-bot=20blocked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #64. Bilibili moved the unsigned /x/web-interface/search/type endpoint behind their anti-bot wall; it now returns an HTML error page (出错啦!) even with buvid3+buvid4 cookies, causing `play -b` to report "No results found". Switch search() to /x/web-interface/wbi/search/type with proper wbi signing: fetch img_key/sub_key from /nav, derive the mixin key via the standard permutation, and sign each request with wts + w_rid (md5). Keys are cached for 6h since they rotate ~daily. Other endpoints (view, playurl, popular, top/rcmd) still work unsigned and are left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/music/bilibili.ts | 71 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/src/music/bilibili.ts b/src/music/bilibili.ts index d2bd64a..7a692d2 100644 --- a/src/music/bilibili.ts +++ b/src/music/bilibili.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import axios, { type AxiosInstance } from "axios"; import type { MusicProvider, @@ -15,6 +16,16 @@ const BILIBILI_HEADERS = { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }; +// Permutation used by B站 to derive the wbi mixin key from img_key+sub_key. +const WBI_MIXIN_KEY_ENC_TAB = [ + 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, +]; + +const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h + export class BiliBiliProvider implements MusicProvider { readonly platform = "bilibili" as const; private api: AxiosInstance; @@ -24,6 +35,8 @@ export class BiliBiliProvider implements MusicProvider { private cidCache = new Map(); private buvidCookie = ""; // anonymous session cookie (buvid3) for anti-412 private buvidInitialized = false; + private wbiMixinKey = ""; + private wbiKeyFetchedAt = 0; constructor() { this.api = axios.create({ @@ -62,6 +75,50 @@ export class BiliBiliProvider implements MusicProvider { return combined ? { Cookie: combined } : {}; } + /** + * Fetch wbi img_key/sub_key from /x/web-interface/nav and derive the + * mixin key used to sign search params. Required since B站 moved the + * search endpoint behind wbi signing — unsigned /search/type now + * returns an anti-bot HTML page. + */ + private async ensureWbiKeys(): Promise { + if (this.wbiMixinKey && Date.now() - this.wbiKeyFetchedAt < WBI_KEY_TTL_MS) { + return; + } + const res = await this.api.get("/x/web-interface/nav", { + headers: this.cookieHeaders, + validateStatus: () => true, // nav returns -101 when not logged in but still includes wbi_img + }); + const wbi = res.data?.data?.wbi_img; + const imgUrl: string = wbi?.img_url ?? ""; + const subUrl: string = wbi?.sub_url ?? ""; + const imgKey = imgUrl.split("/").pop()?.split(".")[0] ?? ""; + const subKey = subUrl.split("/").pop()?.split(".")[0] ?? ""; + if (!imgKey || !subKey) { + throw new Error("Bilibili wbi keys unavailable"); + } + const raw = imgKey + subKey; + this.wbiMixinKey = WBI_MIXIN_KEY_ENC_TAB.map((i) => raw[i] ?? "") + .join("") + .slice(0, 32); + this.wbiKeyFetchedAt = Date.now(); + } + + /** Sign params for wbi-protected endpoints. Returns a new params object including wts and w_rid. */ + private signWbi(params: Record): Record { + const withTs: Record = {}; + for (const [k, v] of Object.entries(params)) withTs[k] = String(v); + withTs.wts = String(Math.floor(Date.now() / 1000)); + const sorted = Object.keys(withTs) + .sort() + .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(withTs[k])}`) + .join("&"); + withTs.w_rid = createHash("md5") + .update(sorted + this.wbiMixinKey) + .digest("hex"); + return withTs; + } + setQuality(quality: string): void { this.quality = quality; } @@ -91,12 +148,14 @@ export class BiliBiliProvider implements MusicProvider { async search(query: string, limit = 20): Promise { await this.ensureBuvidCookie(); - const res = await this.api.get("/x/web-interface/search/type", { - params: { - search_type: "video", - keyword: query, - page_size: limit, - }, + await this.ensureWbiKeys(); + const signed = this.signWbi({ + search_type: "video", + keyword: query, + page_size: limit, + }); + const res = await this.api.get("/x/web-interface/wbi/search/type", { + params: signed, headers: this.cookieHeaders, });