⚡ 全自動查價(進階選用)
書籤版已經夠好用,但每查一格還是要點一下書籤。這一頁提供另一種做法:裝一次 Tampermonkey 使用者腳本後,Google Flights 結果一載好就自動把票價送回外站票矩陣,連書籤都不用點。
這是選用功能,不裝完全不影響比價工具的任何功能;想省事就裝,不想裝就繼續用 📥 抓價書籤。
# 之後的片段(依瀏覽器規範不會隨請求送到任何伺服器);兩條路都不經過任何伺服器。🆕 腳本已更新到 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,之後就不用再管;不重裝就沒有下面這幾件事:
- 不再每查一格閃一個分頁。舊版每送回一格票價就開一個中繼分頁再自己關掉,查 161 格就閃 161 次;v2 直接把票價送回你原本開著的比價工具分頁,順利時完全不開新分頁。
- 查價可以多個分頁並行。因為每個查價分頁都認得「是誰開的我」,比價工具才能同時開好幾個分頁查不同格子(查價精靈面板可選 1/2/3/5,預設 3)。要 v2.1.0 才會開放:並行需要腳本回傳一串工具給的隨機亂碼(
tok)才能百分百確定票價屬於哪一格,v2.0.0 沒有這一步,所以工具只會讓它單分頁依序查(自動前進仍然正常)。 - 不會把同一筆票價寫兩次或寫到別的日期。v2.1.0 在退回中繼分頁時會帶上同一個編號(
rid),工具認得出「這是剛才那一筆的重送」。 - 直送若沒接上(工具頁版本太舊、訊息被擋),v2 會自動退回舊版的中繼分頁做法,不會漏掉票價。
- 啟動條件與隱私範圍與 v1.0.1 完全相同(含 15 分鐘會自動失效的黏性旗標),沒有因為直送或並行而放寬。
⚖️ 誠實對照:書籤版 vs 腳本版
兩種做法送回的東西完全一樣(整頁純文字 → 由比價工具自己解析),差別只在「誰來按那一下」。
| 比較項目 | 📥 書籤版(預設推薦) | ⚡ 腳本版(本頁) |
|---|---|---|
| 要安裝什麼 | 零安裝,把按鈕拖到書籤列即可 | 要先裝 Tampermonkey 瀏覽器擴充,再裝腳本 |
| 每查一格要做的事 | 在結果頁點一下書籤 | 零點擊,結果載好自動送回 |
| 查 12 格的操作次數 | 12 次點擊 | 0 次 |
| 會不會一直開分頁 | 每點一次書籤,會開一個送票價的中繼分頁(收到後自己關掉) | 順利時完全不開新分頁(v2.0.0 起票價直接送回你原本的分頁);直送沒接上時才退回開中繼分頁 |
| 能不能多個分頁並行查 | 不行——書籤一次只能在你正在看的那個分頁按,你沒辦法同時按三個分頁 | 可以,查價精靈面板可選 1/2/3/5 個分頁同時查(預設 3)。並行越多越快,但越可能被 Google 暫時擋下要求人工驗證 |
| 手機可用嗎 | 大多不行(手機沒書籤列),改用「📋 貼回票價」 | 大多不行(手機瀏覽器少有擴充) |
| 什麼時候會動作 | 完全由你決定,只有你親手點書籤的那一刻 | 只在「由比價工具開啟的查價分頁」自動動作。你另外開分頁逛 Google Flights 不會動;但那個查價分頁若不關,你在裡面自己查的結果 15 分鐘內也會被送回(見下方隱私說明) |
| Google 改版時 | 可能失效(讀不到文字) | 可能失效,而且多一層「就緒偵測」也可能誤判 → 逾時不送 |
| 要維護的東西 | 只有一顆書籤,改版時重新拖一次 | v2.1.17 起 Tampermonkey 會自動更新(舊版需手動重裝一次) |
| 建議對象 | 所有人 | 常常一次查十幾格、且能接受裝擴充的人 |
🚨 裝之前:兩個瀏覽器開關(90% 的「裝好了卻沒反應」都是這裡)
這兩個沒開,腳本裝得再對也不會動,而且不會有任何錯誤訊息
- ① 允許使用者腳本(Allow user scripts)
網址列輸入chrome://extensions(Edge 是edge://extensions,或右上角拼圖圖示 → 管理擴充功能) → 找到 Tampermonkey → 點「詳細資料」 → 往下找「允許使用者腳本」 → 打開。
沒開的症狀:查價分頁會正常開啟、Google 票價也會正常載入,但不填表、不自動關閉, 右下角什麼提示都沒有;工具端只能一路空等到逾時。Tampermonkey 控制台最上面會有一條橫幅提醒你開, 但腳本清單看起來完全正常(有裝、版本對、開關綠燈),所以很容易被當成程式壞掉。
⚠️ Chrome 更新後這個開關可能被關掉 —— 「之前明明正常、後來突然不動」幾乎都是它,請回來重開一次。 - ② 允許彈出式視窗
掃描是靠開查價分頁運作的。第一次跑時網址列右側可能出現「已封鎖彈出式視窗」圖示, 點它 → 選「一律允許…顯示彈出式視窗」。
要放行的是www.google.com(腳本退回中繼分頁時是從 Google 頁面開的), 本站也一併放行最保險。
沒開的症狀:掃描會停下來並跳出「瀏覽器擋下了查價分頁」的提示,按「繼續」也只會再被擋一次。
💡 另外:查價分頁請讓它留著別手動關。工具是重複利用同一個分頁、只是導航它(不會被攔截); 一旦被關掉就得開全新分頁,才會撞到彈窗攔截。
兩個都確認過還是沒反應的話,到 Google 查價頁看右下角:v2.1.14 起腳本會在那裡跳一顆 橘色診斷泡泡,直接寫出是哪一道訊號沒到,並且點下去就能手動送回這一格。完全沒有泡泡 = 腳本根本沒被注入,回到上面第 ① 項。
🛠 安裝三步驟
Chrome 線上應用程式商店
Edge 附加元件商店
較新版的 Chrome/Edge 預設不允許擴充功能執行使用者腳本。網址欄輸入
chrome://extensions/(Edge 是 edge://extensions/)→ 找到 Tampermonkey →
點「詳細資料」→ 打開「允許使用者腳本」(Allow user scripts)。沒開的症狀:腳本清單看起來一切正常、開關也是綠的,但查價精靈會一直停在等待、三分鐘後逾時, 而且不會有任何錯誤訊息。若 Tampermonkey 面板頂端出現藍色橫幅要你啟用,就是這一項。
cuijian-autofetch.user.js,把檔案拖進 Tampermonkey 的「公用程式 → 檔案」或直接拖到瀏覽器視窗;也可以按「📋 複製腳本」,在 Tampermonkey 面板按「新增指令碼」,全選刪掉範本後貼上並存檔(Ctrl+S)。www.google.com腳本是從 Google 頁面把票價送回比價工具的,所以瀏覽器問的是要不要允許 www.google.com 的彈出式視窗 —— 不是本站,放行本站沒有用。
做法:點網址欄那個被封鎖的圖示 → 選「一律允許 https://www.google.com 的彈出式視窗和重新導向」→ 完成。 設定一次之後就會一路自動跑完。若沒放行,腳本會在 Google 頁面右下角留一顆「點我送回票價」的按鈕, 資料不會漏掉,只是每格要多點一下。
「⚡ 一鍵安裝」是最省事的:Tampermonkey 會攔截這個 .user.js 網址、直接跳出安裝畫面,按「安裝」或「重新安裝」即可(更新時會蓋掉舊版,不會變成兩個腳本)。
⚠️ 「⬇ 下載腳本」在 Chrome 上常常不會跳安裝畫面——Chrome 預設不允許擴充功能讀取本機檔案(file://),所以拖進去只會用純文字打開。硬要用下載檔的話,得先到 chrome://extensions → Tampermonkey →「詳細資料」→ 開啟「允許存取檔案網址」。不想改設定就用上面的一鍵安裝,或用「📋 複製腳本」貼到 Tampermonkey 的「新增指令碼」。
下載檔是在你的瀏覽器本機即時產生的(檔案內容就是下面那份全文),不會經過任何伺服器。
🔒 隱私與範圍(請先看完再決定要不要裝)
這支腳本會做的事
- 只在兩種網址執行,其他任何網站都不會載入這支腳本:
https://www.google.com/travel/flights*(偵測票價、送回)與cuijian-home-tw.web.app/flights/outstation.html*(你自己的比價工具頁)。 - 在你自己的比價工具頁上,腳本只做兩件事:在網頁根節點掛一個「腳本已安裝」的記號(目前工具端還沒讀它,保留給日後版本);以及(v2.1.16 起)把查價分頁放進腳本信箱的票價轉交給這個工具頁、並把退路用的中繼分頁關掉。它不讀你的比價案內容,也不改任何資料——轉交的只是查價分頁送來的那份票價純文字。
補充說明清楚:工具切換成「⚡ 全自動模式」實際上是靠收到第一筆腳本送回的票價觸發的,不是靠這個記號。 - v2 的「直送」到底送了什麼、收了什麼:比價工具會對它自己開的查價分頁送一個握手訊息
{ __gfping: 1, tok: "s8f3k…" }——裡面沒有你的行程、日期、航點、比價案或任何金額,純粹是「要回票價就傳給我」的招手;tok是一串隨機亂碼(工具給每個查價分頁一支,與內容無關),用來讓工具分得清「同一個分頁裡的舊頁面/新頁面」,避免票價被填到錯的日期。腳本收到後只記住「工具頁是哪個分頁」與那串亂碼,再把票價文字(和原樣的亂碼)傳回去;工具回一個收據(__gfimportAck)腳本就知道不用開中繼分頁了。
兩邊都逐字比對來源網域才收(腳本只認cuijian-home-tw.web.app、工具只認www.google.com),送出時也都指定收件網域、不用萬用字元;其他網站既收不到也傳不進來。 - 只在「由比價工具開啟的查價分頁」動作:必須網址帶有查價參數
tfs=,而且要通過下列任一項認證——(a) 這個分頁是工具用具名分頁cuijian_lookup開的;(b) 來源網址正好是本站;(c) 這個分頁在 15 分鐘內曾通過 (a) 或 (b)。
(c) 的代價要說清楚:它是為了扛住「Chrome 跨站導覽會清掉分頁名稱、某些轉址會吃掉來源網址」而留的退路,副作用是——你若把那個查價分頁留著不關、自己在裡面繼續查機票,15 分鐘內這些查詢結果仍會被自動送回你的比價工具(每次送回右下角一定跳提示,不會無聲進行;超過 15 分鐘記號會自動失效清除)。不想要就關掉那個查價分頁,記號跟著分頁一起消失。
在你自己另外開的分頁逛 Google Flights,腳本完全不會動、不會送出任何東西。 - 只讀取畫面上的純文字(
document.body.innerText,最多 60000 字),不讀 cookie、不讀登入狀態、不讀其他分頁、不碰你的 Google 帳號。 - 文字只送到你自己的外站票比價工具(
cuijian-home-tw.web.app):優先走瀏覽器內建的分頁間訊息直送,其次是腳本自己的 GM 儲存區信箱(v2.1.16),最後手段才走跟書籤完全相同的中繼分頁通道;不上傳任何第三方、沒有任何分析或追蹤程式。腳本裡沒有fetch、XMLHttpRequest,也沒有eval或動態載入外部程式碼。 - 同一個查價網址只送一次;若判定失敗(Google 要求人工驗證、查無航班、25 秒內讀不到票價),就什麼都不送,只在畫面右下角提示,避免把垃圾文字灌進矩陣。
- 全程包在 try/catch:腳本壞掉也只是不動作,不會影響你正常瀏覽 Google Flights。
它要的權限:只有「自己的小儲存區」和「代開分頁」
v2.1.16 起腳本標頭的 @grant 是下面六個、而且只有這六個(發佈前檢查會逐字比對,多一個就擋下):
GM_setValue/GM_getValue/GM_deleteValue/GM_listValues/GM_addValueChangeListener:只讀寫這支腳本自己的儲存區(Tampermonkey 為每支腳本各隔一區,讀不到別支腳本、別的網站、你的 cookie 或登入狀態)。用途:查價分頁把票價放進信箱cj_gm_relay_0…15,工具頁上的同一支腳本收信後交給頁面並刪掉信箱當收據;信箱與認領記號超過 2 分鐘一律自動清除。GM_openInTab:只用在最後手段——GM 儲存區用不了、或沒有任何工具頁認領時,請 Tampermonkey 代開(開在背景、不搶焦點、不會被彈窗攔截器擋)那個會自己關掉的中繼分頁。
仍然沒有 @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 畫面文字的自製腳本,不是 Google 提供的介面,也沒有任何合作關係。Google 隨時可能改版、改版面或加上防護,腳本就會失效。
- 與書籤同等的維護風險。失效時的症狀通常是「等 25 秒後提示未送回」,此時退回書籤版或「📋 貼回票價」手動流程即可,矩陣資料不會壞。
- 就緒偵測可能誤判。腳本靠「畫面上價格字樣數連續兩次不變」判斷載好了;遇到極慢的網路或分批載入,可能提早送出(只帶到部分票價)或逾時不送。送出的資料你在矩陣裡都看得到,發現不對就用工具內的「↩ 復原」或重查一次。
- 安裝擴充本身就是一種信任。Tampermonkey 是知名擴充,但任何腳本管理器都有能力執行你安裝的腳本;請只從你信任的來源安裝腳本,並先看過全文。
- 自動更新(v2.1.17 起)。腳本帶
@updateURL/@downloadURL,指向本站的cuijian-autofetch.user.js(唯一來源,不連第三方);Tampermonkey 會定期比對版本並自動換上新版,新版若要新增權限,Tampermonkey 仍會先跳出來問你。v2.1.16 以前的版本沒有這個設定,要手動重裝一次 v2.1.17。 - 「不開分頁」是順利時,不是保證。直送需要比價工具那一頁還開著、而且已經跟查價分頁握到手。若工具頁被關掉、版本太舊,或訊息被瀏覽器/擴充擋下,腳本會等 1.5 秒後退回開中繼分頁的舊做法(該分頁送完自己關)。兩種情形都會在右下角明確顯示是哪一種,不會無聲。
- 並行不是免費的。同時開多個查價分頁確實比較快,但對 Google 來說也更像自動化行為,越容易被丟出人工驗證頁;被擋到的那一格會自動跳過並在結束時列出來,可以稍後重跑。覺得常被擋就把並行數調回 1 或 2。
另外:並行時若某一格是走「中繼分頁」退路送回來的,比價工具分辨不出那是哪一格(中繼通道沒有帶來源資訊),為了絕不寫錯格子,它會寧可不寫入——該格算未取得,重跑即可。 - 手機基本無解。行動版瀏覽器多半不支援擴充,請改用比價工具內的「📋 貼回票價」複製貼上流程。
- 走的是跟書籤同一條通道。腳本退回中繼分頁時用的是
#gfimport=…&src=userscript&rid=…,跟書籤完全相同(只多了一個來源標記讓精靈知道可以切成全自動模式,以及一個純序號rid讓工具認出重送、不重複套用;兩者都不含你的行程資料)。腳本裡另有一個保留給日後版本的RETURN_MODE = "gfauto"選項,目前線上的比價工具還看不懂它——若你自行改成"gfauto",送回的資料會被靜默丟棄(症狀:中繼分頁閃一下就關、矩陣沒變、精靈停在那一格)。除非你確定工具端已支援,否則請維持預設值。