✈️ 外站票情報站 全自動查價
進階選用 · 需自行安裝擴充

⚡ 全自動查價(進階選用)

書籤版已經夠好用,但每查一格還是要點一下書籤。這一頁提供另一種做法:裝一次 Tampermonkey 使用者腳本後,Google Flights 結果一載好就自動把票價送回外站票矩陣,連書籤都不用點。

這是選用功能,不裝完全不影響比價工具的任何功能;想省事就裝,不想裝就繼續用 📥 抓價書籤。

現在多數功能免安裝即可使用。外站多段區間掃描、區間掃描與常見組合都已是雲端查價,手機、平板、電腦打開就能查——先試 ☁️ 外站多段區間掃描、區間掃描、常見組合。本頁是「電腦進階選用」:只有想在電腦上用 Tampermonkey 腳本自動回填、連點書籤都省掉的人才需要往下看;不裝完全不影響任何功能。
🔒 票價文字只在你的瀏覽器本機處理與傳遞——直送走瀏覽器內建的分頁間訊息(postMessage,指定收件網域),退路走網址 # 之後的片段(依瀏覽器規範不會隨請求送到任何伺服器);兩條路都不經過任何伺服器。

🆕 腳本已更新到 v2.1.17(可自動更新)—— 舊版請重新安裝這最後一次

v2.1.16 新增(一):查價時不再多跳一個外站網頁——舊版在直送接不上的瀏覽器(例如 Brave)每查一格就會多開一個 outstation.html「中繼分頁」再自己關掉,看起來多此一舉。這一版改走 Tampermonkey 自己的儲存區傳遞:查價分頁把票價放進腳本專屬的小信箱,你開著的工具頁(區間掃描 / 外站票矩陣)上的同一支腳本收到後交給頁面,全程不開任何新分頁;只有 GM 儲存區用不了、或 4 秒內沒有任何工具頁認領時,才退回舊的中繼分頁(最後手段)。
v2.1.16 新增(二):走中繼的瀏覽器也能並行掃描——信裡多帶一個 tfs(查價網址裡本來就有的「航線+日期+艙等」編碼,不含個資),區間掃描頁靠它認出每個結果屬於哪一格,最多可同時開 6 個分頁。
更新時 Tampermonkey 會跳出權限確認:這一版的 @grant 由 none 改成 GM_setValue / GM_getValue / GM_deleteValue / GM_listValues / GM_addValueChangeListener / GM_openInTab(下方「它要的權限」有逐項說明)。請按「一鍵安裝 / 更新」重新安裝並同意;沒更新就維持舊行為(會多跳中繼分頁、中繼路徑只能一次一格),照常能用。
v2.1.15 修:右下角那則「沒認出這是工具開的查價分頁,所以沒有送回票價」不再誤報——區間掃描收工時,工具會對每個查價分頁送出「解除待命」(順便清掉分頁名,所以診斷會顯示「分頁名:(空)」),舊版把這個**正常的收工狀態**當成故障跳警報,於是出現「明明票價都進系統了,畫面卻說沒送回」的矛盾。新版在「已收工 / 你手動按 🔍 看的頁 / 這一格已經送過 / 握手晚到但已武裝」這四種情況一律不跳,而且只要後來武裝成功或票價送出,先前那則警報會自動收掉(舊版貼上去就永遠不會消失)。
v2.1.8 修:自己手動開的 Google Flights 頁(例如區間掃描表的 🔍)不再被腳本自動關掉——只有查價精靈/區間掃描自己開的分頁(有握手記號)才會送完自關,你手動開來看明細的頁會留著;v2.1.7 修:單程結果頁改為「穩定 1.5 秒+至少看滿 4 秒」才抓價,不再卡在 Google 分批渲染的空檔提早送出、只抓到前半批(區間掃描逐日查價實測踩到)。
v2.1.6 新增:查不到票的格子會立刻跳過;v2.1.6 再修:查無航班/逾時的分頁會自己關掉(被要求人工驗證的分頁刻意保留,讓你能親自處理);v2.1.6 再修:四段票結果頁載入較慢,改為「穩定 2.5 秒+至少看滿 8 秒」才抓價關分頁,不再只抓到前半批結果 —— 薄航線(下地島、石垣這類)Google 常回「找不到符合搜尋條件的選項」,舊版腳本遇到這種情況什麼都不送回,比價工具只能空等到逾時,整張表 161 格會被這種格子一路拖死。新版會主動通知工具「這格沒有班次 / 被要求人工驗證 / 我讀不到」,工具收到就立刻記錄並跳下一格。
v2.1.2 修的是:查價分頁會一直累積(一列 7 格開 7 個、整張表可能開上百個)。這一版腳本送出票價後會自己把那個查價分頁關掉,畫面就只會有一兩個分頁在輪替。
為什麼要腳本自己關:比價工具原本想重複使用同一個查價分頁,但在 Brave 上實測發現跨網域的分頁參考會被瀏覽器完全切斷(工具找不到舊分頁,只能一直開新的),所以改由分頁自己收尾。
(2.1.0 → 2.1.1 只是補作者/來源資訊,那一版本來不必重裝。)

v2.1.17 起會自動更新(腳本帶 @updateURL,Tampermonkey 預設每天檢查一次;想立刻更新可在 Tampermonkey 管理面板對本腳本按「檢查更新」)。但 v2.1.16 以前的舊版沒有這個設定,必須手動重裝一次 v2.1.17,之後就不用再管;不重裝就沒有下面這幾件事:

⚖️ 誠實對照:書籤版 vs 腳本版

兩種做法送回的東西完全一樣(整頁純文字 → 由比價工具自己解析),差別只在「誰來按那一下」。

比較項目 📥 書籤版(預設推薦) ⚡ 腳本版(本頁)
要安裝什麼 零安裝,把按鈕拖到書籤列即可 要先裝 Tampermonkey 瀏覽器擴充,再裝腳本
每查一格要做的事 在結果頁點一下書籤 零點擊,結果載好自動送回
查 12 格的操作次數 12 次點擊 0 次
會不會一直開分頁 每點一次書籤,會開一個送票價的中繼分頁(收到後自己關掉) 順利時完全不開新分頁(v2.0.0 起票價直接送回你原本的分頁);直送沒接上時才退回開中繼分頁
能不能多個分頁並行查 不行——書籤一次只能在你正在看的那個分頁按,你沒辦法同時按三個分頁 可以,查價精靈面板可選 1/2/3/5 個分頁同時查(預設 3)。並行越多越快,但越可能被 Google 暫時擋下要求人工驗證
手機可用嗎 大多不行(手機沒書籤列),改用「📋 貼回票價」 大多不行(手機瀏覽器少有擴充)
什麼時候會動作 完全由你決定,只有你親手點書籤的那一刻 只在「由比價工具開啟的查價分頁」自動動作。你另外開分頁逛 Google Flights 不會動;但那個查價分頁若不關,你在裡面自己查的結果 15 分鐘內也會被送回(見下方隱私說明)
Google 改版時 可能失效(讀不到文字) 可能失效,而且多一層「就緒偵測」也可能誤判 → 逾時不送
要維護的東西 只有一顆書籤,改版時重新拖一次 v2.1.17 起 Tampermonkey 會自動更新(舊版需手動重裝一次)
建議對象 所有人 常常一次查十幾格、且能接受裝擴充的人

🚨 裝之前:兩個瀏覽器開關(90% 的「裝好了卻沒反應」都是這裡)

這兩個沒開,腳本裝得再對也不會動,而且不會有任何錯誤訊息

兩個都確認過還是沒反應的話,到 Google 查價頁看右下角:v2.1.14 起腳本會在那裡跳一顆 橘色診斷泡泡,直接寫出是哪一道訊號沒到,並且點下去就能手動送回這一格。完全沒有泡泡 = 腳本根本沒被注入,回到上面第 ① 項。

🛠 安裝三步驟

1
🐵
裝 Tampermonkey
Tampermonkey 是知名的免費使用者腳本管理擴充(全球數千萬安裝),從官方商店安裝:
Chrome 線上應用程式商店
Edge 附加元件商店
⚠️ 裝完還要再開一個開關,否則腳本不會執行
較新版的 Chrome/Edge 預設不允許擴充功能執行使用者腳本。網址欄輸入 chrome://extensions/(Edge 是 edge://extensions/)→ 找到 Tampermonkey → 點「詳細資料」→ 打開「允許使用者腳本」(Allow user scripts)。
沒開的症狀:腳本清單看起來一切正常、開關也是綠的,但查價精靈會一直停在等待、三分鐘後逾時, 而且不會有任何錯誤訊息。若 Tampermonkey 面板頂端出現藍色橫幅要你啟用,就是這一項。
2
📜
裝腳本
按下方「⬇ 下載腳本」取得 cuijian-autofetch.user.js,把檔案拖進 Tampermonkey 的「公用程式 → 檔案」或直接拖到瀏覽器視窗;也可以按「📋 複製腳本」,在 Tampermonkey 面板按「新增指令碼」,全選刪掉範本後貼上並存檔(Ctrl+S)。
3
🔍
回工具按查價精靈
⚠️ 第一次跑會跳「已封鎖彈出式視窗」,要放行的是 www.google.com
腳本是從 Google 頁面把票價送回比價工具的,所以瀏覽器問的是要不要允許 www.google.com 的彈出式視窗 —— 不是本站,放行本站沒有用。
做法:點網址欄那個被封鎖的圖示 → 選「一律允許 https://www.google.com 的彈出式視窗和重新導向」→ 完成。 設定一次之後就會一路自動跑完。若沒放行,腳本會在 Google 頁面右下角留一顆「點我送回票價」的按鈕, 資料不會漏掉,只是每格要多點一下。
回到 比價工具,按「🔍 查價精靈」。之後每開一個查價頁,票價就會在結果載好後自動回填矩陣,Google 頁面右下角會閃一個「✅ 已自動送回票價(沒有開新分頁)」的小提示。精靈面板會出現並行分頁選擇(預設 3 個同時查);萬一直送沒接上,腳本會改開一個中繼分頁送回,那個分頁也會自己關掉。
⚡ 一鍵安裝 / 更新(推薦) ⬇ 下載腳本(.user.js) 📥 我還是用書籤版就好

「⚡ 一鍵安裝」是最省事的:Tampermonkey 會攔截這個 .user.js 網址、直接跳出安裝畫面,按「安裝」或「重新安裝」即可(更新時會蓋掉舊版,不會變成兩個腳本)。
⚠️ 「⬇ 下載腳本」在 Chrome 上常常不會跳安裝畫面——Chrome 預設不允許擴充功能讀取本機檔案(file://),所以拖進去只會用純文字打開。硬要用下載檔的話,得先到 chrome://extensions → Tampermonkey →「詳細資料」→ 開啟「允許存取檔案網址」。不想改設定就用上面的一鍵安裝,或用「📋 複製腳本」貼到 Tampermonkey 的「新增指令碼」。
下載檔是在你的瀏覽器本機即時產生的(檔案內容就是下面那份全文),不會經過任何伺服器。

🔒 隱私與範圍(請先看完再決定要不要裝)

這支腳本會做的事

它要的權限:只有「自己的小儲存區」和「代開分頁」

v2.1.16 起腳本標頭的 @grant 是下面六個、而且只有這六個(發佈前檢查會逐字比對,多一個就擋下):

仍然沒有 @connect(不能對外發跨網域請求)、沒有 @require / @resource(不載入任何外部程式碼)、沒有 GM_xmlhttpRequest / GM_cookie / unsafeWindow。有 @grant 時腳本會跑在 Tampermonkey 的沙箱裡:讀畫面文字、postMessage、sessionStorage 與網頁共用、行為一樣;「腳本已安裝」記號是掛在 <html> 的屬性,頁面照常讀得到。每個 GM 呼叫端都有 typeof 守衛——你把標頭 @grant 改回 none 腳本也能跑,只是退回舊的中繼分頁做法。

送票價的優先順序:① postMessage 直送(順利時最快)→ ② GM 信箱(直送沒接上,例如 Brave;不開新分頁)→ ③ 中繼分頁(最後手段)。三條路送的都是同一份純文字、同一個 rid,工具端會去重,不會重複寫入。

③ 的中繼分頁若被彈窗攔截器擋下(但現在有 GM_openInTab 代開,這很少發生),腳本不會無聲失敗、也不會漏資料:會在畫面右下角留一顆「點我送回票價」的小按鈕。

你隨時可以關掉

在 Tampermonkey 面板把這支腳本的開關切成「已停用」就立刻停止作用,按刪除則完全移除,不留任何殘留設定。腳本判定「這是查價分頁」的記號存在該分頁的 sessionStorage(cuijian_autofetch_armed,只有一個時間戳),關掉那個分頁就消失,超過 15 分鐘也會自動清除。

沒有任何帳號、沒有註冊、沒有金鑰,腳本與網站之間不存在任何伺服器端連線。

📜 腳本全文

下面就是會跑在你電腦上的完整程式碼,一行不漏。請自己看過(或請人幫你看過)再安裝——任何要你貼進瀏覽器的程式碼都該先看內容,這是基本自保。

// ==UserScript==
// @name         外站票情報站 · 自動送回票價
// @namespace    https://cuijian-home-tw.web.app/
// @version      2.1.17
// @description  在 Google Flights 結果頁自動把票價文字送回外站票情報站的航班矩陣(v2:直接送回、不再每格閃一個中繼分頁,並可多分頁並行)
// @author       外站票情報站(萃見)
// @homepageURL  https://cuijian-home-tw.web.app/flights/autofetch.html
// @supportURL   https://cuijian-home-tw.web.app/flights/autofetch.html
// @icon         https://cuijian-home-tw.web.app/flights/icons/icon-192.png
// @match        https://www.google.com/travel/flights*
// @match        https://cuijian-home-tw.web.app/flights/*
// @updateURL    https://cuijian-home-tw.web.app/flights/cuijian-autofetch.user.js
// @downloadURL  https://cuijian-home-tw.web.app/flights/cuijian-autofetch.user.js
// @run-at       document-idle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_addValueChangeListener
// @grant        GM_openInTab
// ==/UserScript==

/* 外站票情報站 · 自動送回票價 v2.1.6
 * ─────────────────────────────────────────────────────────────────────────
 * 做什麼:在「由外站票工具開啟的查價分頁」偵測 Google Flights 結果載好,
 *         自動把整頁純文字送回比價工具的航班矩陣,達成零點擊。
 * 不做什麼:不在你自己隨手逛的 Google Flights 動作;不讀畫面文字以外的東西;
 *         不連任何第三方;不寫 cookie;沒有帳號、沒有金鑰。v2.1.17 起 Tampermonkey 會依 @updateURL 自動更新本腳本(只從本站下載這一個檔)。
 *
 * ★ v2.x 與 v1.0.x 的差別(已裝舊版的人要重新安裝才會有):
 *   ① 直送、不閃分頁:比價工具會對「它自己開的查價分頁」送一個 { __gfping: 1, tok } 握手訊息
 *      (純握手,**不帶任何行程/客戶資料**),本腳本從 event.source 就知道工具頁是哪個 window,
 *      票價直接 postMessage 回去 —— 順利時**完全不開新分頁**。舊版每查一格都要開一個中繼分頁
 *      再自己關掉,查 161 格就閃 161 次。
 *   ② 可並行:每個查價分頁都能各自認得「開我的是誰」,工具因此可以同時開好幾個分頁查不同格。
 *      (舊版做不到:多個中繼分頁會搶同一個具名視窗與同一把 localStorage,互相覆蓋。)
 *   ③ 直送沒接上(工具頁版本太舊、還沒握到手、訊息被擋)→ **自動退回舊版的中繼分頁做法**,
 *      不會漏掉票價;兩條路送的都是同一份純文字。
 *   隱私範圍與啟動條件(isArmed)與 v1.0.1 完全相同,沒有放寬。
 *
 * ★ v2.1.0 與 v2.0.0 的差別(**要並行的人必須更新到這一版**;工具端從此只認得回得出 tok 的腳本才開放並行):
 *   ④ 握手訊息多了一個 tok(工具給每個查價分頁槽的**不透明隨機字串**,與行程/日期/金額無關),
 *      本腳本原樣回傳。用途:工具的具名查價分頁會被回收重用(某一格被跳過後,同一個分頁可能
 *      1.5 秒後就被派去查別的格),而分頁裡「舊的那一份頁面」最長還會再輪詢 25 秒——它遲到送回的
 *      票價若只用「哪個分頁」判斷,會被工具認成**新的那一格**、把票價填到錯的日期。帶上 tok 就分得開。
 *   ⑤ 退回中繼分頁時,網址多帶一個 &rid=(就是直送用過的同一個編號),讓工具能認出「這是剛才那一筆的重送」
 *      而不重複套用(直送其實已寫入、只是收據沒回到本頁時會發生)。
 *
* ★ v2.1.17:加上 @updateURL / @downloadURL —— 業主要求「要能自動更新」。Tampermonkey 會定期(預設每天)
 *   從本站抓這個檔比對 @version,有新版就自動換上(新增權限時仍會先問你)。只連本站這一個網址,不連第三方。
 *
 * ★ v2.1.16(**走中繼的瀏覽器——例如 Brave——要並行必須更新到這一版**):
 *   ⑥ 中繼分頁網址多帶一個 &tfs=<本查價頁網址的 tfs 參數>(base64url,只含 A-Za-z0-9_-,上限 800 字元,
 *      不合格就不帶)。tfs 本來就是工具自己組出來、放在查價網址裡的「航線+日期+艙等」編碼,不含任何個資。
 *      原因:Brave 上直送與握手(含 tok)都到不了,結果只能走中繼;中繼信箱原本認不出「這是哪一格」,
 *      工具只敢在「恰好一格在飛」時收,所以無法並行。工具改用 tfs 認格 → 中繼也能並行。
 *      舊版工具(沒有 tfs 欄位)會直接忽略這個參數,行為與 v2.1.15 完全相同。
 *
 * ★ v2.1.16 另一件事(**不再多跳一個外站網頁**):
 *   ⑦ 直送沒接上(Brave、或工具頁被關掉)時,舊版會開一個 outstation.html 中繼分頁把票價轉手 —— 每查一格就閃一個分頁。
 *      現在改走 Tampermonkey 的擴充功能儲存區(GM_setValue):查價分頁把票價寫進一個小信箱(cj_gm_relay_0…15),
 *      開著的工具頁(區間掃描 / 外站票矩陣)上的同一支腳本用 GM_addValueChangeListener 收到後,以同源 postMessage
 *      轉交給工具頁,工具頁認得出是哪一格(tok / tfs)、搶到認領權才套用,腳本再刪掉信箱 = 收據。
 *      中繼分頁只剩「最後手段」:GM 不能用、或 4 秒內沒有任何工具頁認領,才退回開 outstation 中繼分頁(行為與 v2.1.15 相同)。
 *      這需要把 @grant 從 none 改成上面幾個 GM_*(只碰這支腳本自己的儲存區,讀不到任何網頁內容或其他腳本的資料);
 *      Tampermonkey 更新時會要你確認新權限。⚠️ 有 @grant 時腳本跑在沙箱:DOM 讀取、window.postMessage、
 *      window.addEventListener('message')、sessionStorage 都與頁面共用、行為相同;這支腳本沒有用到 unsafeWindow
 *      (不需要:它不讀、不改頁面自己的 JS 變數,「已安裝」記號是掛在 <html> 的屬性,頁面讀得到)。
 *
 * 兩條 @match 各自負責:
 *   ① www.google.com/travel/flights*        → 偵測結果就緒 → 直送票價(退路:開中繼分頁)
 *   ② cuijian-home-tw.web.app/flights/…     → 掛「腳本已安裝」旗標 + 關掉退路用的中繼分頁
 *      (v2.1.6 起擴大到 /flights/* 全部頁面:四段票規劃室、設定精靈頁都要能偵測到安裝狀態)
 *
 * 權限(v2.1.16 起):GM_setValue / GM_getValue / GM_deleteValue / GM_listValues / GM_addValueChangeListener / GM_openInTab。
 * 前五個只讀寫「這支腳本自己的」小儲存區(信箱);GM_openInTab 只用在最後手段(開中繼分頁,由擴充功能代開、不會被彈窗攔截器擋)。
 * 沒有 @connect、沒有 @require / @resource、沒有 fetch / XMLHttpRequest —— 仍然不能對外連線、不載入外部程式碼。
 * 每個 GM_* 呼叫端都有 typeof 守衛:把 @grant 改回 none(或管理員停用)照樣能跑,只是退回舊的中繼分頁做法。
 * 順利時走 postMessage 直送;直送沒接上改走 GM 信箱;兩者都不行才開中繼分頁。
 * 中繼分頁若被彈窗攔截器擋下,退回右下角「點我送回票價」小按鈕,絕不無聲失敗、絕不漏資料。
 * ───────────────────────────────────────────────────────────────────────── */
(function () {
  "use strict";

  var VERSION = "2.1.17";

  // ── 契約常數(與 outstation.html / bookmarklet.html 同源,勿隨意改)─────────
  var TOOL_ORIGIN = "https://cuijian-home-tw.web.app";
  var TOOL_URL    = TOOL_ORIGIN + "/flights/outstation.html";
  var RELAY_TAB   = "cuijian_outstation"; // 送回票價用的具名中繼分頁(與書籤同名)
  var LOOKUP_NAME = "cuijian_lookup";     // 精靈用 window.open(url, 'cuijian_lookup') 開查價頁
  // 🐛 v2.1.13:區間掃描(scan.html)開的查價分頁叫 'cuijian_scan_1'…'_5',不是 'cuijian_lookup_N'。
  //   舊版只認 cuijian_lookup*,掃描開的分頁因此少了 (a) 這道硬訊號,全靠 (b) document.referrer 撐著 ——
  //   而 referrer 在「瀏覽器設定成不送 referrer / 隱私擴充功能剝掉它 / 中途被轉址吃掉」時會是空字串,
  //   於是腳本判定「不是工具開的」→ 完全不動作 → 分頁開了卻不填表也不自關(業主在另一台電腦實測)。
  //   多認這個名字並沒有放寬隱私範圍:判斷的仍是同一件事「這是工具開的查價分頁」。
  var SCAN_NAME   = "cuijian_scan";       // 區間掃描用 window.open(url, 'cuijian_scan_N') 開查價頁
  var ARMED_KEY   = "cuijian_autofetch_armed"; // sessionStorage(僅此分頁、僅 google.com)
  var ARMED_TTL_MS = 15 * 60 * 1000;           // 黏性旗標有效期:距最後一次「工具開的查價頁」超過 15 分鐘即失效
  // 🙈 v2.1.10:純瀏覽旗標。工具頁「我只是想看這一天」的連結會在網址帶 #cjview,
  //   但 Google Flights 是單頁應用 —— 使用者一改篩選/排序,網址就被換成 /search?… ,**hash 會消失**,
  //   只看當下網址的判斷會失效(業主實測:在該分頁按 SkyTeam 篩選後又跳出中繼分頁)。
  //   所以第一次看到 #cjview 就把它記在這個分頁的 sessionStorage,之後不論網址怎麼變都維持純瀏覽。
  var VIEWONLY_KEY = "cuijian_autofetch_viewonly";
  var disarmed = false;                        // 工具送 __gfdisarm 後為 true:這個分頁不再送回任何東西

  // 回傳契約:
  //   "gfimport" → #gfimport=<base64url(票價文字)>&src=userscript  ← 預設,也是線上比價工具唯一看得懂的通道
  //   "gfauto"   → #gfauto=v1.<base64url(來源網址)>.<base64url(票價文字)>
  //                ⚠️ 保留給未來版本:目前線上的 outstation.html 只解析 #gfimport,
  //                   選 "gfauto" 會讓送回的資料靜默不被接收(中繼分頁閃一下就關、矩陣不會變)。
  // 兩種都走「開一個中繼分頁 → 工具端用 localStorage 把文字交給你原本開著的分頁 → 中繼分頁關閉」。
  // ⚠️ v2.0.0 起這條只是**退路**:優先走下面的 postMessage 直送(不開任何分頁),直送沒接上才用它。
  var RETURN_MODE = "gfimport";

  // ── ⚡ 直送通道(v2.x)訊息契約:欄位名兩邊(本腳本 / outstation.html)必須逐字一致 ───
  //   工具 → 本頁: { __gfping: 1, tok }                                  ← 純握手 + 分頁槽的不透明隨機字串
  //                                                                        (**不帶任何行程/客戶資料**)
  //   本頁 → 工具: { __gfimport: 1, text, src: "userscript", rid, tok }   ← 票價文字 + 原樣回傳的 tok
  //   工具 → 本頁: { __gfimportAck: 1, rid }                             ← 收到了,不必再開中繼分頁
  // 🔒 送出時 targetOrigin 一律寫死 TOOL_ORIGIN(**絕不用 "*"**);收訊時一律先逐字比對 ev.origin。
  var ACK_WAIT_MS = 1500; // 直送後等 ACK 的上限;等不到就當作沒人在聽,退回中繼分頁(絕不漏資料)
  var TOK_MAX     = 32;   // tok 是工具給的字串:限長 + 只原樣回傳,絕不解讀、不落地、不做他用

  // ── 就緒偵測參數 ─────────────────────────────────────────────────────────
  var POLL_MS       = 500;   // 每 500ms 取樣一次
  var STABLE_NEEDED = 2;     // 連續 2 次「價格字樣數」相同且 ≥1 即視為就緒
  var MIN_PRICES    = 1;     // 至少要有 1 個價格字樣才算有結果
  var QUIET_MS      = 800;   // 且 DOM 需靜默 ≥800ms(MutationObserver 量測,擋 SPA 分批渲染)
  var TIMEOUT_MS    = 25000; // 25 秒逾時上限:逾時不送,交給工具端自己逾時處理
  var MAX_TEXT      = 60000; // 與書籤相同的文字截斷長度
  var RELAY_CLOSE_MS = 1400; // 中繼分頁自關延遲(> 工具端 ACK 輪詢上限 800ms)

  // 價格字樣:NT$1,234 / $1,234 / TWD 1,234 / ¥12,345 …
  var PRICE_RE = /(?:NT\$|US\$|HK\$|CA\$|A\$|S\$|RM|₩|€|£|¥|$|\$)\s?\d[\d,]{2,}|(?:TWD|USD|JPY|KRW|HKD|EUR|GBP|SGD|CNY|NTD)\s?\d[\d,]{2,}/g;
  // 錯誤/驗證頁特徵:命中就當失敗,絕不送垃圾文字回去
  var BLOCK_RE = /我們的系統偵測到|不尋常的流量|unusual traffic|Our systems have detected|我不是機器人|reCAPTCHA|請確認您不是機器人|驗證您是人類|verify you(?:'re| are) (?:a )?human/i;
  var EMPTY_RE = /找不到.{0,6}航班|沒有找到.{0,6}航班|查無.{0,6}航班|No flights found|no results found/i;

  // ── 工具:base64url(ES5 安全、unicode-safe,演算法與書籤逐字相同)────────
  function b64url(s) {
    return btoa(unescape(encodeURIComponent(s)))
      .replace(/\+/g, "-")
      .replace(/\//g, "_")
      .replace(/=+$/, "");
  }

  // ── 畫面右下角小提示(純 DOM API + textContent,不碰 innerHTML)───────────
  function hint(msg, kind, keep, onClick) {
    try {
      var bg = kind === "bad" ? "#A6402F" : (kind === "act" ? "#9C6B1F" : "#221D17");
      var d = document.createElement("div");
      d.textContent = msg;
      d.style.cssText =
        "position:fixed;right:18px;bottom:18px;z-index:2147483647;" +
        "background:" + bg + ";color:#fff;padding:10px 16px;border-radius:999px;" +
        "font:14px/1.4 system-ui,-apple-system,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.3);" +
        "max-width:70vw;" + (onClick ? "cursor:pointer;" : "pointer-events:none;");
      if (onClick) {
        d.addEventListener("click", function () {
          try { d.parentNode.removeChild(d); } catch (e) {}
          onClick();
        });
      }
      (document.body || document.documentElement).appendChild(d);
      if (!keep) {
        setTimeout(function () { try { d.parentNode.removeChild(d); } catch (e) {} }, 2000);
      }
      return d;
    } catch (e) { return null; }
  }

  /* ═══════════════════════════════════════════════════════════════════════
   * A. 跑在工具頁(cuijian-home-tw.web.app/flights/outstation.html)
   *    ① 掛旗標讓工具知道「腳本裝好了」,可以顯示全自動模式
   *    ② 把送票價用的中繼分頁關掉(工具端自己 close 不一定關得掉)
   * ═══════════════════════════════════════════════════════════════════════ */
  function runOnTool() {
    // ① 腳本存在旗標:同分頁同文件的屬性,不是跨網域資料
    try {
      document.documentElement.setAttribute("data-cuijian-userscript", VERSION);
      try {
        document.dispatchEvent(new CustomEvent("cuijian-userscript-ready", { detail: { version: VERSION } }));
      } catch (e1) {
        // 沙箱模式下 detail 物件跨世界複製可能丟例外;頁面端只讀 <html> 屬性、不讀 detail,所以不帶也行
        document.dispatchEvent(new CustomEvent("cuijian-userscript-ready"));
      }
    } catch (e) {}
    try { gmBridgeStart(); } catch (e) {}   // v2.1.16:工具頁端接 GM 信箱,轉交給頁面(見 GMRELAY 區塊)

    // ② 中繼分頁自關:只在「這個文件一開始就是帶著送票價 hash 被開出來的」才關。
    //    工具會用 history.replaceState 把 hash 清掉,所以改讀 navigation entry 的原始網址。
    try {
      var initial = "";
      try {
        var nav = performance.getEntriesByType && performance.getEntriesByType("navigation");
        if (nav && nav[0] && nav[0].name) initial = nav[0].name;
      } catch (e2) {}
      if (!initial) initial = location.href;
      if (initial.indexOf("#gfauto=") > -1 || initial.indexOf("#gfimport=") > -1) {
        setTimeout(function () { try { window.close(); } catch (e3) {} }, RELAY_CLOSE_MS);
      }
    } catch (e) {}
  }

  /* ═══════════════════════════════════════════════════════════════════════
   * B. 跑在 Google Flights 查價頁
   * ═══════════════════════════════════════════════════════════════════════ */

  // ── ⚡ 直送:工具頁的 window 參考 + ACK 等待表 ────────────────────────────
  // 🐛 v2.1.13:握手本身就是「工具開的查價分頁」的最硬證明 —— 工具只會對**它自己 window.open 出來的**
  //   那個 window 參考送 __gfping,而且收訊端逐字比對 ev.origin === TOOL_ORIGIN。
  //   舊版沒把它算成啟動訊號,只認 window.name / document.referrer:
  //     · Chrome 跨站導覽會清掉 window.name
  //     · referrer 會被「瀏覽器隱私設定 / 擴充功能 / 中途轉址」剝成空字串
  //   兩個都沒了就判定「不是工具開的」→ 分頁開了卻整個不動作(業主在另一台電腦實測)。
  //   ⚠️ 沒有放寬範圍:純瀏覽頁(cjview)與已解除待命(disarm)的頁仍然先被擋掉,握手也救不回來。
  var pingArmed = false;
  var manualArmed = false;   // 使用者在診斷泡泡上按「點我立刻送回」
  var onArmedRetry = null;   // 由 runOnGoogle 掛上:握手遲到時重新評估這一頁(見下方 maybeStart)
  var toolWin = null;   // 從 { __gfping } 的 event.source 取得。這只是一個 window 參考,本身不含任何資料
  var toolTok = "";     // 工具給這個分頁槽的不透明隨機字串;只原樣回傳,讓工具分得清「舊/新頁面」
  var ackWaiters = {};  // rid -> 收到 ACK 時要跑的 callback

  // 只認 TOOL_ORIGIN 來的訊息(逐字比對 ev.origin),其它任何來源(含 Google 自己的 iframe)一律略過。
  // ⚠️ 這個監聽器**只接收、不送出**:它做的事就是記住「工具頁是哪個 window」與「哪一筆被收下了」。
  //    它不會因為收到訊息就去讀畫面或送出任何東西 —— 送出仍然只發生在 isArmed() 通過、
  //    且就緒偵測判定「票價載好了」之後。**啟動範圍與 v1.0.1 完全相同,沒有因為直送而放寬。**
  function bindToolMessages() {
    try {
      window.addEventListener("message", function (ev) {
        try {
          if (ev.origin !== TOOL_ORIGIN) return;
          var d = ev.data;
          if (!d || typeof d !== "object") return;
          if (d.__gfping === 1) {  // 握手:記住工具頁的 window,以及這個分頁槽的 tok(原樣回傳用)
            toolWin = ev.source;
            toolTok = (typeof d.tok === "string") ? d.tok.slice(0, TOK_MAX) : "";
            if (!pingArmed) {
              pingArmed = true;
              try { if (window.sessionStorage) sessionStorage.setItem(ARMED_KEY, String(Date.now())); } catch (e2) {}
              // 握手可能晚於首次判定(第一次 isArmed() 已經回 false 並記下這個網址),補跑一次
              try { clearDiag(); } catch (e4) {}   // v2.1.15:握手到了 = 不再是「沒認出」,警報立刻收掉
              try { if (typeof onArmedRetry === "function") onArmedRetry(); } catch (e3) {}
            }
            return;
          }
          // 🛑 v2.1.10:工具跑完/停止時主動「解除待命」—— 這個查價分頁從此不再送回任何東西,
          //   使用者可以留著它自己繼續查機票。舊版只能等 15 分鐘黏性旗標自然過期,期間在同一個
          //   分頁裡繼續搜尋就會被送回工具(業主回報「只是在看 Google Flights,卻自己跳出比價工具頁」)。
          if (d.__gfdisarm === 1) {
            disarmed = true;
            try { if (window.sessionStorage) sessionStorage.removeItem(ARMED_KEY); } catch (e2) {}
            try { window.name = ""; } catch (e2) {} // 清掉 cuijian_lookup_N,重新載入也不會又被認成工具開的
            return;
          }
          if (d.__gfimportAck === 1) {
            var rid = (typeof d.rid === "string") ? d.rid : "";
            var cb = ackWaiters[rid];
            if (cb) { delete ackWaiters[rid]; cb(); }
          }
        } catch (e) {}
      }, false);
    } catch (e) {}
  }

  // 只在「由精靈/書籤流程開啟的查價頁」啟動(隱私關鍵)。
  // 網址必須帶查價參數 tfs=,而且至少要有一個「來自工具」的訊號:
  //   (a) window.name 是工具的具名查價分頁:'cuijian_lookup',或並行時的 'cuijian_lookup_1'…'_5'
  //       (⚠️ 多認 _N 只是因為工具並行時會給每個查價分頁不同名字,判斷的仍是同一件事
  //        「這是工具開的查價分頁」,啟動範圍沒有變寬)
  //   (b) document.referrer 正好是本站(整個 origin 逐字比對,不是前綴比對——
  //       前綴比對會讓 https://cuijian-home-tw.web.app.假網域.com 也算「來自本站」)
  //   (c) 這個查價分頁 15 分鐘內曾經被 (a) 或 (b) 認證過(sessionStorage 旗標;
  //       Chrome 跨站導覽會清掉 window.name、某些轉址會吃掉 referrer,
  //       靠它讓同一個查價分頁後續每一格都還認得出來)。
  //       ⚠️ 這條有代價:如果你把那個查價分頁留著、自己在裡面繼續查機票,
  //          15 分鐘內仍會被自動送回工具(送回時右下角一定會顯示提示,不會無聲)。
  //          不想要就關掉那個分頁——旗標跟著分頁一起消失。
  //          ⚠️ 並行時工具會開好幾個查價分頁(最多 5 個),所以跑完後可能留下**好幾個**這樣的分頁,
  //             一個一個關掉才會全部失效。
  //       黏性旗標只能被 (a)(b) 續期,自己不會延壽;過期即自我清除。
  // 三者皆不成立 → 腳本完全不動作,你在自己的分頁逛 Google Flights 不會被送出任何東西。
  function isArmed() {
    var href = "";
    try { href = location.href || ""; } catch (e) { return false; }
    if (disarmed) return false;                 // 🛑 工具已通知「跑完了,別再送」
    // 🙈 v2.1.9 / v2.1.10:網址帶 #cjview = 工具頁上「我只是想自己看這一天」的連結(區間掃描表的 🔍)。
    //   這種頁完全不送回票價,也就不會為了送回而開中繼分頁 —— 業主回報「按 🔍 只是想看,
    //   卻跳出一個不相干的頁面」的根因(沒握到手 → 走中繼退路 → 開 outstation 送回)。
    //   v2.1.10 起把它記進 sessionStorage:Google 單頁應用改篩選會把 hash 洗掉,只看當下網址會失效。
    try {
      if (href.indexOf("cjview") > -1) {
        if (window.sessionStorage) sessionStorage.setItem(VIEWONLY_KEY, "1");
        return false;
      }
      if (window.sessionStorage && sessionStorage.getItem(VIEWONLY_KEY) === "1") return false;
    } catch (e) {
      if (href.indexOf("cjview") > -1) return false;   // sessionStorage 不可用時至少當下這頁擋掉
    }

    // 🔒 v2.1.15:網址**必須**帶 tfs=(= 由工具用 protobuf 組出來的查價網址)。
    //   這一條是安裝頁與隱私說明白紙黑字承諾的啟動範圍下限,也是 release gate 明文要求的不變量;
    //   上面第 (c) 條的黏性旗標少了它,同一個分頁裡**任何** Google 頁面都會被算成「工具開的查價頁」。
    //   (註解從 v1.0.1 起就寫著「網址必須帶查價參數 tfs=」,但程式碼漏了這道實作 —— 這版補回來。)
    if (!/[?&#]tfs=/.test(href)) return false;

    // (a)(b)(d):這一次載入確實由工具發動的硬訊號
    var fromTool = pingArmed || manualArmed;   // (d) 收過工具的握手(ev.origin 已逐字比對過) /(e) 使用者自己按了診斷泡泡上的鈕
    try {
      var wn = window.name || "";
      if (wn === LOOKUP_NAME || wn.indexOf(LOOKUP_NAME + "_") === 0) fromTool = true;
      // 區間掃描的分頁名(cuijian_scan_1…_5)。⚠️ 手動 🔍 的 'cuijian_scan_view' 與接力的 'cuijian_relay'
      //   刻意**不**列入:那兩種頁是給人自己看的,本來就帶 cjview 標記、不送回票價也不自關。
      if (!fromTool && /^cuijian_scan_[1-9][0-9]?$/.test(wn)) fromTool = true;
    } catch (e) {}
    try {
      var ref = document.referrer || "";
      if (!fromTool && (ref === TOOL_ORIGIN || ref.indexOf(TOOL_ORIGIN + "/") === 0)) fromTool = true;
    } catch (e) {}

    if (fromTool) {
      try { if (window.sessionStorage) sessionStorage.setItem(ARMED_KEY, String(Date.now())); } catch (e) {}
      return true;
    }

    // (c):同一個查價分頁的黏性旗標,有期限、過期自清
    try {
      if (window.sessionStorage) {
        var ts = Number(sessionStorage.getItem(ARMED_KEY));
        if (isFinite(ts) && ts > 0 && (Date.now() - ts) <= ARMED_TTL_MS) return true;
        if (ts) { try { sessionStorage.removeItem(ARMED_KEY); } catch (e2) {} }
      }
    } catch (e) {}
    return false;
  }

  function pageText() {
    try { return (document.body && document.body.innerText) ? document.body.innerText : ""; }
    catch (e) { return ""; }
  }

  function countPrices(t) {
    if (!t) return 0;
    PRICE_RE.lastIndex = 0;
    var m = t.match(PRICE_RE);
    return m ? m.length : 0;
  }

  // 送回通道:開一個中繼分頁(與書籤同一條路)。
  // 優先 GM_openInTab:腳本是自動觸發、沒有使用者手勢,裸 window.open 會被彈窗攔截器擋掉;
  // GM_openInTab 由擴充功能開分頁、不搶焦點。都不行時退回「點一下才送」的小按鈕,絕不無聲失敗。
  // ⚡ v2.1.0:rid 是直送用過的同一個編號(只有 a-z0-9 字元,不需編碼)。工具用它認出「這是剛才那一筆的
  //    重送」而不重複套用 —— 直送其實已經寫進去、只是收據沒回到本頁時會發生。沒有 rid(從沒直送過)就不帶。
  // 🆕 v2.1.16:從查價網址取出 tfs 參數(base64url)。只接受 [A-Za-z0-9_-]{1,800},其它一律當作沒有(不帶、不報錯)。
  //   這個值不是機密:它就是工具自己組出來放進查價網址的「航線+日期+艙等」編碼(protobuf→base64url)。
  function tfsOf(href) {
    try {
      var m = /[?&#]tfs=([^&#]*)/.exec(String(href || ""));
      return (m && /^[A-Za-z0-9_-]{1,800}$/.test(m[1])) ? m[1] : "";
    } catch (e) { return ""; }
  }
  function tfsTail(href) {
    var t = tfsOf(href);
    return t ? ("&tfs=" + t) : "";
  }
  function relayUrl(text, src, rid) {
    var tail = (typeof rid === "string" && /^[a-z0-9]{1,64}$/.test(rid)) ? ("&rid=" + rid) : "";
    if (RETURN_MODE === "gfimport") {
      tail += tfsTail(src);   // v2.1.16:帶上本頁的 tfs,讓工具在中繼路徑也認得出是哪一格(見 tfsOf)
      return TOOL_URL + "#gfimport=" + b64url(text) + "&src=userscript" + tail;
    }
    return TOOL_URL + "#gfauto=v1." + b64url(src) + "." + b64url(text);
  }

  function openRelay(url) {
    try {
      if (typeof GM_openInTab === "function") {
        GM_openInTab(url, { active: false, insert: true });
        return true;
      }
    } catch (e) {}
    var w = null;
    try { w = window.open(url, RELAY_TAB); } catch (e2) { w = null; }
    return !!w;
  }

  var sentUrls = {}; // 一次性:同一個查價網址只送一次

  // 🐛 v2.1.6:三種「拿不到票價」的情況,舊版只在畫面上提示、**什麼都不送回工具** ——
  //   工具端於是只能一路空等到逾時。整張表 161 格裡像下地島(SHI)、石垣(ISG)這種薄航線很多,
  //   每格白等好幾分鐘,整輪等於卡死(業主實測回報)。
  //   改成主動回報,工具收到就立刻記狀態、跳下一格:
  //     noflights = Google 明說找不到航班(真的沒班次)→ 工具記「沒有三家的班次」
  //     blocked   = 被要求人工驗證                    → 工具記「沒抓到內容,可稍後重跑」
  //     timeout   = 腳本自己 25 秒讀不到票價           → 同上
  //   ⚠️ 只送「原因」這個白名單字串,不送任何頁面內容 —— 隱私範圍沒有變寬。
  function reportFail(reason, url) {
    if (sentUrls[url]) return;          // 這一格已經送過東西了,不要再補一筆
    sentUrls[url] = 1;
    var rid = "f" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
    var sent = false;
    if (toolWin) {
      try {
        toolWin.postMessage({ __gfimport: 1, text: "", fail: reason, src: "userscript", rid: rid, tok: toolTok }, TOOL_ORIGIN);
        sent = true;                     // 直送即可,不必等 ACK(工具端沒收到最多回到逾時,不會更糟)
      } catch (e) {}
    }
    if (!sent) {
      // v2.1.16:失敗回報也先走 GM 信箱(收尾——提示/自關/退回中繼分頁——都在 deliverViaGm 的 callback 裡,不在這裡)
      deliverViaGm("", url, rid, reason, function () {
        var ok2 = false;
        try { openRelay(TOOL_URL + "#gfimport=&src=userscript&rid=" + rid + "&fail=" + reason + tfsTail(url)); ok2 = true; } catch (e) {}
        if (ok2 && reason !== "blocked") closeSelfSoon();
      });
      return;
    }
    // v2.1.6:查無航班/逾時的分頁已無資訊價值,比照成功送回自我關閉(否則薄航線一輪下來
    // 失敗分頁照樣堆滿——v2.1.2 要消滅的正是分頁堆積)。blocked 刻意不關:
    // Google 要求人工驗證,關掉會把「被擋住了」藏起來,而且使用者可能要親自過驗證才能繼續。
    if (sent && reason !== "blocked") closeSelfSoon();
  }

  // 🪟 v2.1.2:送出成功後把「自己這個查價分頁」關掉。
  //   為什麼需要:比價工具是用 window.open 開查價分頁,原本想靠具名分頁重用同一個分頁,
  //   但 2026-07-26 在 Brave 實測發現**跨網域的 window 參考會被整個閹掉** ——
  //   `closed` 恆為 true、`location.href` 賦值靜默無效、`postMessage` 也被丟棄。
  //   工具端因此永遠找不到舊分頁、每一格都得開新的 → 一列 7 格開 7 個、整張表 161 格開 161 個。
  //   由「分頁自己關自己」繞過這個限制:window.close() 只對「由腳本開出來的分頁」有效,
  //   而查價分頁正是工具用 window.open 開的,所以關得掉;不是的話就靜默無效,不會有副作用。
  //   ⚠️ 一定要**延遲**再關:①中繼分頁是這一頁 window.open 出去的,關太快在某些瀏覽器上會連帶
  //   影響剛開的子分頁 ②使用者要看得到那行 ✅ 提示,否則會以為什麼都沒發生。
  var SELF_CLOSE_MS = 1600;
  // 🪟 v2.1.8:只有「工具握手(ping)過的分頁」= 精靈/掃描自己開的查價頁才自動關。
  //   使用者手動點 🔍 開來看的頁沒有人 ping 它(toolTok 恆為空),送完票價後**留著給人看**——
  //   業主實測回報「按 🔍 想看明細,頁面自己關起來」的根因就是舊版無差別自關。
  // 🪟 v2.1.12:自關與否看「這頁是不是純瀏覽頁」,不再看握手(toolTok)。
  //   v2.1.8 曾改成「有握手才關」,但 Brave 會把跨網域分頁參考整個切斷 —— 工具的握手訊息根本到不了,
  //   結果掃描開的分頁在 Brave 上**永遠不關**、一路堆積(業主實測)。
  //   正確的分界其實是 v2.1.9 就建立的:🔍 手動看的頁帶 cjview 標記、**從頭就不會送回票價**,
  //   所以「會走到這裡(=已送回票價)的頁」必然是掃描/精靈開的 → 送完就關,握手只是加分不是門檻。
  function isViewOnly() {
    try { if (window.sessionStorage && sessionStorage.getItem(VIEWONLY_KEY) === "1") return true; } catch (e) {}
    try { return location.href.indexOf("cjview") > -1; } catch (e) { return false; }
  }
  function closeSelfSoon() {
    if (isViewOnly()) return;   // 手動看的頁:留著
    setTimeout(function () { try { window.close(); } catch (e) {} }, SELF_CLOSE_MS);
  }

  /* ═══════════════════════════════════════════════════════════════════════
   * GMRELAY(v2.1.16):Tampermonkey 擴充功能儲存區信箱 —— 取代「每格多開一個 outstation 中繼分頁」
   *   查價分頁(Google)→ gmSend 寫 cj_gm_relay_<0..15> → 工具頁(同一支腳本)gmBridgeStart 收到 →
   *   同源 postMessage {__cjgm:1,t:"offer"} 給頁面 → 頁面認得出是哪一格就回 "want" → 腳本搶認領權(cj_gm_claim_<rid>)
   *   → 搶到才回 "go" 給頁面套用並刪信箱(= 收據;查價分頁看到信箱被別的分頁刪掉就知道送達了)。
   *   沒人認領 → 信箱原封不動 → 查價分頁 4 秒後自己收回並退回中繼分頁。
   * 🔒 只在 TOOL_ORIGIN 的頁面之間轉交、postMessage 的 targetOrigin 寫死 TOOL_ORIGIN;信箱內容視為不可信輸入,
   *    逐欄位白名單過濾(rid / tfs / tok / fail / 長度)後才交給頁面,頁面端還會再驗一次。
   * ═══════════════════════════════════════════════════════════════════════ */
  // <GMRELAY>
  var GM_BOX_PREFIX = "cj_gm_relay_";
  var GM_CLAIM_PREFIX = "cj_gm_claim_";
  var GM_BOXES = 16;                 // 信箱數(同時最多 16 筆在途,遠大於並行上限 6)
  var GM_TTL_MS = 120000;            // 信箱/認領記號壽命:超過 2 分鐘一律當垃圾清掉
  var GM_ACK_WAIT_MS = 4000;         // 等工具頁認領+刪信箱的上限;逾時 → 退回中繼分頁
  var GM_VERIFY_MS = 60;             // 寫入後讀回確認(防兩個查價分頁同時寫到同一格信箱)
  var GM_PENDING_MS = 10000;         // 工具頁端:offer 給頁面後最多等頁面回 want 這麼久
  var GM_MAX_TEXT = 200 * 1024;
  var GM_FAILS = ["noflights", "blocked", "timeout"];

  function gmOk() {
    return typeof GM_setValue === "function" && typeof GM_getValue === "function" &&
           typeof GM_deleteValue === "function" && typeof GM_addValueChangeListener === "function";
  }
  function gmParse(raw) {
    try { var o = JSON.parse(raw); return (o && typeof o === "object") ? o : null; } catch (e) { return null; }
  }
  function gmFresh(o) {
    var t = Number(o && o.ts);
    return isFinite(t) && Math.abs(Date.now() - t) <= GM_TTL_MS;
  }
  // 信箱內容逐欄位白名單;不合格回 null
  function gmClean(o) {
    if (!o || typeof o !== "object") return null;
    var rid = (typeof o.rid === "string" && /^[a-z0-9]{1,64}$/.test(o.rid)) ? o.rid : "";
    if (!rid) return null;
    var text = (typeof o.text === "string") ? o.text.slice(0, GM_MAX_TEXT) : "";
    var fail = (typeof o.fail === "string" && GM_FAILS.indexOf(o.fail) >= 0) ? o.fail : "";
    if (!text.replace(/\s+/g, "") && !fail) return null;
    var tfs = (typeof o.tfs === "string" && /^[A-Za-z0-9_-]{1,800}$/.test(o.tfs)) ? o.tfs : "";
    var tok = (typeof o.tok === "string" && /^[A-Za-z0-9_-]{1,32}$/.test(o.tok)) ? o.tok : "";
    return { rid: rid, text: text, src: "userscript", tfs: tfs, tok: tok, fail: fail };
  }
  // 清掉過期的信箱與認領記號(靠 GM_listValues;沒有這個 API 就略過,逾時的信箱在 gmSend 挑格子時也會被覆蓋)
  function gmCleanup() {
    try {
      if (typeof GM_listValues !== "function") return;
      var keys = GM_listValues() || [];
      for (var i = 0; i < keys.length; i++) {
        var k = String(keys[i]);
        if (k.indexOf(GM_BOX_PREFIX) === 0) {
          var o = gmParse(GM_getValue(k, ""));
          if (!o || !gmFresh(o)) GM_deleteValue(k);
        } else if (k.indexOf(GM_CLAIM_PREFIX) === 0) {
          var t = Number(String(GM_getValue(k, "")).split("|")[0]);
          if (!isFinite(t) || Math.abs(Date.now() - t) > GM_TTL_MS) GM_deleteValue(k);
        }
      }
    } catch (e) {}
  }
  // 查價分頁端:把 payload 放進一格空信箱,等工具頁刪掉它(= 收到了)。done(true/false) 只會呼叫一次。
  function gmSend(p, done) {
    var finished = false, timer = null, key = "", attempt = 0, tries = 0, raw = "";
    function finish(ok) {
      if (finished) return;
      finished = true;
      if (timer) { clearTimeout(timer); timer = null; }
      if (!ok) { try { if (key && GM_getValue(key, "") === raw) GM_deleteValue(key); } catch (e) {} }   // 收回自己的信,不留垃圾
      try { done(ok); } catch (e) {}
    }
    try {
      gmCleanup();
      raw = JSON.stringify({ text: p.text, src: "userscript", rid: p.rid, tfs: p.tfs || "", tok: p.tok || "", fail: p.fail || "", ts: Date.now() });
    } catch (e) { finish(false); return; }
    var start = Math.floor(Math.random() * GM_BOXES);
    function tryNext() {
      if (finished) return;
      if (tries >= GM_BOXES) { finish(false); return; }
      key = GM_BOX_PREFIX + ((start + tries++) % GM_BOXES);
      var cur = "";
      try { cur = GM_getValue(key, ""); } catch (e) { finish(false); return; }
      if (cur) { var o = gmParse(cur); if (o && gmFresh(o)) { tryNext(); return; } }   // 這格有別人在途的信 → 換一格
      var my = ++attempt, k = key;
      try {
        GM_setValue(k, raw);
        GM_addValueChangeListener(k, function (name, oldV, newV, remote) {
          // 只認「別的分頁把我這封信刪掉」;信箱被別人覆寫後再刪,oldV 不是我的信,不算收據
          if (my !== attempt || finished) return;
          if (remote && oldV === raw && (newV === undefined || newV === null || newV === "")) finish(true);
        });
      } catch (e) { finish(false); return; }
      setTimeout(function () {
        if (finished || my !== attempt) return;
        var back = ""; try { back = GM_getValue(k, ""); } catch (e) {}
        if (back !== raw) { attempt++; tryNext(); return; }   // 兩個分頁同時挑中這一格,我被蓋掉了 → 換一格
        timer = setTimeout(function () { finish(false); }, GM_ACK_WAIT_MS);
      }, GM_VERIFY_MS);
    }
    tryNext();
  }
  // 工具頁端:接 GM 信箱 → 同源 postMessage 轉交頁面 → 頁面 want → 認領 → go + 刪信箱
  function gmBridgeStart() {
    if (!gmOk()) return;
    var tabId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
    var pending = {};   // rid -> { key, raw, t }
    function post(m) { try { window.postMessage(m, TOOL_ORIGIN); } catch (e) {} }
    function offer(key, raw) {
      try {
        var o = gmParse(raw);
        if (!o || !gmFresh(o)) return;
        var c = gmClean(o);
        if (!c) return;
        var now = Date.now();
        for (var r in pending) { if (pending.hasOwnProperty(r) && now - pending[r].t > GM_PENDING_MS) delete pending[r]; }
        if (pending[c.rid]) return;
        pending[c.rid] = { key: key, raw: raw, t: now };
        post({ __cjgm: 1, t: "offer", rid: c.rid, text: c.text, src: c.src, tfs: c.tfs, tok: c.tok, fail: c.fail });
      } catch (e) {}
    }
    window.addEventListener("message", function (ev) {
      try {
        if (ev.origin !== TOOL_ORIGIN) return;
        var d = ev.data;
        if (!d || typeof d !== "object" || d.__cjgm !== 1 || d.t !== "want") return;
        var rid = (typeof d.rid === "string") ? d.rid : "";
        var pd = pending[rid];
        if (!pd) return;
        delete pending[rid];
        var ckey = GM_CLAIM_PREFIX + rid;
        if (GM_getValue(ckey, "")) return;                       // 別的工具頁分頁已經在認領了
        var mine = Date.now() + "|" + tabId;
        GM_setValue(ckey, mine);
        setTimeout(function () {
          try {
            if (GM_getValue(ckey, "") !== mine) return;           // 輸了:什麼都不做,也**不刪**信箱(讓贏的那個分頁去刪)
            post({ __cjgm: 1, t: "go", rid: rid });
            if (GM_getValue(pd.key, "") === pd.raw) GM_deleteValue(pd.key);   // 收據:刪信箱(只刪我認領的那一封)
          } catch (e) {}
        }, GM_VERIFY_MS);
      } catch (e) {}
    }, false);
    gmCleanup();
    for (var i = 0; i < GM_BOXES; i++) {
      (function (k) {
        GM_addValueChangeListener(k, function (name, oldV, newV, remote) {
          if (remote && newV) offer(name, newV);
        });
        var cur = ""; try { cur = GM_getValue(k, ""); } catch (e) {}
        if (cur) offer(k, cur);   // 頁面晚於信箱載入(例如重新整理)也要接得到
      })(GM_BOX_PREFIX + i);
    }
  }
  // 送出入口:GM 可用就走 GM 信箱;成功 → 提示並自關;GM 不可用/沒人認領 → onGiveUp()(呼叫端退回中繼分頁)
  function deliverViaGm(text, url, rid, fail, onGiveUp) {
    if (!gmOk()) { onGiveUp(); return; }
    var id = (typeof rid === "string" && /^[a-z0-9]{1,64}$/.test(rid)) ? rid : ("g" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8));
    gmSend({ rid: id, text: text, tfs: tfsOf(url), tok: toolTok, fail: fail || "" }, function (ok) {
      if (ok) {
        if (!fail) hint("✅ 已自動送回票價(沒有開新分頁)", "ok");
        if (fail !== "blocked") closeSelfSoon();
      } else {
        onGiveUp();
      }
    });
  }
  // </GMRELAY>

  // ⚡ 直送:postMessage 給工具頁並等 ACK。
  // 回 true  = 已經送出去了(成不成功由 ACK 或逾時決定;逾時會自己退回中繼,不會漏)。
  // 回 false = 根本送不出去(還沒握到手/分頁已關)→ 由呼叫端立刻走中繼退路。
  function sendDirect(text, url) {
    if (!toolWin) return false;
    var rid = "p" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
    var settled = false, timer = null;
    ackWaiters[rid] = function () {
      if (settled) return;
      settled = true;
      if (timer) clearTimeout(timer);
      hint("✅ 已自動送回票價(沒有開新分頁)", "ok");
      closeSelfSoon();   // 🪟 v2.1.2:票價已交出去,這個查價分頁沒有留著的理由
    };
    try {
      // tok 原樣回傳(可能是空字串:還沒收到帶 tok 的握手)。工具靠它分辨「同一個分頁的舊/新頁面」。
      toolWin.postMessage({ __gfimport: 1, text: text, src: "userscript", rid: rid, tok: toolTok }, TOOL_ORIGIN);
    } catch (e) {
      delete ackWaiters[rid];
      return false;
    }
    timer = setTimeout(function () {
      if (settled) return;
      settled = true;
      delete ackWaiters[rid];
      toolWin = null;                    // 這個參考顯然沒人在聽,下一格別再白等 1.5 秒
      // 等不到 ACK → v2.1.16:先走 GM 信箱(不開新分頁);沒人認領才退回中繼分頁做法,絕不漏資料;帶上同一個 rid 讓工具去重
      deliverViaGm(text, url, rid, "", function () { openRelayFallback(text, url, rid); });
    }, ACK_WAIT_MS);
    return true;
  }

  // 退路(= v1.0.x 原本的做法,只多帶一個 rid 供工具去重):開中繼分頁把票價交給工具,工具收到後那個分頁會自己關掉。
  function openRelayFallback(text, url, rid) {
    var payloadUrl;
    try { payloadUrl = relayUrl(text, url, rid); }
    catch (e) { hint("⚠️ 票價編碼失敗,未送回", "bad"); return; }

    if (openRelay(payloadUrl)) {
      hint("✅ 已送回票價(直送沒接上,改開中繼分頁)", "ok");
      closeSelfSoon();   // 🪟 v2.1.2:Brave 上直送必定接不上,這條才是實際路徑 —— 不自己關就會累積成一堆分頁
      return;
    }
    // 被彈窗攔截:留一顆可點的小按鈕,使用者點一下就有手勢、一定開得起來
    hint("📥 瀏覽器擋下自動送回,點我送回票價", "act", true, function () {
      try { window.open(payloadUrl, RELAY_TAB); } catch (e) {}
    });
  }

  function sendBack(text, url) {
    if (sentUrls[url]) return;
    sentUrls[url] = 1;
    clearDiag();   // v2.1.15:這一格要送出去了,先前「沒認出」的警報不可以留在畫面上
    var clipped = String(text).slice(0, MAX_TEXT);
    if (sendDirect(clipped, url)) return; // 已握到手 → 直送(等 ACK;逾時自動退回中繼)
    // 沒握到手(Brave / 工具頁版本太舊 / 還沒 ping 過來)→ v2.1.16:GM 信箱;沒人認領才開中繼分頁
    deliverViaGm(clipped, url, "", "", function () { openRelayFallback(clipped, url); });
  }

  // ── 就緒輪詢(每 POLL_MS 取樣;價格字樣數連續 STABLE_NEEDED 次相同且 ≥1,
  //    且 DOM 已靜默 QUIET_MS,才視為結果載好)──────────────────────────────
  function watchOnce(url) {
    var lastSig = null, stable = 0, t0 = Date.now(), timer = null, mo = null;
    // 🕒 v2.1.6:四段票(長行程)的結果頁載入慢、會分批渲染 —— 舊的「穩定 1 秒就算載完」
    //    會太早抓價關分頁(業主實測:還沒跑完就關了,抓到的最低價可能不完整)。
    //    用 tfs 長度當航段數代理:四段 ≈190 字元、兩段 ≈100;門檻 140。
    var tfsm = /[?&]tfs=([A-Za-z0-9_-]+)/.exec(url);
    var longTrip = !!(tfsm && tfsm[1].length > 140);
    // 🕒 v2.1.7:短行程(單程逐日掃描)也會分批渲染 —— 業主實測「還在跑就被關」,
    //    1 秒穩定會卡在「最佳航班已出、其他航班還沒出」的批間空檔提早送。
    //    短行程也加嚴:連續 1.5 秒穩定 + 至少看滿 4 秒。
    var stableNeed = longTrip ? 5 : 4;               // 長行程 2.5 秒 / 短行程 2 秒「價格內容」穩定才算載完
    var minDwell   = longTrip ? 8000 : 4000;         // 且至少看滿 8 秒 / 4 秒,避免只抓到前半批結果
    var tmoMs      = longTrip ? 40000 : 35000;       // v2.1.11:價格就地更新可能拖幾秒,逾時一律放寬(逾時=整格作廢,寧可多等)
    var lastMut = Date.now();

    try {
      mo = new MutationObserver(function () { lastMut = Date.now(); });
      mo.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
    } catch (e) { mo = null; }

    function stop() {
      if (timer) { clearInterval(timer); timer = null; }
      if (mo) { try { mo.disconnect(); } catch (e) {} mo = null; }
    }

    function tick() {
      try {
        if (sentUrls[url]) { stop(); return; }
        if (location.href !== url) { stop(); return; } // SPA 換頁 → 交給 URL 監看器重開一輪

        var t = pageText();

        // 失敗辨識:驗證/錯誤頁 → 不送,只在本頁提示
        if (BLOCK_RE.test(t)) { stop(); reportFail("blocked", url); hint("⚠️ Google 要求人工驗證 —— 已通知工具跳過這一格", "bad"); return; }
        if (EMPTY_RE.test(t) && countPrices(t) === 0) { stop(); reportFail("noflights", url); hint("這個航段查不到航班 —— 已通知工具跳過這一格", "ok"); return; }

        // 💰 v2.1.11:穩定判定從「價格**個數**沒變」改成「價格**內容**逐字沒變」。
        //   業主實測抓包:Google 會先渲染一批初始價格、幾秒後**就地把同一列的數字更新成更便宜的**
        //   (個數不變、數值變了),舊判定會被騙過而提早送出 —— 紀錄 34,679、實頁其實是 29,945。
        //   改成把整頁所有價格字樣串起來比對,任何一個數字還在變就繼續等。
        PRICE_RE.lastIndex = 0;
        var toks = t.match(PRICE_RE) || [];
        var n = toks.length;
        var sig = toks.join("|");
        if (n >= MIN_PRICES && sig === lastSig) stable++;
        else stable = (n >= MIN_PRICES) ? 1 : 0;
        lastSig = sig;

        if (stable >= stableNeed && (Date.now() - lastMut) >= QUIET_MS && (Date.now() - t0) >= minDwell) {
          stop();
          sendBack(t, url);
          return;
        }

        if (Date.now() - t0 > tmoMs) {
          stop();
          reportFail("timeout", url);
          hint("⚠️ " + Math.round(tmoMs / 1000) + " 秒內沒讀到票價 —— 已通知工具跳過這一格", "bad");
        }
      } catch (e) {
        stop(); // 任何意外一律停手,絕不影響使用者瀏覽 Google
      }
    }

    timer = setInterval(tick, POLL_MS);
    tick();
  }

  // 🔎 v2.1.14:武裝失敗不再靜默。
  //   這個腳本最糟的失效模式是「什麼都不做」—— 分頁開了、票價也載好了,但 isArmed() 沒過,
  //   畫面上完全沒有任何痕跡,使用者只看到「分頁不關也不填表」,查不出是哪一關卡住。
  //   規矩是「絕不無聲失敗」,所以只要網址帶 tfs=(= 由工具組出來的查價網址)卻沒武裝,
  //   就把三道訊號的現況顯示出來,並給一顆「我是掃描開的」手動武裝鈕當逃生口。
  //   ⚠️ 只顯示「有/無」三個布林值,不顯示網址、不顯示任何頁面內容。
  var diagShown = "";
  var diagEl = null;
  // 🔇 v2.1.15:診斷泡泡是「卡住了」的警報,一旦不再卡住就必須自己消失。
  //   舊版用 keep=true 貼上去之後**沒有任何路徑會把它收掉** —— 握手晚到、或這個分頁後來補送成功,
  //   畫面上仍留著一行「沒有送回票價」,而票價其實早就進系統了(業主實測回報)。
  function clearDiag() {
    diagShown = "";
    if (!diagEl) return;
    try { if (diagEl.parentNode) diagEl.parentNode.removeChild(diagEl); } catch (e) {}
    diagEl = null;
  }
  function showDiag(retry) {
    var href = "";
    try { href = location.href || ""; } catch (e) {}
    if (href.indexOf("tfs=") < 0) return;        // 不是工具組出來的查價頁 → 不打擾
    // 🔇 v2.1.15:下面四種狀態是**設計如此**,不是故障,跳警報只會嚇到人:
    //   ① disarmed:工具跑完主動解除待命(掃描收工時對每個查價分頁送 __gfdisarm,順便清掉 window.name
    //      —— 這正是「分頁名:(空)」的來源)。票價早就收齊了,這時候說「沒有送回票價」是錯的。
    //   ② 純瀏覽頁(#cjview):使用者自己按 🔍 要看的那天,本來就不送回。
    //   ③ 這個網址已經送過了。 ④ 期間內剛武裝成功(握手晚到)。
    if (disarmed) return;
    if (isViewOnly()) return;
    if (sentUrls[href]) return;
    if (isArmed()) return;
    if (diagShown === href) return;
    diagShown = href;
    var wn = "";
    try { wn = window.name || ""; } catch (e) {}
    var ref = "";
    try { ref = document.referrer || ""; } catch (e) {}
    var msg = "⚠️ 沒認出這是工具開的查價分頁,所以沒有送回票價。"
      + "分頁名:" + (wn ? wn : "(空)")
      + " · 來源:" + (ref ? ref.slice(0, 40) : "(空)")
      + " · 握手:" + (pingArmed ? "有" : "無")
      + " —— 點我立刻送回";
    diagEl = hint(msg, "act", true, function () {
      diagEl = null; diagShown = "";
      manualArmed = true; retry();
    });
  }

  function runOnGoogle() {
    bindToolMessages(); // 先掛好握手監聽:工具一開分頁就會開始 ping,晚掛會漏掉前幾次
    var current = "", watching = "", t0 = Date.now();
    function maybeStart() {
      try {
        var href = location.href;
        // 🐛 v2.1.13:舊版是「網址沒變就直接 return」—— 第一次判定沒武裝(握手還沒到)之後,
        //   同一個網址**永遠不會再被評估**,握手到了也白搭。改成記「已經在看的是哪個網址」,
        //   網址沒變但還沒開始看時仍可補開一輪(watchOnce 內部也有 sentUrls 一次性保險)。
        if (watching === href) return;
        current = href;
        if (!isArmed()) {
          // 給握手 8 秒到達的餘裕,還是沒武裝就把診斷顯示出來(每個網址只講一次)
          if (Date.now() - t0 > 8000) showDiag(maybeStart);
          return;
        }
        clearDiag();   // v2.1.15:武裝成功(握手晚到也算)→ 先前那則警報已經過時,收掉它
        if (sentUrls[href]) return;
        watching = href;
        watchOnce(href);
      } catch (e) {}
    }
    onArmedRetry = maybeStart;
    maybeStart();
    // Google Flights 是 SPA:同一個分頁換查詢時網址會變、不會重載,靠這個監看器再跑一輪
    setInterval(maybeStart, 1000);
  }

  // ── 進入點 ───────────────────────────────────────────────────────────────
  try {
    if (location.host === "www.google.com") runOnGoogle();
    else if (location.href === TOOL_ORIGIN || location.href.indexOf(TOOL_ORIGIN + "/") === 0) runOnTool();
  } catch (e) {
    /* 全程 try/catch:腳本壞掉也只是不動作,不影響任何網站正常使用 */
  }
})();

⚠️ 風險誠實說明

裝之前請接受這幾件事

腳本只在 Google Flights 查價頁與你自己的比價工具頁執行、只讀畫面文字、只送回你自己的外站票比價工具,票價文字不經任何伺服器,也不讀 cookie、登入狀態或其他個人資料。可隨時在 Tampermonkey 停用或刪除。 隱私權說明 改用書籤版