你在 content script 裡改了
window.fetch,B 站的播放器完全不理你。因為你改的那個 window,跟網頁用的不是同一個。
我寫的 B 站擴充(它在解決什麼問題看這篇)核心只做一件事:B 站的播放器去抓影片時,把網址裡的 CDN 節點(放影片檔的伺服器)換成你選的那台。
要換網址,就得在 B 站自己的 fetch、XMLHttpRequest、window.__playinfo__ 上動手腳。這種「把原本的函式包一層,經過時順便改一下參數」的做法叫 hook。
擴充裡負責對網頁動手腳的腳本叫 content script:瀏覽器會在符合網址的頁面上自動執行它。第一次寫擴充的人通常會這樣寫:
// content.js
const _fetch = window.fetch;
window.fetch = function (input, init) {
console.log("抓到了", input);
return _fetch.apply(this, arguments);
};
裝上去,打開 B 站,Console 一片安靜。一行都沒印。(第一反應通常是懷疑 B 站把 fetch 蓋掉了)
問題不在 fetch,在 window。
content script 的 window 不是頁面的 window
Chrome 的 content script 預設跑在一個叫 ISOLATED world 的地方,可以想成「跟網頁住同一間房子,但各睡各的房間」。
它跟網頁共用同一份 DOM(document.querySelector 拿到的是同一個元素),也共用同源的 localStorage。但 JavaScript 的全域物件是各自一份:網頁有它的 window.fetch,content script 有自己的 window.fetch。你改的是自己那份,B 站的程式碼用的還是原本那份。
Chrome 是故意這樣設計的。兩邊的 JS 物件互相看不到,網頁就沒辦法去碰擴充的變數,擴充也不會被網頁亂改的 prototype 影響。
那要改到網頁那份怎麼辦? 在擴充的設定檔 manifest.json 裡,content_scripts 可以指定 "world": "MAIN",腳本就會直接跑在網頁自己的 JS 環境裡,看到的 window 就是 B 站的 window。這個欄位是 Chrome 111 才加的(Chrome 文件),Firefox 要 128 以後(MDN),舊瀏覽器照抄會直接不認。
代價是:MAIN world 裡沒有擴充 API。chrome.storage、chrome.i18n 拿不到;chrome.runtime 在 Chrome 上可能還看得到,但那是給一般網頁對外連線用的殘缺版,不是擴充自己那套。對瀏覽器來說,你現在就是網頁的一部分。
兩個世界各自看得到什麼
| MAIN world | ISOLATED world | |
|---|---|---|
網頁的 window.fetch、XMLHttpRequest | 看得到,改了網頁就會用到 | 看到的是自己那份,改了沒用 |
網頁的全域變數(window.__playinfo__、window.player) | 看得到 | 看不到 |
DOM、同源 localStorage | 看得到 | 看得到 |
window.postMessage 與 message 事件 | 收得到也發得出去 | 收得到也發得出去 |
chrome.storage、chrome.i18n、擴充版的 chrome.runtime | 拿不到 | 拿得到 |
MAIN 改得到網頁,但讀不到使用者在 popup(點擴充圖示跳出來的小視窗)存的設定。ISOLATED 讀得到設定,但改不到網頁。
那老方法呢? 以前常見的做法是 ISOLATED 的 content script 往頁面插一個 <script> 標籤,讓那段程式碼跑在網頁裡。但 <script src> 要等檔案載入,不保證比 B 站自己的腳本早跑;直接塞 inline 程式碼又可能被網頁的 CSP(限制網頁能執行哪些腳本的安全設定)擋掉。更重要的是,插進去的那段一樣拿不到 chrome.*,還是得找人傳話。
既然怎樣都要兩段程式碼,不如直接在 manifest 宣告兩支。
拆成兩支:main-hook.js 與 bridge.js
這是擴充的 src/manifest.json(v2.6.0)裡 content script 的部分:
"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-hook.js(MAIN):包住 B 站的fetch/XHR、攔window.__playinfo__,把影片網址換成設定的節點。hook 怎麼寫的不在這篇,hook 細節見另一篇。bridge.js(ISOLATED):讀chrome.storage.local的設定、查chrome.i18n的多語文案,轉交給 main-hook;再把 main-hook 回報的日誌交給 background,測速進度則留著等 popup 來問。
圖上要看的是黃色那一層。main-hook 跟 bridge 沒辦法直接呼叫對方的函式,唯一碰得到的地方是兩邊共用的東西:DOM、同源 localStorage、window 的 message 事件。而 B 站自己的腳本也站在同一層上,這件事後面講安全時會再回來。
圖上只畫了設定那條路。popup 按下測速時,是用 chrome.tabs.sendMessage 直接把指令送給這個分頁的 bridge,不經過 chrome.storage,後面講 all_frames 時會再碰到。
兩邊怎麼傳話:localStorage 給開局,postMessage 給之後
bridge 啟動時做的事(src/bridge.js 精簡過):
var CFG_KEY = "__CDN_SWITCHER_CFG__";
function pushConfig(cfg) {
// 1) 寫一份到同源 localStorage,給 main-hook「下次載入」同步讀
window.localStorage.setItem(CFG_KEY, JSON.stringify(cfg));
// 2) 用 postMessage 推給「現在這一頁」的 main-hook
window.postMessage({ __cdnSwitcher: 1, dir: "config", payload: cfg }, "*");
}
function loadAndPush() {
chrome.storage.local.get(DEFAULTS, function (items) { pushConfig(/* 合併預設值 */ items); });
}
loadAndPush();
chrome.storage.onChanged.addListener(function (changes, area) {
if (area === "local") loadAndPush(); // popup 一改設定,馬上再推一次
});
同一份設定走兩條路,為什麼?
因為 main-hook 要在 B 站的腳本跑之前就決定「要不要裝 hook、換成哪個節點」,這個決定必須是同步的。它拿不到 chrome.storage,就算拿得到,chrome.storage.local.get 也是非同步的,等 callback 回來,B 站可能已經把影片網址抓完了。(白話講:同步是這一行跑完、值就拿到手;非同步是先留一個「好了叫我」的 callback,結果晚點才送來,這段時間 B 站的程式碼照跑)
localStorage.getItem 是同步的。所以 main-hook 一開局就這樣讀:
var cfg = readCfg();
function readCfg() {
var c = {};
for (var k in DEFAULTS) c[k] = DEFAULTS[k];
try {
var raw = window.localStorage.getItem(CFG_KEY);
if (raw) { var p = JSON.parse(raw); for (var k2 in DEFAULTS) if (p[k2] !== undefined) c[k2] = p[k2]; }
} catch (e) {}
return c;
}
讀到的是 bridge 上一次寫進去的值。第一次裝擴充、localStorage 還是空的,就用程式碼裡寫死的 DEFAULTS。
那 localStorage 跟 chrome.storage 不一致的時候呢? 有開著的 B 站分頁時,onChanged 會讓 bridge 馬上把 localStorage 寫新,不會不一致。會不一致的是「沒有任何 B 站分頁開著的時候改了設定」:popup 寫進 chrome.storage,但當下沒有 bridge 在跑,沒人更新 localStorage。下次打開 B 站,main-hook 先讀到舊值。(清掉 bilibili.com 的網站資料也一樣,只是變成讀到 DEFAULTS)
這時 postMessage 會補上:bridge 讀完 chrome.storage 就推一個 dir: "config" 過來,main-hook 收到後比對前後差異。只換節點就叫播放器 player.reload() 在播放器內重載;如果是「開/關」這種需要裝或拆 hook 的變更,才整頁重新載入。
另外,localStorage 是「同源」才共用:www.bilibili.com 跟 player.bilibili.com 這種不同子網域各有一份。所以 iframe 裡的 main-hook 要讀到設定,那個 iframe 裡也得有一支 bridge 把設定寫進它那份,兩支腳本都設 all_frames(後面會再講)就是這樣配對的。
多語文案也是一樣的套路。main-hook 要跳 toast(「已自動切換至備援節點」之類),但 MAIN 裡沒有 chrome.i18n。所以 bridge 代查好整包文案,用 __CDN_SWITCHER_MSGS__ 這個 key 寫進 localStorage 再 postMessage 一次;main-hook 的 readMsgs() 跟 readCfg() 長得幾乎一樣,bridge 還沒推過來之前就用內建的繁中文案頂著。
反方向也走 postMessage。每則訊息都帶 __cdnSwitcher: 1 跟一個 dir 說明是什麼:
- main-hook → bridge:
journal(事件日誌)、sample(播放採樣)、debug(除錯快照)、speedtest-progress/speedtest-done(測速進度)、autotest-done(自動測速選出的節點) - bridge → main-hook:
config、messages,以及 popup 按下測速時轉過來的speedtest-run、speedtest-stop
bridge 收到日誌和 debug 快照後,用 chrome.runtime.sendMessage 交給 background(擴充在背景跑的那支腳本,Chrome 上是 service worker),存進 storage.session(關瀏覽器就清空)。測速進度則留在 bridge,等 popup 來問。main-hook 不碰 chrome.storage,日誌也不自己存,只負責喊「發生了什麼」。
chrome.storage 是正本,localStorage 是給 MAIN 開局同步讀的副本,postMessage 負責追上之後的變更。
為什麼一定要 document_start
run_at 決定 content script 什麼時候跑,有三種:document_start(HTML 才剛開始解析)、document_end、document_idle(預設,頁面差不多載完)。這個擴充兩支都設 document_start:hook 要比 B 站自己的腳本先裝好。
B 站的影片頁會在 HTML 裡直接塞一份 window.__playinfo__(第一個分 P 的播放網址),播放器也會很早就去打 playurl(B 站給播放器影片網址的 API)。等到 document_idle 才裝 hook,網址早就拿到了,換什麼都來不及。
拿 headless Chromium(沒有畫面、拿來跑自動化測試的 Chromium)載入擴充、對一個假的 www.bilibili.com 頁面測:頁面 <head> 裡第一行 inline script 讀 String(window.fetch),拿到的已經是 main-hook 包過的版本(function (input, init) { var url = ""; ...)。至少在這個測試裡,MAIN world 的 document_start 腳本跑在網頁自己的第一行程式碼之前。
但一個測試不代表每次都這樣。真實的 B 站頁面更複雜,我沒辦法保證每次都趕得上,所以 main-hook 裝完 hook 會順手查一下 Performance API(瀏覽器替每個網路請求記的時間表):有沒有在 hook 之前就發出去的 playurl 請求,有的話記成 playurlBeforeHook。有人回報問題時,一眼就看得出是不是 race condition(誰先跑完誰贏、這次剛好輸了)。
bridge 也設 document_start,是為了盡早把 message 的 listener 掛上。postMessage 是非同步派送的,main-hook 開局發的日誌會排到之後才送達,bridge 只要在同一個階段掛好 listener 就收得到。(寫 localStorage 倒是快不了,那是在 chrome.storage.local.get 的 callback 裡做的,本來就是非同步)
all_frames: true 則是因為 B 站的播放器不一定在最上層頁面,嵌入式播放器、預覽會在 iframe 裡,那些 frame 一樣要換節點。每個 frame 都會有自己的一對 main-hook+bridge。代價有兩個:
- popup 送訊息過來時,所有 frame 的 bridge 都會收到,所以 bridge 只在
window.top === window時才回應 popup。 - main-hook 的事件日誌和播放採樣只記最上層。程式碼註解寫的理由是「iframe 裡的播放器(嵌入/預覽)不記,免得淹掉主頁的紀錄」。換節點照做,只是問題回報裡看不到 iframe 播放器的紀錄,這是刻意的取捨。
NOTE
popup 那邊本來可以用 chrome.tabs.sendMessage(tabId, msg, { frameId: 0 }) 只送給最上層,但 bridge.js 的註解寫了:iPad Safari 送訊息時不能指定 frameId: 0,所以 popup 會退回不指定 frame 的送法,靠 bridge 自己判斷 isTopFrame。
安全面:MAIN 跟 postMessage 都是公開場合
把東西放進 MAIN world 或丟上 postMessage,就等於放在 B 站頁面看得到的地方。用 headless Chromium 實測,頁面自己的腳本能看到:
- MAIN 裡掛在
window上的東西:window.__CDN_SWITCHER_INSTALLED__(防止重複安裝的旗標)、window.__cdnSwitcherSimulateStall(模擬卡頓的測試開關,正式版也有,只是沒有任何介面入口)。main-hook 其他東西都包在 IIFE(宣告完立刻執行的函式,裡面的變數不會掛到 window 上)裡,沒有外露。 - localStorage 的
__CDN_SWITCHER_CFG__、__CDN_SWITCHER_MSGS__。 - 每一則 postMessage。在頁面上掛一個
messagelistener,就能依序收到journal、config、messages、debug。
所以這些地方只放「被看到也沒差」的東西。設定值、介面文案、host 名稱可以;token、個資、帶簽名的完整分段網址不行。main-hook 的日誌註解就寫明「分段 URL 只記 host 與檔名,簽名參數一律不帶」。
反過來,看得到就代表也發得出來:postMessage 誰都能發。同一個 window 裡的任何腳本(B 站自己的、它載入的第三方腳本、其他擴充的 content script,不管在哪個 world)發出來的訊息,跟 main-hook 發的長得一模一樣。
bridge 的 listener 開頭是這樣:
window.addEventListener("message", function (ev) {
if (ev.source !== window) return; // 檢查 1
var d = ev.data;
if (!d || d.__cdnSwitcher !== 1) return; // 檢查 2
// ...依 d.dir 分派
});
- 檢查 1
ev.source !== window:擋掉別的 iframe、別的視窗丟過來的訊息。同一個 window 裡的腳本發的,source就是window,照樣通過。 - 檢查 2
__cdnSwitcher !== 1:從 window 上所有的 postMessage 裡挑出自己的。這是分類,不是驗證,任何人都能在訊息裡加這個欄位。
bridge 共用的入口就這兩道。之後依 dir 分派,各種訊息自己決定要不要再檢查內容,例如 autotest-done 寫回節點前,會先確認「開測時的節點」跟現在的設定一樣。
能擋住「同頁腳本偽造訊息」的只有圖上的檢查 3:看內容合不合理,而且要每種訊息自己寫。最該寫的是會寫回 chrome.storage 的那幾種,因為寫進去的值會留下來,影響之後每一個 B 站分頁。
擴充裡已經有一條路這樣做了,只是它不走 postMessage。擴充另外附了一個獨立的測速程式(不是 popup 裡的測速,是要另外下載的 Windows 小程式),它測完沒辦法直接跟擴充講話,只能用瀏覽器打開 https://www.bilibili.com/#cdnsw-import=1.<host>,<host>…,由 bridge 讀網址上的 hash 再交給 background 匯入自訂節點列表。這種網址任何網站都做得出來,所以 background.js 的註解寫得很清楚:「匯入網址任何網站都能做出來,所以只收 cdn-list.json 裡有的節點……別人沒辦法藉此把影片導到自己的網域」。cdn-list.json 是擴充內建的節點清單,也就是圖上說的白名單。
自己寫擴充的時候,凡是會從 postMessage 寫回 chrome.storage 的訊息,都比照這條路做:只收白名單裡的值,不在清單上的一律丟掉。
Firefox 跟 Safari 呢
src/manifest.firefox.json 的 content_scripts 跟 Chrome 版一字不差,同樣是 world: "MAIN"/"ISOLATED"、document_start、all_frames。兩份 manifest 的差別在別處:Firefox 版的 background 用 "scripts": ["background.js"] 而不是 service_worker,另外多了 browser_specific_settings.gecko(擴充 id、最低支援版本等)。打包時 scripts/build.mjs 只是把對應的那份改名成 manifest.json 放進 zip。
Safari 版的 Xcode 專案直接用相對路徑引用 repo 的 src/(scripts/build-safari.mjs 的註解),所以跟 Chrome 吃的是同一份 manifest。
換句話說,Chrome(含同核心的 Edge)、Firefox、Safari 都直接在 manifest 宣告 world: "MAIN",repo 沒有替不支援的舊版瀏覽器準備退路(例如退回前面說的插 <script> 標籤)。跨瀏覽器的差異是在別的地方處理的:上面提到的 iPad Safari frameId,以及 background.js 裡舊版 Safari 沒有 storage.session 時退回 storage.local。
自己動手看兩個世界
先裝好這個擴充(Chrome 線上應用程式商店搜「嗶速 - 海外影片加速」,或從 GitHub 把 src/ 當未封裝擴充載入),打開任一支 B 站影片,再開 DevTools 的 Console。
Console 輸入框上方有一個 context 下拉選單,預設是 top,也就是網頁本身(MAIN world)。裝了擴充之後,選單裡會多一個擴充名稱的選項,那就是它的 ISOLATED world。
(選單裡的 iframe.html 是 B 站頁面裡的 iframe,每個 frame 各自有一個 top;DevTools Performance Metrics 是 DevTools 自己的,不用理它)
在兩個 context 各打一次:
typeof chrome.storage // top: "undefined" 擴充: "object"
window.__CDN_SWITCHER_INSTALLED__ // top: true 擴充: undefined
localStorage.getItem("__CDN_SWITCHER_CFG__") // 兩邊都讀得到同一份
前兩行結果相反,第三行一樣。前面講的「各自一份的 JS 物件、共用的 localStorage」,在這裡可以直接看到。
下次寫擴充要改網頁的 JS 行為,先問一句:這段是要改網頁,還是要用 chrome.*? 兩個都要的話,一支放 MAIN、一支放 ISOLATED,中間用 localStorage 跟 postMessage 傳話。


Comments(0)
No comments yet. Be the first to share your thoughts!