Day 11 · W2 · 無障礙線 · 難度 ★★★☆☆
本系列由 AI 協作撰寫。 內容、技術判斷、程式碼由 light-design 數位顧問團隊與 Claude 共同產出,最終由作者驗證後 publish。完整協作模式與把關方式見 Day 01。
有一條規則負責找「聊天記錄、通知列、活動動態」這類會一直長出新訊息的容器,提醒你加上 role="log"。
它在我自家站 43 個頁面裡的 32 頁報了問題。
而那 32 頁指的都是同一個東西:
class="BlogList_list__IKEY0"
一個文章清單。 不會自己長出新訊息,也沒有人在裡面聊天。
一句話主軸:偽陽性比漏抓致命。 漏抓只是少報一條,偽陽性會讓人學會無視這個工具,而一旦養成無視的習慣,準確率再高也等於零。
規則靠 class 名稱猜這個容器是不是訊息日誌,判斷條件當時長這樣:
_LOG_HINT = re.compile(r"(log|chat|message-list|activity-feed|notifications|聊天)",
re.IGNORECASE)
log 是子字串比對。而 BlogList 裡面有 log。
我原本以為這條規則的毛病會是漏抓。訊息日誌在一般網站上本來就少見,我預期整站零命中,頂多漏掉某個沒寫 role 的通知列。結果它一口氣報了 32 頁,全部是同一個文章清單。
沒早點發現的原因很簡單:它是第一次對整站跑才爆出來的。 一頁一頁看的時候,多報一行 info 很容易被當成雜訊滑過去;要看到 32 這個數字,得先把 43 頁的結果攤在同一張表上。
每一篇文章頁底下都掛著一個「最新文章」清單,加上部落格首頁本身,31 個頁面各中一次。剩下一頁中的是 BlogList_head__3b07R,同一個元件的另一個部分。
修法是先擋掉已知的假朋友:
# `log` substring matches Blog/Catalog/Dialog/Prologue — exclude those first.
_LOG_NEGATIVE = re.compile(r"(blog|catalog|dialog|prologue|epilogue|login|logo)",
re.IGNORECASE)
清單裡的 logo 特別值得看一眼。我自家站每一頁的頁首頁尾都有 Logo_logo__srTfk 跟 Footer_titleLogo__RpgBb,兩個都含 log。它們沒有觸發,純粹是因為子元素數量沒到門檻 —— 換一個把 logo 拆得比較細的網站,43 頁會全中。
所以光有 negative regex 不夠,我同時把訊號拉緊:
aria_live = (el.get("aria-live") or "").lower()
children = el.find_all(recursive=False)
if aria_live in {"polite", "assertive"} or len(children) >= 5:
原本只要 3 個子元素就算數。訊息日誌真正的特徵不是「東西很多」,是「東西會自己變」,而那件事在 HTML 上只有 aria-live 說得出來。
子元素數量是我當初找的替代訊號,因為它好取。好取的訊號通常也是弱訊號,而弱訊號配上寬鬆的名稱比對,兩個誤差會相乘。
改完之後:32 頁變 0 頁。

兩條規則的偽陽性都歸零,而 fail 一條都沒少。刪掉的全部是噪音。
Day 3 講規則檔自動掛載的時候,我寫過這樣一段:
寫成三個點,這個檔會 import 失敗,自動掛載直接跳過它,然後什麼事都不會發生。沒有紅字,沒有警告。
實際不是這樣。 我把一個規則檔的 from ....models 改成三個點,然後跑:
ModuleNotFoundError: No module named 'a11y_moda.rules.models'
整包載不起來,連列出規則清單都跑不了。因為掛載那段的 import_module 沒有包 try/except,例外直接往上竄。不是靜默跳過,是大聲爆炸。
那真正會靜默的是什麼?我找了一輪,是這兩種:檔名跟碼號對不起來、lint 出現 scan 沒有的碼號。兩種都不會拋例外,只會讓數字悄悄對不上。
所以補了三條測試守住:檔案數必須等於註冊數、檔名必須等於碼號、lint 的碼號必須是 scan 的子集。目前全綠。
這兩件事是同一種病:我對自己寫的工具,描述得比實際情況更有信心。差別只在於一個影響一段文字,另一個影響 32 個頁面。
另一條規則檢查「格式錯誤訊息夠不夠具體」,它會把候選訊息送給模型判斷。當時的做法是全頁面搜尋這些字:
_FMT_HINT = re.compile(r"(format|invalid|格式|錯誤|無效|請以)", re.IGNORECASE)
我的部落格在寫什麼?寫 AI、寫數位轉型、寫規範。「格式」跟「錯誤」這兩個詞每篇都出現。
13 個頁面中招。而這條跟上一條有個關鍵差別:
role=log 那條 誤判 → 報告多一行噪音
格式訊息那條 誤判 → 報告多一行噪音 + 一次模型呼叫
LLM 規則的偽陽性是雙倍懲罰。 13 個頁面就是 13 次白花的呼叫,而且掃越多頁越貴。
修法是在呼叫之前先問一個問題:
# No <form> on the page → nothing to judge. Stops blog body text containing
# 「格式」「錯誤」 from being treated as form error messages.
forms = soup.find_all("form")
if not forms:
return
一篇部落格文章沒有表單,就不可能有表單錯誤訊息。這行 return 讓 13 頁歸零,順便省下 13 次呼叫。
再加一層:就算頁面有表單,也只看真的長得像錯誤欄位的容器 —— role="alert"、role="status"、有 aria-live、class 含 error 之類的字、或者 <output>。
兩條修完之後回頭看,用的其實是同一套順序:
scope 這個頁面有沒有相關的東西 沒有 <form> 就整條不跑
role 這個元素是不是那一類 只看錯誤欄位容器,不是全頁文字
signal 訊號夠不夠強才開口 aria-live 或 5 個以上子元素
這三層要照順序,而且越前面越便宜。 scope 是一次 find_all,signal 要走完整棵樹。順序倒過來,效果一樣但成本差很多。如果那條規則還要打模型,差的就不只是成本。
這兩條是今年五月修的,當時對本機的 31 頁版本量過一次。這次為了確認那組數字還算不算數,我把修正前的版本從 git 取出來,對現在的線上站重跑一遍:
2026-05-04 本機 31 頁 24/31 = 77% 9/31 = 29%
2026-08-25 線上 43 頁 32/43 = 74% 13/43 = 30%
頁數多了三分之一,偽陽比例幾乎沒動。 那就不是某一批內容剛好倒楣,是規則本身的問題。
值得寫下來的是:兩次修正,fail 的數量都沒有變。 拿掉的全部是 info 級的噪音,真正的問題一條都沒少。這是判斷修得對不對的唯一標準。偽陽性很好殺,把規則改嚴一點就什麼都不會報了,但那叫關掉不叫修好。
第三條的情況不太一樣。它檢查圖示的對比是不是夠,而它報的東西並沒有錯,錯的是它用什麼語氣報。
修前 SVG icon 含淺色 fill=#ffffff,對比度可能不足。
修後 SVG 全部 fill 為淺色(#ffffff),需人工確認與背景對比。
差別在哪?前者宣稱了一個它沒有把握的判斷。 SVG 用 currentColor 或被 CSS 覆寫的時候,工具看不到最終顏色 —— 這正是 Day 10 那三個 1.00 的同一件事。
所以它從 info(我建議你改)換成 caveat(我量不到,請人看)。同樣兩頁還是被標出來,但讀的人現在知道那是一個待確認事項,不是一個待修 bug。
這不是把標準放寬,是把話講準。
到這裡三條規則其實是三個不同的問題。第一條是名稱比對太寬,該收窄;第二條是根本不該跑到那裡,該加前置判斷;第三條是判斷沒錯但語氣過頭,該換狀態。把它們都叫「偽陽性」然後一律砍掉,會砍掉不該砍的那一類。
最後一條是另一種失敗。它抓對了 —— 標題層級真的跳級 —— 但訊息沒有用:
修前 標題層次跳級:上一層為 h3,但下一個是 h6(跳過 h4)
「上一層為 h3」是它記錄的整份文件最深層級,不是使用者眼前那個標題的前一個。讀的人拿到這句話,還是得自己回去數。
修後 標題層次跳級:h6「來自各產業的真實回饋」前最近的 heading 是
h2「客戶怎麼說」,跳 4 級(建議改為 h3)。整份文件最深見過 h3。
同一個問題,訊息從「你有事」變成「改這一行,改成這個」。

三層要照順序,越前面越便宜。要打模型的規則,前兩層必須在呼叫之前。
log 命中 BlogList,logo 只是剛好逃過門檻aria-live 說得出來明天 Day 12:上架前的自查抓到一件事。被我掃的那個網頁,可以叫我的工具說它通過。