幫沒有 API 的產品做 API:瀏覽器自動化的可靠性工程
NotebookLM 是我用得最多的 AI 工具之一,但它沒有公開 API。想在自動化流程裡用它——丟來源、問問題、拿回答——只有一條路:瀏覽器自動化。
所以我做了一個 MCP server,用 Playwright 驅動真的 NotebookLM 頁面,把「加來源」「提問」「撈答案」包成程式可以呼叫的工具,發上 PyPI。
第一版跑通只花一個週末。然後接下來兩個版本,我都在修同一類問題:它會回傳「看起來完全合理、但其實是錯的」的東西。
為什麼瀏覽器自動化的錯特別毒
一般 API 整合的失敗是誠實的:4xx、5xx、timeout,你看得到、接得住。
瀏覽器自動化不一樣。你面對的是一個為人類設計的動態頁面,它的失敗模式是「抓到了東西,但不是你要的東西」:
| 失敗 | 症狀 | 下游後果 |
|---|---|---|
| 抓到舊答案 | 問第二題,拿回第一題的回覆 | 答非所問,但格式完全正確,很難發現 |
| 抓到半截答案 | 串流還在打字就收割 | 結論被切在句子中間,引用缺失 |
| 抓到 UI 本身 | 把「AI 簡報」「心智圖」等功能按鈕的文字當成回覆 | 一段完全不知所云的「答案」 |
這三種在呼叫端看起來都是 200 OK。如果呼叫端是另一個 AI agent,它會拿著錯的內容繼續往下推理——錯誤不會停在這裡,會傳播。
失敗一的解法:答案要「連續穩定」才算數
AI 回答是串流的,DOM 裡的答案節點會持續變長。什麼時候算「寫完了」?
等固定秒數是最直覺的答案,也是最錯的:短答案白等,長答案等不夠。正確的訊號不是時間,是穩定性:
# 輪詢答案節點:內容連續 3 次檢查都相同,才視為完成
if text == last_text:
stable_answer_checks += 1
else:
stable_answer_checks = 0 # 還在變,重新計數
last_text = text
if has_changed_answer and stable_answer_checks >= 3:
return text # 新的、而且穩定的答案兩個條件缺一不可。「穩定」擋住半截答案;「新的」擋住舊答案——提問前先記住畫面上既有的回覆,收割時要求內容必須不同於它。只有「變了、然後停了」的內容才是這一題的完整答案。
失敗二的解法:偵測 UI 污染
最陰險的是第三類。NotebookLM 的工作區裡到處是功能入口——AI 簡報、學習卡、心智圖、Audio Overview——當選擇器在某次改版後開始抓到過寬的節點,這些按鈕文字會被串成一段「回答」。
它不是亂碼,是一串真實的中文詞,只是不成語意。人看一眼就知道不對,程式看不出來。
我的解法是建一個特徵庫:把工作區的控制元素文字和功能名稱列成標記清單,對抓到的內容計數:
def detect_answer_ui_pollution(text: str) -> list[str]:
"""辨識誤把 NotebookLM 工作區介面當成回答正文的情況。"""
normalized = re.sub(r"\s+", " ", text).strip().lower()
control_hits = [m for m in _ANSWER_UI_CONTROL_MARKERS if m in normalized]
feature_hits = [m for m in _ANSWER_UI_FEATURE_MARKERS if m in normalized]
# 控制元素命中 ≥2,或功能名稱命中 ≥4 → 這不是答案,是介面
if len(control_hits) >= 2 or len(feature_hits) >= 4:
return [*control_hits, *feature_hits]
return []閾值是設計重點:真的答案偶爾會提到一次「心智圖」(來源內容剛好在講它),所以單次命中不能判死;但一段文字同時出現四個功能名稱,或兩個純控制元素字樣,幾乎不可能是自然語言的回覆。
判定污染之後怎麼辦?這是整個專案最重要的一個決定:
回傳明確的 ANSWER_EXTRACTION_FAILED 錯誤,而不是把抓到的東西照樣交出去。寧可讓呼叫端知道「這次沒拿到」,也不要給它一段會被當真的錯誤內容。對包給 AI agent 用的工具,這條原則再怎麼強調都不過分——agent 對「格式正確的錯誤內容」毫無抵抗力。
失敗三的解法:選擇器的分層防禦
沒有 API 合約,DOM 就是合約——而對方隨時可以改。選擇器不是「寫對」的問題,是「怎麼老化」的問題。
我的做法是每個目標元素給一個 fallback 陣列,從最特定排到最通用:
"sources_panel": [
"section.source-panel", # 當前版本的實際結構,最準
'[data-testid="sources-panel"]', # 測試屬性,次穩定
'[aria-label*="Sources"]', # 無障礙標籤,跨改版存活率高
'[aria-label*="來源"]', # 中文介面的同一層
'[class*="sources"]', # 最寬的網,最後手段
],排序本身就是策略:最上面的準但脆,改版就斷;最下面的活得久但寬,容易誤抓——這正是 UI 污染的來源之一。所以分層防禦和污染偵測是一對的:寬選擇器負責在改版後撐住基本功能,污染偵測負責把寬選擇器抓錯的東西攔下來。
另外兩個配套:所有「可有可無」的元素讀取都設短逾時(元素消失時不要拖慢整條流程),以及對「頁面還在載入」設明確狀態——載入中的筆記本頁面會被天真版讀成「零來源的未命名筆記本」,那也是看起來合理的錯誤資料。
驗證:133 個測試,加上真的登入去跑
這類專案的測試有個特殊困難:你 mock 不了 NotebookLM。單元測試蓋得住解析邏輯(污染偵測、穩定性判斷、錯誤分類),但「選擇器還抓不抓得到真的頁面」只有一種驗法——用真的帳號、真的筆記本、真的長串流答案跑過。
所以每個版本出門前是兩層:133 個單元測試,加一輪對著登入中的 NotebookLM 實跑——健康檢查、筆記本中繼資料、來源數、長答案抽取、引用抽取,全過才發版。
如果你也要包一個沒有 API 的產品
五條原則,按重要性排:
一、預設不信任抓到的東西。每一段內容過完整性檢查(穩定?新的?不是 UI?)才交出去。
二、寧可明確失敗。回錯誤碼永遠好過回「看起來合理的錯誤內容」,尤其當呼叫端是 AI。
三、等待用穩定性訊號,不用固定秒數。
四、選擇器分層,並接受寬選擇器需要污染偵測來配平。
五、真環境驗證是發版流程的一部分,不是出事後的補救。
瀏覽器自動化永遠是暫時的橋——哪天官方 API 出了,這些程式碼全部可以扔。但「不信任抓到的內容、壞的時候大聲說」這套紀律,在任何跨系統整合裡都用得上。
相關文章:從零到 PyPI——讓 NotebookLM 可以被程式控制