iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Modern Web

前端不寫 Python,照樣 ship 一把網頁無障礙 CLI系列 第 11

Day 11:我的工具把整個部落格當成訊息日誌

  • 分享至 

  • xImage
  •  

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__srTfkFooter_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 頁。

兩條規則修正前後的偽陽性對照圖。第一條 AR2410302E 檢查 role=log,修正前在 43 頁中的 32 頁報出問題,修正後為 0 頁;第二條 GN1330101E 檢查格式錯誤訊息,修正前在 43 頁中的 13 頁報出問題,修正後為 0 頁,同時省下 13 次語言模型呼叫。圖中並標示兩次修正都沒有改變 fail 的數量,代表真實問題沒有被誤殺

兩條規則的偽陽性都歸零,而 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。

同一個問題,訊息從「你有事」變成「改這一行,改成這個」。

三層守門的順序示意圖。第一層 scope 判斷這個頁面有沒有相關元素,例如沒有表單就整條規則不執行;第二層 role 判斷元素是不是那一類,例如只看具有 alert 或 status 角色的錯誤欄位容器;第三層 signal 判斷訊號夠不夠強才回報,例如必須具有 aria-live 屬性或五個以上子元素。圖中標示三層必須照順序執行,越前面的檢查成本越低,若規則需要呼叫語言模型則前兩層必須在呼叫之前完成

三層要照順序,越前面越便宜。要打模型的規則,前兩層必須在呼叫之前。

今天的重點

  • 偽陽性比漏抓致命。 漏抓少報一條,偽陽性讓人學會無視整個工具
  • 子字串比對是最容易踩的坑。 log 命中 BlogListlogo 只是剛好逃過門檻
  • 強訊號比長清單有用。 訊息日誌的特徵是「會自己變」,那件事只有 aria-live 說得出來
  • LLM 規則的偽陽性要收兩次錢。 scope 判斷必須在呼叫之前
  • 有些偽陽不該刪,該降級。 那是語氣錯不是判斷錯
  • 判斷修得對不對只有一個標準:fail 的數量不能變。少了就是誤殺

明天 Day 12:上架前的自查抓到一件事。被我掃的那個網頁,可以叫我的工具說它通過。


上一篇
Day 10:漸層背景上的白字,公式要吃哪一個顏色
下一篇
Day 12:被我掃的網頁,可以叫我的工具說它通過
系列文
前端不寫 Python,照樣 ship 一把網頁無障礙 CLI12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言