Back to notes
Tech shareChrome ExtensionJavaScriptBilibiliCDN

在 Chrome 擴充的 MAIN world 用 hook 即時改寫 Bilibili 串流 CDN

B 站的播放網址會從 SSR 的 __playinfo__、playurl API、分段請求和直播 API 進來。這篇用 bilibili-cdn-switcher 的程式碼說明每條路怎麼攔、改了什麼,以及 Response 只能讀一次、XHR responseText 唯讀、測速要繞過自己的 hook 這些坑。

Updated 10 min13 viewsArticle language

B 站把影片網址藏在好幾個地方。想換 CDN 節點,每一個地方都得搶在播放器讀到之前先動手。

我做的擴充「嗶速」只做一件事:B 站給完影片網址之後,把裡面的 CDN 節點換成你選的那個。為什麼要換、換哪個,上一篇講過了。

這篇講怎麼換。

程式碼都摘自 bilibili-cdn-switcher v2.6.0 的 src/main-hook.js。整支 1777 行,大部分是測速、自動切換跟除錯面板,真正負責「換」的是 installHooks 和它前面那幾個改寫函式。

為什麼一定要擠進 MAIN world

B 站播放器拿網址、抓影片,用的都是頁面自己的 window.fetch、XMLHttpRequest 和 window.__playinfo__。

一般的 content script 跑在 ISOLATED world:看得到同一份 DOM,但 window 是另一份。你在那邊把 fetch 換掉,頁面根本不會用到。所以 hook 一定要注入頁面的 MAIN world,而且要在 document_start 就跑,理論上比 B 站任何一行 script 都早(實際上不一定,最後一節會講怎麼驗)。兩個 world 的差別另外寫了一篇:Chrome 擴充的 MAIN world 和 ISOLATED world。

"content_scripts": [
  { "matches": ["*://*.bilibili.com/*"], "js": ["main-hook.js"],
    "run_at": "document_start", "world": "MAIN", "all_frames": true },
  { "matches": ["*://*.bilibili.com/*"], "js": ["bridge.js"],
    "run_at": "document_start", "world": "ISOLATED", "all_frames": true }
]

代價是 MAIN world 沒有 chrome.* API,讀不到使用者的設定。

那叫 ISOLATED world 的 bridge.js 讀完 chrome.storage 再傳過來? 來不及。chrome.storage 是非同步的,等它 callback 回來,B 站的 script 早就跑完了。

所以 bridge.js 平常就把設定同步一份到同源的 localStorage(設定一有變動就重寫),main-hook.js 在檔案開頭直接同步讀。它讀到的是 bridge 之前寫好的那份,不是這次載入才去拿的:

var cfg = readCfg(); // window.localStorage.getItem("__CDN_SWITCHER_CFG__")
function isActive(c) { return !!c.enabled && c.videoEnabled !== false && c.cdnHost !== "base"; } // "base" 是舊版的「原始(不覆寫)」選項,等同關閉
var ACTIVE = isActive(cfg); // 依「載入當下」設定決定是否安裝 hook

載入之後,bridge.js 每次發現設定變了,會再用 postMessage 把新設定推給 main-hook.js,main-hook.js 收到就更新 cfg。ACTIVE 是載入當下定死的,所以開關功能要整頁重載;換節點不用(後面會講為什麼,也會講哪些情況還是得重載)。

B 站的播放網址從哪幾條路進來

我把 B 站播放器的行為拆開看,網址會從四條路進來:

  1. 第一個分 P:網址直接寫在 HTML 裡(伺服器端渲染,SSR),由一段 inline script 塞進 window.__playinfo__。
  2. 換分 P、番劇、課程:播放器去打 playurl API。一般影片是 x/player/wbi/playurl,番劇是 pgc/player/web/v2/playurl,課程是 pugv/player/web/playurl。可能走 fetch 也可能走 XHR,兩個都要包。
  3. 分段請求本身:播放器拿到網址後,一段一段去 CDN 抓 .m4s。這是最後一關,網址長什麼樣,影片就從哪裡來。
  4. 直播:另一套 API、另一套網址規則,最後講。

每條路上都埋一個 hook:

播放網址怎麼進播放器,hook 埋在哪 · 在 Miro 開啟

改了什麼:只換 host,順序重排

不管網址從哪條路來,B 站給的 JSON 結構都差不多:dash.video、dash.audio 底下每一軌有一個 baseUrl 和一串 backupUrl(舊格式叫 durl,番劇、課程還多包一層 video_info)。

下面是一支一般影片的 window.__playinfo__,從 B 站頁面實際抓下來的。只留一軌影片、一軌音訊;網址的 query 很長,又帶著帳號和 IP 相關的參數,只留幾個看得出用途的:

{
  "code": 0,
  "data": {
    "quality": 32,
    "format": "flv480",
    "timelength": 2055637,
    "accept_quality": [32, 16],
    "dash": {
      "duration": 2056,
      "video": [
        {
          "id": 32,
          "baseUrl": "https://upos-hz-mirrorakam.akamaized.net/upgcxcode/31/21/62131/62131_da3-1-30032.m4s?e=...&deadline=1792937073&upsig=...&hdnts=...",
          "base_url": "(同 baseUrl)",
          "backupUrl": [
            "https://upos-sz-mirrorcosov.bilivideo.com/upgcxcode/31/21/62131/62131_da3-1-30032.m4s?e=...&deadline=1792937073&upsig=..."
          ],
          "backup_url": ["(同 backupUrl)"],
          "bandwidth": 155817,
          "mimeType": "video/mp4",
          "codecs": "avc1.64001E",
          "width": 512,
          "height": 384,
          "frameRate": "14.925",
          "SegmentBase": { "Initialization": "0-1002", "indexRange": "1003-5954" },
          "codecid": 7
        }
      ],
      "audio": [
        {
          "id": 30216,
          "baseUrl": "https://upos-hz-mirrorakam.akamaized.net/upgcxcode/31/21/62131/62131_da3-1-30216.m4s?e=...",
          "backupUrl": [
            "https://upos-sz-mirrorcosov.bilivideo.com/upgcxcode/31/21/62131/62131_da3-1-30216.m4s?e=..."
          ],
          "bandwidth": 68646,
          "mimeType": "audio/mp4",
          "codecs": "mp4a.40.2"
        }
      ],
      "dolby": { "type": 0, "audio": null },
      "flac": null
    }
  }
}

幾個跟擴充有關的地方:

  • 這支影片原本的 baseUrl 在 akamai(upos-hz-mirrorakam.akamaized.net),backupUrl 是 B 站自家的 upos-sz-mirrorcosov。擴充要改的就是這兩個欄位。
  • 同一個欄位給兩份:baseUrl/base_url、backupUrl/backup_url,值一模一樣(音訊軌也一樣,上面省略了)。所以 applyTrack 寫回時,原本有哪個欄位就改哪個。
  • video 是這支影片的所有畫質、所有編碼,每一軌都要改。id 是畫質代碼(32 是 480P、16 是 360P),同一個畫質還分 AVC、HEVC、AV1 各一軌,這支影片一共 6 軌影片、2 軌音訊。
  • dolby、flac(杜比和 Hi-Res 音訊)有內容的話,裡面也是同樣的軌道結構,一起改。

番劇、課程和舊格式外層長得不一樣,軌道本身是同一套(下面只是結構示意):

// 番劇(pgc/player/web/v2/playurl)、課程:dash 多包一層 video_info
{ "code": 0, "result": { "video_info": { "dash": { "video": [ ... ], "audio": [ ... ] } } } }

// 舊格式:沒有 dash,影音合在一起,網址在 durl 每一段的 url 和 backup_url
{ "code": 0, "data": { "durl": [ { "url": "https://...", "backup_url": ["https://..."] } ] } }

applyTrack 對每一軌做的事:

function applyTrack(track) {
  var base = asStr(track.baseUrl) || asStr(track.base_url) || asStr(track.url);
  var backups = track.backupUrl || track.backup_url || [];
  if (!Array.isArray(backups)) backups = [];
  backups = backups.map(asStr).filter(Boolean);
  // ...(選「備用」模式時反過來,備援排最前面)
  var swapped = base ? replaceHost(base, effHost()) : "";
  var ordered = distinct([swapped, base].concat(backups)).filter(Boolean);
  var top = ordered[0], rest = ordered.slice(1);
  track.baseUrl = top;   // 原本有 base_url/url 欄位的,也照原欄位寫回
  track.backupUrl = rest;
  return top;
}

擴充有兩種模式。平常是「節點模式」:effHost() 是你選的那個 host(例如 upos-sz-mirror08ct.bilivideo.com),程式裡用 isHostMode() 判斷。另一種是「備用模式」:不換 host,只把 B 站自己給的 backupUrl 排到最前面,也就是上面註解那行。下面講的都是節點模式。

  • baseUrl 換成你選的節點,path 和 query 原封不動。網址裡的簽名不用重算,換了 host 一樣回 206、CORS 也過(這是實測出來的,B 站沒有保證過)。
  • 原本 B 站給的 akamaized.net 跟備援節點,全部往後排進 backupUrl,一個都沒丟。

replaceHost 就是 new URL(url) 之後改 .host,沒什麼魔法。

那 backupUrl 留著,你選的節點掛掉時播放器會不會自己往後試? 會試,但沒用。後面的 ③ 會把所有不是目標節點的分段請求換回目標節點,播放器退到 akamaized.net 的請求一樣被換回那個掛掉的節點。

留著的真正用途是另一件事:改寫時會順手記下這些 host,節點掛了,由擴充的自動切換把「目標節點」換成其中一個,③ 再把請求導過去(細節在另一篇)。

① __playinfo__:搶在 B 站寫入的那一刻

第一個分 P 的網址根本沒有發 API,HTML 一解析到那段 inline script 就寫進 window.__playinfo__,播放器再去讀。

要攔一個「被賦值」的動作? 那就在它被賦值之前,先把它變成 setter:

if (window.__playinfo__) rewriteRoot(window.__playinfo__, "playinfo"); // 晚到了,就地改
else {
  var store;
  Object.defineProperty(window, "__playinfo__", {
    configurable: true,
    get: function () { return store; },
    set: function (v) { try { rewriteRoot(v, "playinfo"); } catch (e) {} store = v; }
  });
}

B 站那行 window.__playinfo__ = {...} 照常執行,只是它觸發的是我們的 set。物件先改完才存起來,播放器之後讀到的就是改過的版本。

setter 搶先:B 站寫入 __playinfo__ 的那一刻 · 在 Miro 開啟

configurable: true 要留著,不然之後別人想對同一個名字 defineProperty 會直接丟錯。rewriteRoot 包在 try 裡,改壞了就放原物件過去,B 站照常播,頂多沒換到節點(hook 出事不能拖垮頁面,整支檔案幾乎每個 hook 都這樣包)。

如果 hook 晚到了(__playinfo__ 已經有值),就直接改那個物件。但播放器可能已經讀走了,所以這只是補救。

② playurl:包 fetch,回應只能讀一次

換分 P、看番劇的時候,網址是從 playurl API 回來的。包 window.fetch,看到 playurl 就等回應回來,改完 JSON 再交給播放器:

var _fetch = window.fetch;
window.fetch = function (input, init) {
  var url = typeof input === "string" ? input : (input && input.url) || "";
  // ...
  if (PLAYURL_RE.test(url)) {
    return _fetch.call(this, input, init).then(function (resp) {
      return resp.clone().text().then(function (txt) {
        var out = tryRewriteText(txt, "playurl");
        if (out == null) return resp;
        return new Response(out, { status: resp.status, statusText: resp.statusText, headers: new Headers(resp.headers) });
      }).catch(function () { return resp; });
    });
  }
  // ...
  return _fetch.call(this, input, init);
};

Response 的 body 只能讀一次。直接 resp.text() 讀完,播放器那邊就拿到一個已經被讀過的空殼。所以先 clone() 再讀,讀完:

  • 有改到:用改過的文字 new Response(...) 一個新的,status、statusText、headers 照抄(url、redirected 這些唯讀欄位抄不過去,新的 Response 裡 url 是空字串)。
  • 沒改到、或任何一步出錯:把原本那個 resp 原封不動還回去。

tryRewriteText 會先用 indexOf 看看文字裡有沒有 dash、durl、backupUrl,沒有就不 JSON.parse,省得每個 playurl 回應都 parse 一次(XHR 那邊也共用這個函式)。

XHR:responseText 是唯讀的,怎麼改?

fetch 好歹可以換一個新的 Response 回去。XHR 沒這麼好命,responseText 跟 response 是 XMLHttpRequest.prototype 上只有 getter 的屬性,沒辦法直接賦值。

解法是在這一個 XHR 實例身上用 defineProperty 蓋一個同名的 own property。實例自己的屬性會擋在原型前面,播放器讀到的就是我們的 getter:

var textDesc = Object.getOwnPropertyDescriptor(proto, "responseText");
var respDesc = Object.getOwnPropertyDescriptor(proto, "response");
// proto.send 裡,確定是 playurl 時:
var compute = function () {
  if (computed) return; computed = true;
  if (self.responseType === "json") { var obj = respDesc.get.call(self); rewriteRoot(obj, "playurl"); cachedObj = obj; }
  else cachedText = tryRewriteText(textDesc.get.call(self), "playurl");
};
Object.defineProperty(self, "responseText", {
  configurable: true,
  get: function () { if (self.readyState === 4) { compute(); if (cachedText != null) return cachedText; } return textDesc.get.call(self); }
});
  • 真正的回應還是用原型上原本的 getter textDesc.get.call(self) 去拿,不然會自己呼叫自己。
  • 等 readyState === 4 才改,而且只算一次。沒下載完就讀的,照原樣回傳。
  • responseType 是 "json" 時,response 已經是物件,直接改物件。response 另外也蓋了一個同樣邏輯的 getter。

open 那邊也有一個很容易踩的坑:

return open.call(this, method, url, arguments.length > 2 ? arguments[2] : true, arguments[3], arguments[4]);

為什麼不直接傳 arguments[2]? 因為播放器如果只呼叫 open(method, url),arguments[2] 是 undefined。照規格,async 參數省略才是 true,明確傳 undefined 會被轉成 false,整個請求變成同步的。包一層 hook 就把頁面卡死,這種 bug 很難查。

③ 分段請求:最後一道關卡,也是換節點不重整的關鍵

前兩個 hook 改的是「網址清單」,第三個直接改「真的發出去的請求」。

function isSwappableSeg(url) {
  if (!isHostMode()) return false;
  if (!SEG_RE.test(url)) return false;
  var h = hostOf(url);
  return !!h && CDN_HOST_RE.test(h) && h !== effHost();
}
// fetch hook 裡:
if (isSwappableSeg(url)) {
  var nu = swapSegHost(url);
  if (typeof input === "string") input = nu;
  else { try { input = new Request(nu, input); } catch (e) {} }
}

XHR 一樣在 open 裡把 url 換掉。input 是 Request 物件的時候不能只換字串,要 new Request(nu, input),把 method、headers 這些一起帶過去。

既然 baseUrl 都改過了,為什麼還要這一層? 我在 av170001 上實測過:

player.reload() 不會重打 playurl,只會重新初始化 MSE、重新請求分段。

意思是,使用者在 popup 換節點時,如果只靠改 playurl,就得整頁重新整理。有了分段這一層,流程變成:popup 存設定 → bridge.js 用 postMessage 把新設定推進來 → main-hook.js 更新 cfg.cdnHost(effHost() 每次都讀最新值)→ 呼叫 player.reload()。播放器重發的分段就會被導到新節點。不用整頁重載,全螢幕和進度都保留。

開關功能就不行了。設計上功能關掉時完全不碰 __playinfo__、不改任何網址,所以 setter 乾脆不裝,而不是裝了再看旗標。要裝或拆 setter,只能整頁重載。備用模式也一樣要重載,因為它只改 playurl 那層,得讓播放器重打一次 playurl。

實際的程式長這樣(精簡過,拿掉了 try/catch、日誌、換節點的提示和來源檢查):

// main-hook.js:收到 bridge.js 推來的新設定
window.addEventListener("message", function (ev) {
  if (ev.source !== window) return;
  var d = ev.data;
  if (!d || d.__cdnSwitcher !== 1 || d.dir !== "config") return;
  var beforeSig = effSig(cfg), beforeActive = ACTIVE, beforeEffHost = effHost();
  // 寫進 cfg 之後,effHost() 下一次就讀到新節點
  for (var k in DEFAULTS) if (d.payload[k] !== undefined) cfg[k] = d.payload[k];
  // 節點和開關都沒變(例如只切除錯疊層):什麼都不做
  if (beforeSig === effSig(cfg)) return;
  if (beforeActive !== isActive(cfg)) {
    // 開/關擴充:要裝或拆 hook
    fullReload();
  } else if (cfg.cdnHost === "backup" || beforeEffHost === "backup") {
    // 切到或離開「備用」:要重打 playurl
    fullReload();
  } else if (!WATCH_RE.test(location.pathname) || mainVideoIdle()) {
    // 不在觀看頁,或影片暫停、播完:不重載,之後的分段自然會走新節點
  } else if (!playerReload()) {
    // 拿不到 player.reload 才退回整頁重載
    fullReload();
  }
});

function playerReload() {
  var p = window.player;
  if (!p || typeof p.reload !== "function") return false;
  var t = p.getCurrentTime() || 0;
  // 重新初始化 MSE、重新請求分段,被分段 hook 導到新節點
  p.reload();
  if (t > 1) {
    // reload 不保證停在原本的進度:每 300ms 補一次 seek,直到差距小於 2 秒(最多約 20 次)
    var tries = 0, iv = setInterval(function () {
      if (Math.abs(p.getCurrentTime() - t) < 2 || ++tries > 20) return clearInterval(iv);
      p.seek(t);
    }, 300);
  }
  return true;
}

中間那個「暫停或播完就不重載」的分支是後來補的。節點設定存在 storage,所有分頁共用:在一個分頁測速、換了節點,其他分頁也會收到這次變更。暫停中的分頁如果也跟著 player.reload(),影片會自己播起來(回報 #33)。反正分段層每次都讀最新的 effHost(),這些分頁等使用者按播放,之後的分段一樣會走新節點,不用重載。

分段的判斷也踩過一次坑:

var SEG_RE = /\/upgcxcode\/.*\.m4s|\/upgcxcode\//i;
var CDN_HOST_RE = /(bilivideo\.(com|cn)|akamaized\.net|hdslb\.com)$/i;
function isSegUrl(url) { return SEG_RE.test(url) && CDN_HOST_RE.test(hostOf(url)); }

(SEG_RE 的前半段其實被後半段包含了,留著是歷史。)

原本只看路徑。結果 data.bilibili.com 的打點上報會把分段網址整串塞在 query 裡,被誤認成分段,速度樣本(擴充拿每個分段的下載速度算網速,就是除錯面板上的 spd)和「目前 CDN」全被污染。分段一定要連 host 一起驗。

分段請求失敗時(403、逾時、連不上)會自動退回備用節點,那是另一套邏輯,寫在另一篇。這邊只提一件事:播放器 seek 或切畫質時會自己 abort 進行中的分段,AbortError 不能當成節點壞掉。

④ 直播:同一個叢集裡換線路

直播完全是另一套規則。直播流的 host 長這樣,07 是叢集號,同一個叢集有四種線路:

  • ov:d1--ov-gotcha07.bilivideo.com
  • ovb:d1--ov-gotcha07b.bilivideo.com
  • cn:d1--cn-gotcha07.bilivideo.com
  • cnb:d1--cn-gotcha07b.bilivideo.com

實測下來:

  • 網址裡的 token 綁的是叢集號,同號的四種線路互通。
  • 上一篇那 300 個點播節點拿來播直播,一律 403。

所以直播的「換節點」只能在同一個叢集號裡換線路,liveFetch 把 host 改成同號的另一種(開著自動切換時,失敗就地用原 host 重試)。

操蛋的是,每次換線路都要 livePlayer.reload(),播放器會重打 getRoomPlayInfo,B 站可能給你另一個叢集號。測速測的是 07,切過去播的卻是 05。

解法是 playInfoFetch:同一個直播間、同樣參數的 getRoomPlayInfo,簽名還沒快過期就直接回上次那份,連網路都不發。

if (txt && usablePlayInfo(parseOrNull(txt)) && (!reason || reason === "0") && playInfoServedAt.length < 6) {
  playInfoServedAt.push(now);
  return Promise.resolve(new Response(txt, { status: 200, headers: { "Content-Type": "application/json; charset=utf-8" } }));
}
  • 進房的第一份不是 API 打的,是頁面 SSR 塞在 __NEPTUNE_IS_MY_WAIFU__.roomInitRes 的(格式跟 API 回應一樣),拿它當第一份快取。
  • 簽名剩不到 2 分鐘就放行,讓 B 站給新的。
  • 播放器出錯自己重試(req_reason 不是 0),或 20 秒內已經回了 6 次快取(多半是播放器在死循環重試),一律放行,免得整批節點掛掉時被快取卡死。

自己測速時,要繞過自己的 hook

擴充有手動測速:拿目前在播的分段網址,把 host 換成每一個候選節點,各抓幾 MB 算速度。

問題來了。測速的請求如果走 window.fetch,會經過我們自己裝的 hook。isSwappableSeg 一看:「host 不是目前的目標節點?」,順手就把它換回目標節點。結果測了十個節點,其實十次都在測同一個。

所以在裝 hook 之前,先把原生的 fetch 存起來:

var NATIVE_FETCH = window.fetch; // 存原生 fetch,供手動測速用,繞開下面裝的 fetch hook

function measureHost(host) {
  var testUrl = replaceHost(sampleUrl(), host);
  // ...
  NATIVE_FETCH(testUrl, { signal: ctrl ? ctrl.signal : undefined, cache: "no-store" }).then(/* 讀 stream 計 bytes */);
}

這行在檔案第 163 行,installHooks() 在第 1032 行才呼叫,順序不能顛倒。直播的探測和測速也全部走 NATIVE_FETCH。

怎麼確認 hook 真的跑在 B 站前面

document_start 通常趕得上,但「通常」不是「一定」。所以 hook 裝完會自己檢查一次:

installHooks();
// hook 裝好前頁面已經打過 playurl → 那份 playurl 沒被改寫
debug.playurlBeforeHook = performance.getEntriesByType("resource").some(function (e) { return PLAYURL_RE.test(e.name || ""); });

hook 裝好之前就已經跑完的請求,會留在 Resource Timing 裡。查得到 playurl 就代表輸了這場競速,記進問題回報的日誌裡。還在飛、沒回來的請求不會出現在這裡,所以這只是粗檢,會漏報。

實際跑起來長這樣。這是擴充的除錯面板,關閉時(mode=off)B 站給的是 Akamai,rw=0 seg=0,什麼都沒改:

擴充除錯面板,mode=off:cdn 是 upos-hz-mirrorakam.akamaized.net,src=- rw=0 seg=0,紅框標出速度 66 kB/s

開啟後(另一支影片,數字看量級就好):

擴充除錯面板,mode=on:cdn 換成 upos-sz-mirror08ct.bilivideo.com,src=playinfo rw=1 seg=0,紅框標出速度 3.3 MB/s

src=playinfo rw=1 表示這次是 ① 的 setter 改到的,改了一次。seg=0 是因為 ① 已經把 baseUrl 換成目標節點,分段那層一個都不用再換。① 和 ③ 是互補的:前面改到了,後面就閒著。

不想信除錯面板的話,直接看 DevTools 的 Network 面板。同一支影片,篩選 m4s(順便排除 data.bilibili.com,B 站的埋點網址裡也有 m4s 這幾個字)。

關閉擴充,分段全部從 Akamai 來:

DevTools Network 面板篩選 m4s,擴充關閉時,紅框框出的網域欄每一列都是 upos-hz-mirrorakam.akamaized.net

開啟擴充,同一支影片的分段全部改成 08ct:

同樣的篩選,擴充開啟時,紅框框出的網域欄每一列都換成 upos-sz-mirror08ct.bilivideo.com

原始碼

全部在 GitHub,src/main-hook.js 搜 installHooks 就是這篇的主體。想直接裝來用:嗶速 - 海外影片加速 for bilibili。

Comments(0)

No comments yet. Be the first to share your thoughts!

Related notes

Notice on top of the Bilibili player saying the CDN was slow and playback switched to a backup node
Tech share14 min

Detecting CDN Failures in a Bilibili Extension and Falling Back to Backup Nodes

How the extension decides a CDN node is really broken: which errors to ignore, how to detect stalls from currentTime and the forward buffer, why it switches once quietly before asking you, and how it avoids bouncing between nodes. Also covers live streams.

繁體中文 / English

“Almost NOBODY knows this: Lag-free Bilibili Secret Setting”
How-to guide2 min

The Secret Setting That Fixes Bilibili Buffering

Bilibili buffering overseas? DNS and VPN tricks are a coin toss, because the real problem is the slow CDN node Bilibili assigns you. Install one extension to lock videos onto the fastest node, plus what to use on Android and TV.

繁體中文 / 廣東話 / 简体中文 / English / 日本語 / 한국어

The same 4K video before and after the extension: buffering at 9 KB/s before, 8.8 MB/s after
Tech share11 min

How to Actually Fix Bilibili Buffering for Good

Why popular Bilibili videos play fine overseas while unpopular ones stall: CDN caching and trips back to origin, why the narrowest link sets your speed, why DNS and VPN tricks are hit or miss, and how my extension picks a default node from real tests across 14 Taiwanese ISPs.

繁體中文 / English

CDNSpeedTest while the speed test is running
How-to guide6 min

How to Join the Bilibili CDN Speed Test

Step-by-step guide: download CDNSpeedTest.exe, pass the network check, log in with a QR code, and send the report so the extension can find the fastest Bilibili servers in your country.

繁體中文 / 简体中文 / English / 日本語 / 한국어

Cover: more testers, better defaults. A loop of four steps: test at home, get custom nodes, report back, update the default list (cdn-list.json), then back to users in the next release
Tech share23 min

How Crowdsourced Speed Tests Keep Improving Bilibili CDN Server Picks

How a Windows speed test app benchmarks 299 Bilibili nodes in people's homes, picks the 10 most stable with layered videos and a three-stage funnel, writes them into the extension on the spot, and sends reports back so each country's default nodes improve by market-share-weighted coverage and missing ISPs get recruited.

繁體中文 / English

Cover: one page, two JS worlds. typeof chrome.storage is undefined in the MAIN world console and object in the ISOLATED world; both share the DOM and localStorage and talk via postMessage
Tech share14 min

Chrome Extensions: The Basics of MAIN World and ISOLATED World

Patched window.fetch in a content script and the page ignored you? What the MAIN and ISOLATED worlds can each see, the three ways to pick a world, when to split into two scripts, how they talk through localStorage and postMessage, and which forged messages to reject.

繁體中文 / English

Cover: one src/ folder goes through build.mjs into Chrome, Edge and Firefox zips (Chrome and Edge share one sha256), while Safari goes through an Xcode project to the App Store
Tech share19 min

Shipping a Bilibili Extension from One Codebase to Chrome, Edge, Firefox and Safari

How one src/ becomes four store builds, why the zips must be reproducible (with real sha256 runs and a line-ending gotcha), 5-language release notes guarded by a pre-push hook, and which release steps are scripted versus always done by hand.

繁體中文 / English