DEVELOPMENT.md
開發者筆記
給要改這支擴充功能程式碼的人看的——單純想安裝使用的話看 README.md 就好。
本機開發(載入未封裝的擴充功能)
- Chrome 網址列輸入
chrome://extensions。 - 右上角開啟「開發人員模式」。
- 點「載入未封裝項目」,選擇這個資料夾(
answerhub-extension/)。 - 改完程式碼後,回到
chrome://extensions按重新整理即可套用——背景 service worker 跟一般分頁是分開重新整理的,改到background.js一定要點這裡的重新整理圖示,不能只 重新整理 netholiday 分頁,不然會發生「側邊欄是新版、背景還在跑舊版」的狀況(v0.7.1/ v0.7.2 真的發生過,見下方)。
架構
manifest.json— Manifest V3 設定,宣告 content script、background service worker、side panel(side_panel.default_path,沒有action.default_popup,也沒有另外的options_ui——設定是側邊欄裡的一個畫面,不是獨立頁面)。background.js— 唯一會呼叫 AnswerHub API、開背景分頁查敵方站的地方,持有 API Key,回應 content script 與側邊欄的訊息;也在啟動時設定chrome.sidePanel.setPanelBehavior,讓點工具列圖示直接開側邊欄。content.js— 在 netholiday.kh.edu.tw 頁面上偵測題目、讀取選項、決定要不要自動選取/換題/換科目/送出。netholiday-reh-scraper.js— 只在背景分頁的 netholiday.reh.tw 頁面上跑,點搜尋鍵、捲動觸發 lazy-load、讀答案,回報給background.js。similarity.js— 純函式的題目/選項比對邏輯(Dice 係數,門檻 0.8),content.js和測試共用。shared.css—sidepanel.html的設計 token 與基礎元件(輸入框、按鈕、狀態文字、警告框)。sidepanel.html/sidepanel.js— 點工具列圖示打開、會一直開著的側邊欄,單一文件裡切換兩個畫面(#status-view/#settings-view,JS 直接 toggle,不涉及任何頁面導覽):狀態主畫面(額度顯示、手動重新查詢目前題目、等級 4 的「開始答題」——沒有貼題目文字手動查詢的搜尋框,那個功能拿掉了)、設定畫面(API Key、自動化程度)、兩畫面共用的即時動態(接收ACTIVITY廣播訊息)與版本自檢提示。scripts/compute-hash.js— 每次發布前算出目前檔案的雜湊,供貼到 AnswerHub 的extension-version端點。scripts/package.js— 打包成使用者下載用的.zip(dist/answerhub-extension.zip),檔案清單直接讀background.js的HASHED_FILES,不另外維護一份。
選擇器可信度
科目卡片(.icon-item[name="N"],pass 屬性判斷是否已完成)和送出按鈕(#examOver)是
照使用者提供的真實頁面 HTML 對過的。科目列表畫面跟題目畫面的網址(exam.action /
answer.action,content.js 的 currentPage())也是使用者從真實網址列直接回報的。其餘
選擇器(#topic、#answer .radio、span.assign-num)還是照 NetHoAuto.py(這支擴充功能
的 Python + Selenium 前身,見下方)的紀錄推測、沒有對照真實頁面驗證過——如果換題/換科目/
送出沒有照預期動作,先假設是這些沒驗證過的選擇器出問題。
也是照使用者提供的真實頁面確認過:數學題常常整題是一張圖(#topic 底下只有
<p><img></p>,公式沒辦法用純文字表示)。findQuestionElement() 原本要求元素要有文字才
算「找到」,純圖片的 #topic 因此被判定成「沒找到」,整段邏輯連進都進不去——不只不查答
案,連換題、等級 4 的猜都不會發生,遇到圖片題就卡住不動了。現在改成有文字或有 <img>
就算找到,questionText 抓不到文字時改用圖片的 src(解析後的絕對網址)當題目文字送去
查——不是新發明的做法,是 NetHoAuto.py 對這種情況本來就有的做法
(question_text = image_url # 使用圖片連結作為題目內容)。
這個網址一度對 AnswerHub 自己這邊保證查不到:apps-siao-ai 的
lib/answerhub/question-key.ts 會把題目文字裡的 http(s):// 網址整段拿掉才算 key(原始語
料裡的圖片題是「真正的文字敘述 + 附帶的圖片連結」,連結只是附加資訊,拿掉沒差)。一個
「整題就是網址」的題目拿掉網址後剩空字串,key 變成 null,不可能對到任何一筆資料——不
是沒收錄,是這個查詢方式對 AnswerHub 舊版的比對邏輯來說結構性地不可能成立。apps-siao-ai
的 answer-lookup 路由(2026-08-26 之後)已經修好:questionKey() 回傳 null 時,改用
imageIdsInQuestion() 抓出的圖片 uuid 去比對 question 欄位(爬蟲存資料時這個欄位的網址
沒被拿掉),真的查得到資料——所以這支擴充功能不用再自己繞過 AnswerHub 這段,兩邊(自己
站、敵方站)都照原本的優先順序查。真的連文字帶圖片都沒有的情況(例如純語音題)才會兩邊
都略過查詢,但換題、猜答案照常跑。
這是 NetHoHpRdlAuto(Python + Selenium
版本)的重寫版,選擇器與比對邏輯是參考它的既有決策,不是逐行照搬——瀏覽器擴充功能直接看
得到真實渲染後的 DOM,不需要 Selenium 版本裡大量的 fallback/重試機制。Python 版本維持獨
立、不受這次重寫影響。
排查問題
側邊欄的「即時動態」是第一個該看的地方(不用開 DevTools)。看不夠細的話,在
netholiday.kh.edu.tw 頁面按 F12 打開 DevTools 的 Console,篩選 [AnswerHub] 看到一樣的
內容加上結構化資料(物件、陣列)。
敵方站(netholiday.reh.tw)那個背景分頁查完會自動關閉,幾乎來不及開它自己的 DevTools,
所以查詢過程也會廣播到側邊欄的即時動態(前綴 [bg]/[rival]),同時記在 background
service worker 自己的 console——去 chrome://extensions、這個擴充功能卡片、點
「service worker」開發者工具連結,篩 [AnswerHub:background] 看
「own site: not-found → 開分頁 → rival site: found/not-found」這條流程。分頁頁面本身
的操作細節(點了沒、撈到幾個候選答案)在 [AnswerHub:rival-site],只有真的手動切到那個
分頁、在它關閉前打開 DevTools 才看得到,比較難重現,平常排查就先看側邊欄或 background
那邊的摘要。
額度、金鑰測試、敵方站查詢分別是三套獨立的計數機制,搞混會誤判問題:
- 查題目(自動作答):每支 key 每日 100 次,
/answerhub/api/answer-lookup每次回應(含 429)都帶quota: {remaining, limit},側邊欄拿這個算進度條。 - 測試金鑰:打
/answerhub/api/answer-lookup/validate,完全不同支端點,不吃上面那個額度, 自己另外限每小時 30 次。 - 查敵方站(
netholiday.reh.tw):不吃 AnswerHub 的額度,是完全獨立的機制,靠開背景分頁 完成(chrome.tabs.create({active: false})),同一時間最多一個,12 秒逾時。因為答案不 在原始 HTML 裡(要先點搜尋鍵、捲動觸發 lazy-load 才出現),單純fetch()抓不到,才需要 真的開一個分頁讓netholiday-reh-scraper.js操作頁面。分頁沒有真的「隱形」的方式,會在 分頁列短暫出現。
測試
比對邏輯(similarity.js)是唯一抽出來、脫離瀏覽器環境也能測試的部分
(content.js/background.js/sidepanel.js 直接依賴 chrome.* API 與真實 DOM,沒有
另外的測試 runner 之前先靠手動載入驗證)。hash-consistency.test.js 另外鎖住
background.js 跟 scripts/compute-hash.js 的雜湊合併分隔符一定要一致——這兩個曾經真
的兜不起來過(見下方發布 checklist 的附註),不是靠眼睛看出來的,是靠這條測試。
node --test
發布 checklist
每次改完 manifest.json/background.js/content.js/sidepanel.html/sidepanel.js/shared.css/similarity.js/netholiday-reh-scraper.js
任一個之後想發新版:
- 視改動大小手動 bump
manifest.json的version。 - 執行
node scripts/compute-hash.js,拿到{version, hash}。 - 把這個值貼進
apps-siao-airepo 的lib/answerhub/extension-version.ts(/answerhub/api/extension-version路由讀這個 檔案),commit、push、重新部署answerhub容器。 - 執行
node scripts/package.js,把新的dist/answerhub-extension.zip複製到apps-siao-airepo 的public/downloads/answerhub-extension.zip,一起 commit、push、 重新部署——這是使用者實際下載的檔案,忘記這步等於使用者永遠拿到舊版。 - commit、push 這個 repo。
- 順序不能反——
extension-version端點要先更新,不然舊版使用者短暫看到「版本正常」的 錯誤訊息。
v0.4.1/v0.4.2 曾經回報「版本一直顯示過期」,兩次都誤判成快取問題。 真正原因是
background.js 的 computeSelfHash 拿 "\x00" 當合併分隔符,scripts/compute-hash.js
用的是空格 " ",兩邊從 v0.2.0 起就沒對上過。已經修好,而且 hash-consistency.test.js
會在 node --test 時直接比對兩個檔案的分隔符——如果你改動任一邊的 .join(...) 呼叫,
先跑一次測試再發布。
發布通路
這個 repo 推到 SiaoHub(https://git.siao.ai/siao/answerhub-extension)——repo 頁面本身
公開可看,但 SiaoHub 目前匿名 clone 需要登入才行(設計決定,不是 bug),也沒有
「Download ZIP」功能。所以使用者實際下載走的是 apps.siao.ai 上的靜態檔案
(public/downloads/answerhub-extension.zip),不是 git.siao.ai——上面第 4 步就是在
講這個。這支 repo 本身還是繼續推到 SiaoHub,作為原始碼版本控制與紀錄,只是不是使用者下載
的入口。