Browser AutomationPlaywrightMCPReliabilityPythonNotebookLM

幫沒有 API 的產品做 API:瀏覽器自動化的可靠性工程

·閱讀約 6 分鐘

NotebookLM 是我用得最多的 AI 工具之一,但它沒有公開 API。想在自動化流程裡用它——丟來源、問問題、拿回答——只有一條路:瀏覽器自動化。

所以我做了一個 MCP server,用 Playwright 驅動真的 NotebookLM 頁面,把「加來源」「提問」「撈答案」包成程式可以呼叫的工具,發上 PyPI。

第一版跑通只花一個週末。然後接下來兩個版本,我都在修同一類問題:它會回傳「看起來完全合理、但其實是錯的」的東西。

為什麼瀏覽器自動化的錯特別毒

一般 API 整合的失敗是誠實的:4xx、5xx、timeout,你看得到、接得住。

瀏覽器自動化不一樣。你面對的是一個為人類設計的動態頁面,它的失敗模式是「抓到了東西,但不是你要的東西」:

失敗症狀下游後果
抓到舊答案問第二題,拿回第一題的回覆答非所問,但格式完全正確,很難發現
抓到半截答案串流還在打字就收割結論被切在句子中間,引用缺失
抓到 UI 本身把「AI 簡報」「心智圖」等功能按鈕的文字當成回覆一段完全不知所云的「答案」
三類「成功回傳錯誤內容」的失敗

這三種在呼叫端看起來都是 200 OK。如果呼叫端是另一個 AI agent,它會拿著錯的內容繼續往下推理——錯誤不會停在這裡,會傳播。

失敗一的解法:答案要「連續穩定」才算數

AI 回答是串流的,DOM 裡的答案節點會持續變長。什麼時候算「寫完了」?

等固定秒數是最直覺的答案,也是最錯的:短答案白等,長答案等不夠。正確的訊號不是時間,是穩定性:

python
# 輪詢答案節點:內容連續 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——當選擇器在某次改版後開始抓到過寬的節點,這些按鈕文字會被串成一段「回答」。

它不是亂碼,是一串真實的中文詞,只是不成語意。人看一眼就知道不對,程式看不出來。

我的解法是建一個特徵庫:把工作區的控制元素文字和功能名稱列成標記清單,對抓到的內容計數:

python
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 陣列,從最特定排到最通用:

python
"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 可以被程式控制

參考資料