AI生成前端无障碍检查报告:TaoToken 统一 Key 接入自动化 a11y 审计完整方案

📅 发布时间:2026/10/15 13:18:59
AI生成前端无障碍检查报告:TaoToken 统一 Key 接入自动化 a11y 审计完整方案
1. 前端无障碍审计为什么总在 CI 里卡壳无障碍a11y这件事很多前端团队都经历过同一个循环季度审计时突击修一批上线后没人管下次审计又冒出一堆。WCAG 2.1/2.2 的 AA 级要求并不复杂难的是把它变成一条能持续跑的流水线。axe-core 这类工具本身很成熟注入页面、跑一遍、拿到 violations 数组技术上没有门槛。真正让人头疼的是三件事扫描结果是一大坨 JSON几十上百条 violation 堆在一起没人愿意读每条 violation 只有规则 ID 和 help 链接初级工程师看不懂为什么要改、怎么改CI 里跑完只输出一个数字PR 作者不知道这次改动到底新增了哪几条问题。我见过一个 SaaS 项目第一次接入 axe-core 后 CI 直接报出 247 个问题。团队当场懵了因为没人能在一周内消化这个量级。后来他们按严重级拆成三批处理第一周只碰 critical第二周 high第三周 medium才把节奏拉回来。这个案例说明自动化扫描只是起点把原始结果翻译成人能读、能排期、能验证的报告才是让 a11y 真正落地的关键环节。这正是 AI 能补位的地方。axe-core 负责发现AI 负责解释和归类——把 violations 按 WCAG 条款分组、按修复难度排序、为每条问题生成 before/after 代码示例、估算工时、给出学习路径。前端团队拿到的不再是 JSON而是一份可以直接贴进迭代会的报告。本文聚焦的场景很具体前端团队在 CI 中落地 WCAG 自动化审计用 AI 汇总 axe-core 扫描结果并生成可读报告。我会给出可复制的审计脚本配置、TaoToken 统一 Key 的接入参数、报告模板并完整演示一次从扫描到报告生成的验证动作。适合正在做前端质量基建、准备接入 a11y 门禁、或者已经被无障碍审计报告淹没的工程师。读完你能拿到一套能直接跑起来的方案而不是又一篇概念科普。2. TaoToken 统一 Key 接入让 AI 报告生成不再散落各处2.1 为什么报告生成层需要一个统一入口AI 增强报告的核心动作是把结构化扫描结果喂给模型拿回结构化修复建议。这个动作在 CI 里会被高频调用每个 PR、每个页面、每条 violation 都可能触发一次模型请求。如果团队里每个项目各自维护一套 API Key、各自处理鉴权和重试很快就会变成一团乱麻——Key 泄露风险、额度分散、模型版本不一致、报错格式五花八门。TaoToken 在这里扮演的是统一网关的角色。它把模型调用收敛到一个 Base URL 和一个 Key 上CI 脚本、本地调试、报告生成服务都走同一个入口。对前端团队来说好处很直接审计脚本里只需要维护一份配置换模型、调额度、看用量都在一个地方完成不用在十几个仓库里翻环境变量。需要说清楚的是TaoToken 不是绕过什么的通道它是一个正常的 API 聚合服务提供 OpenAI 兼容的接口格式。你用它调用模型和直接对接模型厂商在协议层面是一致的只是把多模型、多项目的管理成本降下来了。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。2.2 接入前要准备的三样东西在写审计脚本之前先把这三样准备好后面配置会反复用到第一是 Base URL。TaoToken 的 API 根地址是https://taotoken.net/api注意这里不带任何查询参数直接作为 OpenAI SDK 的baseURL使用。如果你用的是 Anthropic 协议比如 Claude Code 场景路径会略有不同但本文的审计脚本走 OpenAI 兼容格式用这个即可。第二是 API Key。到控制台创建地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。创建后立刻复制保存页面刷新后不再完整显示。Key 的权限建议按项目隔离CI 用的 Key 和本地调试用的分开方便出问题时快速吊销。第三是 Model ID。报告生成对模型的要求是能稳定输出 JSON、理解 HTML/JSX 片段、中文表达通顺。你可以在模型对话页面先试几条真实 violation看哪个模型返回的 JSON 最规整。地址是 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。选定后把 Model ID 记下来写进配置。2.3 把 Key 放进 CI 的正确姿势绝对不要把 Key 硬编码进脚本或提交到仓库。GitHub Actions 用 SecretsGitLab CI 用 masked variables本地用.env并加进.gitignore。审计脚本读取环境变量这样同一份代码在本地和 CI 都能跑。一个容易踩的坑CI 里环境变量名和本地不一致导致本地能跑、CI 报 401。建议统一用TAOTOKEN_API_KEY、TAOTOKEN_BASE_URL、TAOTOKEN_MODEL三个变量名脚本里只认这三个谁改谁负责。2.4 报告生成在整条链路里的位置把整条链路画清楚后面配置才不会乱前端代码 → 构建 → 启动服务 → Playwright 打开页面 → 注入 axe-core → 扫描 → violations JSON → 预处理去重/分组/截断→ 调用 TaoToken → AI 修复建议 → 合并成 EnhancedReport → 输出 HTML/JSON → 上传 artifacts → PR 评论TaoToken 只负责中间violations → AI 建议这一段。扫描、预处理、报告渲染都在你自己的脚本里完成。这样设计的好处是即使 AI 调用失败你仍然有一份原始的 axe 扫描结果可用不会因为模型超时导致整个 CI 挂掉。降级策略很重要后面排障章节会展开。3. 可复制的审计脚本与配置片段3.1 项目结构与依赖先建一个独立的审计目录不要和业务代码混在一起a11y-audit/ ├── package.json ├── .env.example ├── config/ │ └── a11y.config.json ├── scripts/ │ ├── scan.js # Playwright axe 扫描 │ ├── enhance.js # 调用 TaoToken 生成修复建议 │ └── report.js # 渲染 HTML/JSON 报告 └── reports/ # 输出目录加进 .gitignorepackage.json的依赖{ name: a11y-audit, version: 1.0.0, type: module, scripts: { a11y:scan: node scripts/scan.js, a11y:enhance: node scripts/enhance.js, a11y:report: node scripts/report.js, a11y:all: npm run a11y:scan npm run a11y:enhance npm run a11y:report }, dependencies: { playwright: ^1.44.0, axe-core: ^4.9.0, openai: ^4.52.0, dotenv: ^16.4.5 } }3.2 审计配置文件config/a11y.config.json把扫描参数和 AI 参数集中管理{ urls: [ http://localhost:3000/, http://localhost:3000/login, http://localhost:3000/dashboard ], scan: { waitForSelector: main, scrollToBottom: true, timeout: 30000, axeTags: [wcag2a, wcag2aa, wcag21a, wcag21aa] }, ai: { baseURL: https://taotoken.net/api, model: your-model-id, temperature: 0.2, maxTokens: 1200, concurrency: 3 }, report: { outputDir: reports, failOnCritical: true, baselinePath: reports/baseline.json } }几个参数值得解释。waitForSelector是等页面真正渲染完成的关键SPA 尤其重要只等端口监听是不够的。scrollToBottom用来触发懒加载虚拟列表和 Intersection Observer 场景下不加这个会漏掉大量 DOM。axeTags限定只跑 WCAG 相关规则避免混入实验性规则导致噪音。concurrency控制并发调用模型的请求数太高容易触发限流3 到 5 比较稳。3.3 环境变量模板.env.exampleTAOTOKEN_API_KEYsk-xxxxxxxxxxxxxxxx TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODELyour-model-id本地复制成.env填真实值CI 里通过 Secrets 注入同名变量。3.4 扫描脚本核心逻辑scripts/scan.js的关键部分import { chromium } from playwright; import fs from fs; import path from path; import config from ../config/a11y.config.json assert { type: json }; const axeSource fs.readFileSync( path.resolve(node_modules/axe-core/axe.min.js), utf-8 ); async function scanPage(browser, url) { const page await browser.newPage(); await page.goto(url, { waitUntil: networkidle, timeout: config.scan.timeout }); await page.waitForSelector(config.scan.waitForSelector, { timeout: config.scan.timeout }); if (config.scan.scrollToBottom) { await page.evaluate(async () { await new Promise((resolve) { let total 0; const step 400; const timer setInterval(() { window.scrollBy(0, step); total step; if (total document.body.scrollHeight) { clearInterval(timer); window.scrollTo(0, 0); resolve(); } }, 100); }); }); await page.waitForTimeout(500); } await page.addScriptTag({ content: axeSource }); const results await page.evaluate(async (tags) { return await window.axe.run(document, { runOnly: { type: tag, values: tags } }); }, config.scan.axeTags); await page.close(); return results.violations; } const browser await chromium.launch(); const allResults []; for (const url of config.urls) { const violations await scanPage(browser, url); allResults.push({ url, violations, timestamp: new Date().toISOString() }); } await browser.close(); fs.mkdirSync(reports, { recursive: true }); fs.writeFileSync(reports/raw-scan.json, JSON.stringify(allResults, null, 2)); console.log(扫描完成共 ${allResults.reduce((s, r) s r.violations.length, 0)} 条 violation);这段脚本跑完会生成reports/raw-scan.json里面是原始的 axe 结果。注意addScriptTag用的是本地node_modules里的 axe不要用 CDN——CI 环境网络不稳定CDN 挂了整个扫描就废了。3.5 AI 增强脚本与 TaoToken 调用scripts/enhance.js负责把原始 violation 喂给模型import OpenAI from openai; import fs from fs; import dotenv/config; import config from ../config/a11y.config.json assert { type: json }; const client new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL || config.ai.baseURL }); const SYSTEM_PROMPT 你是前端无障碍专家。用户会给你一条 axe-core 的 violation 请输出严格的 JSON不要任何额外文字。字段 { explanation: 为什么这是问题用中文2-3句, before: 有问题的代码片段, after: 修复后的代码片段, difficulty: easy|medium|hard, estimatedMinutes: 数字, wcagRef: 对应的 WCAG 条款编号 }; async function enhanceViolation(violation) { const userContent JSON.stringify({ id: violation.id, impact: violation.impact, description: violation.description, help: violation.help, tags: violation.tags, nodes: violation.nodes.slice(0, 3).map((n) ({ target: n.target, html: n.html.slice(0, 500) })) }); const res await client.chat.completions.create({ model: process.env.TAOTOKEN_MODEL || config.ai.model, temperature: config.ai.temperature, max_tokens: config.ai.maxTokens, response_format: { type: json_object }, messages: [ { role: system, content: SYSTEM_PROMPT }, { role: user, content: userContent } ] }); try { return JSON.parse(res.choices[0].message.content); } catch (e) { return { explanation: violation.help, before: violation.nodes[0]?.html || , after: , difficulty: medium, estimatedMinutes: 30, wcagRef: violation.tags.find((t) t.startsWith(wcag)) || }; } } const raw JSON.parse(fs.readFileSync(reports/raw-scan.json, utf-8)); const enhanced []; for (const page of raw) { const items []; for (const v of page.violations) { const suggestion await enhanceViolation(v); items.push({ ...v, suggestion }); } enhanced.push({ ...page, violations: items }); } fs.writeFileSync(reports/enhanced.json, JSON.stringify(enhanced, null, 2)); console.log(AI 增强完成);这里有几个设计决策。response_format: { type: json_object }强制模型输出 JSON能大幅降低解析失败率。nodes.slice(0, 3)只取前三个节点避免 prompt 过长——一条 violation 可能有几十个节点全塞进去既贵又慢。解析失败时走 fallback用 axe 自带的 help 字段兜底保证报告不会因为模型抽风而空白。3.6 报告渲染scripts/report.js把 enhanced.json 渲染成 HTML核心是分组和排序import fs from fs; const data JSON.parse(fs.readFileSync(reports/enhanced.json, utf-8)); const severityOrder { critical: 0, serious: 1, moderate: 2, minor: 3 }; const allIssues data.flatMap((page) page.violations.map((v) ({ ...v, url: page.url })) ); allIssues.sort((a, b) severityOrder[a.impact] - severityOrder[b.impact]); const totalMinutes allIssues.reduce( (s, i) s (i.suggestion?.estimatedMinutes || 30), 0 ); const html !DOCTYPE html html langzh-CNheadmeta charsetUTF-8 title无障碍审计报告/title style body{font-family:system-ui,sans-serif;max-width:960px;margin:24px auto;padding:0 16px} .issue{border:1px solid #e0e0e0;border-radius:8px;padding:16px;margin:12px 0} .critical{border-left:4px solid #d32f2f} .serious{border-left:4px solid #f57c00} .moderate{border-left:4px solid #fbc02d} .minor{border-left:4px solid #689f38} pre{background:#f5f5f5;padding:12px;border-radius:4px;overflow-x:auto} .meta{color:#666;font-size:13px} /style/headbody h1无障碍审计报告/h1 p classmeta生成时间${new Date().toLocaleString(zh-CN)} 问题总数${allIssues.length} 预估总工时${totalMinutes} 分钟/p ${allIssues.map((i) div classissue ${i.impact} h3${i.id} — ${i.help}/h3 p classmeta页面${i.url} 严重度${i.impact} 难度${i.suggestion?.difficulty} 预估${i.suggestion?.estimatedMinutes} 分钟/p p${i.suggestion?.explanation || i.description}/p ${i.suggestion?.before ? precode${escapeHtml(i.suggestion.before)}/code/pre : } ${i.suggestion?.after ? precode${escapeHtml(i.suggestion.after)}/code/pre : } /div).join()} /body/html; function escapeHtml(s) { return String(s).replace(//g, amp;).replace(//g, lt;).replace(//g, gt;); } fs.writeFileSync(reports/a11y-report.html, html); console.log(报告已生成reports/a11y-report.html);跑完npm run a11y:all你会得到三份产物raw-scan.json原始数据用于 baseline 对比、enhanced.json带 AI 建议用于二次处理、a11y-report.html人读版本上传 artifacts。4. 验证请求从扫描到报告跑通一次4.1 准备一个可扫描的页面为了验证整条链路先起一个最小的测试页面。新建demo/index.html!DOCTYPE html html langzh-CN headmeta charsetUTF-8titlea11y 测试页/title/head body main h1登录/h1 img src/logo.png form input typetext placeholder用户名 input typepassword placeholder密码 div onclicksubmit()登录/div /form p stylecolor:#aaa;background:#fff忘记密码/p /main /body /html这个页面故意埋了四个典型问题图片缺 alt、表单控件缺 label、用 div 当按钮、颜色对比度不足。用npx serve demo -l 3000起服务或者塞进你现有的 dev server。4.2 配置并执行扫描把config/a11y.config.json的urls改成[http://localhost:3000/]waitForSelector改成main。然后npm install cp .env.example .env # 编辑 .env 填入真实的 TAOTOKEN_API_KEY 和 TAOTOKEN_MODEL npm run a11y:scan预期输出扫描完成共 4 条 violation打开reports/raw-scan.json你应该能看到image-alt、label、color-contrast等规则 ID。如果这里是 0 条先别往下走检查waitForSelector是否命中了、页面是否真的渲染了。4.3 调用 TaoToken 生成修复建议npm run a11y:enhance这一步会逐条调用模型。观察控制台正常情况下几秒到十几秒完成。完成后打开reports/enhanced.json找image-alt那条看suggestion字段{ explanation: 图片缺少 alt 属性屏幕阅读器无法向视障用户描述图片内容违反 WCAG 1.1.1。, before: img src\/logo.png\, after: img src\/logo.png\ alt\公司 Logo\, difficulty: easy, estimatedMinutes: 5, wcagRef: wcag111 }如果explanation是中文、after里有具体的 alt 文本、difficulty是 easy说明 TaoToken 调用成功模型输出符合预期。如果explanation直接等于 axe 的 help 原文说明走了 fallback大概率是模型返回的不是合法 JSON去检查response_format和模型是否支持。4.4 生成并查看报告npm run a11y:report用浏览器打开reports/a11y-report.html。你应该看到四条问题按严重度排序每条都有中文解释、before/after 代码、预估工时顶部有总工时汇总。这份 HTML 就是可以直接贴进 PR 或迭代会的产物。4.5 接入 CI 的最小配置GitHub Actions 示例name: a11y-audit on: pull_request: branches: [main] jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 20 - run: npm ci - run: npm run build - run: npm run start - run: npx wait-on http://localhost:3000 - run: cd a11y-audit npm ci npm run a11y:all env: TAOTOKEN_API_KEY: ${{ secrets.TAOTOKEN_API_KEY }} TAOTOKEN_BASE_URL: https://taotoken.net/api TAOTOKEN_MODEL: ${{ secrets.TAOTOKEN_MODEL }} - uses: actions/upload-artifactv4 if: always() with: name: a11y-report path: a11y-audit/reports/注意wait-on只能确认端口通了不能确认页面渲染完成。真正的渲染等待在scan.js的waitForSelector里两者配合才稳。5. 常见报错排查401、local proxy failed、reading choices、OAuth5.1 401 Unauthorized最常见的报错几乎都是 Key 的问题。按这个顺序查先确认环境变量名对得上。脚本里读的是process.env.TAOTOKEN_API_KEYCI Secrets 里也必须叫这个名字大小写敏感。我见过有人 Secrets 里写TAOTOKEN_KEY脚本读TAOTOKEN_API_KEY结果本地.env有值所以能跑CI 里就是 401。再确认 Key 没有多余空格。从控制台复制时容易带上首尾空白sk-xxx和sk-xxx在服务端看来是两个不同的 Key。在脚本里加一句console.log(apiKey.trim().length)快速验证。最后确认 Key 没被吊销或额度耗尽。到 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 看 Key 状态和用量。如果 Key 是新建的但一直 401检查是不是复制时漏了前缀。5.2 local proxy failed / connection refused这个报错通常出现在 CI 环境含义是脚本连不上baseURL。先确认TAOTOKEN_BASE_URL的值是https://taotoken.net/api不要带尾部斜杠也不要带任何查询参数。OpenAI SDK 会在这个地址后面拼/chat/completions多一个斜杠就变成//chat/completions部分网关会拒绝。如果地址没问题还是连不上检查 CI runner 的出网策略。有些企业内网 CI 默认禁止外网访问需要把taotoken.net加进白名单。这个不是代码问题找运维开权限即可。还有一种情况是本地开发时用了系统级网络设置导致 Node 的请求走了非预期路径。用curl -I https://taotoken.net/api在同一个终端里测一下如果 curl 通而 Node 不通检查是否有HTTP_PROXY之类的环境变量被 Node 读取了。5.3 reading choices / Cannot read properties of undefined这个报错说明res.choices[0]是 undefined即模型返回体结构不符合预期。三种可能一是请求根本没成功返回的是错误对象而不是正常响应。在enhanceViolation里加 try/catch 把完整响应打出来const res await client.chat.completions.create({ /* ... */ }); if (!res.choices || res.choices.length 0) { console.error(异常响应:, JSON.stringify(res)); throw new Error(empty choices); }二是模型不支持response_format: { type: json_object }服务端忽略了该参数并返回了纯文本SDK 解析后结构异常。换一个明确支持 JSON mode 的模型或者去掉response_format改用 prompt 约束。三是并发太高被限流返回了 429 但被 SDK 包装成了异常结构。把concurrency降到 1 试试如果单条能过、并发就挂那就是限流问题加个简单的队列或p-limit控制。5.4 OAuth / authentication 相关报错如果你在 Claude Code 或类似工具里看到 OAuth 报错通常是把两套鉴权方式搞混了。TaoToken 的 API 调用走的是 API KeyBearer Token不是 OAuth 流程。在 Claude Code 场景下配置时Base URL 填https://taotoken.net/apiKey 填控制台创建的 API KeyModel ID 填你选定的模型。三件套缺一不可只填 Base URL 不填 Key 会直接报鉴权失败。Claude Code 的配置文件通常在~/.claude/settings.json或项目级.claude/settings.json格式类似{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-xxxxxxxx, ANTHROPIC_MODEL: your-model-id } }改完重启 Claude Code 生效。如果还是报 OAuth 相关错误检查是不是有旧的登录态缓存清掉~/.claude下的凭据文件重试。5.5 扫描结果为空不是报错但更让人抓狂。页面明明有问题axe 却返回 0 条 violation。九成是渲染时机问题。SPA 在networkidle之后可能还在跑 hydrationDOM 里只有骨架屏。解决办法是把waitForSelector指向一个只有真实内容渲染后才存在的元素比如[data-testiddashboard-loaded]。如果页面有懒加载确认scrollToBottom生效了可以在page.evaluate里console.log(document.body.scrollHeight)看滚动前后的差异。另一个可能是axeTags配错了。如果你只写了[wcag2aa]那 A 级规则不会跑某些 violation 就漏了。建议用[wcag2a, wcag2aa, wcag21a, wcag21aa]覆盖主流范围。6. 把审计变成习惯从一次性扫描到持续门禁跑通一次扫描不难难的是让它持续产生价值。这里分享几个实际落地时验证过的做法。第一建立 baseline 快照。第一次跑完审计后把raw-scan.json存成baseline.json。之后每次 CI 跑完对比当前结果和 baseline只对新增的 violation做阻断。存量问题不阻塞合并但会记录在报告里。这样团队不会被历史包袱压垮同时新引入的问题一个都跑不掉。对比逻辑很简单按violation.id node.target做 key找出当前有而 baseline 没有的条目。第二按严重度分批处理。critical 和 serious 必须当次 PR 修掉moderate 可以排进下个迭代minor 记录在案即可。这个分级不是拍脑袋axe 的impact字段已经给了参考AI 生成的difficulty和estimatedMinutes可以帮你排期。一个 critical 但 easy 的问题优先级应该高于 serious 但 hard 的问题。第三报告要进开发者的工作流。HTML 报告放在 artifacts 里PR 作者不一定会点开。更有效的做法是把摘要直接评论到 PR 上新增几条、都是什么规则、预估工时多少。GitHub Actions 用actions/github-script读enhanced.json生成评论几行代码的事。如果团队用 VS Code可以把enhanced.json转成 SARIF 格式用编辑器插件直接在代码行上标注问题点击跳转修复效率比看 HTML 高得多。第四定期回看趋势。把每次审计的汇总数据总问题数、critical 数、通过率写进一个简单的存储按周或按月看趋势。如果数字在降说明门禁有效如果持平或上升说明修复速度跟不上新增速度需要调整策略。这个数据也是向管理层证明 a11y 投入产出比的好材料。第五AI 建议要验证。模型生成的修复代码看起来合理但可能引入新问题。我遇到过模型建议给div rolebutton加tabindex0却忘了加键盘事件处理结果 WCAG 4.1.2 从部分合规变成另一种违规。稳妥的做法是对 AI 生成的 after 代码片段再跑一次 axe 扫描确认没有引入新 violation 再采纳。这一步可以做成自动化把 after 片段渲染到测试页面跑 axe对比前后 violation 数量。最后别忘了无障碍的本质是给人用的。自动化工具能覆盖大约 30% 到 40% 的 WCAG 条款剩下的需要人工测试键盘导航是否顺畅、屏幕阅读器读出来的顺序是否合理、焦点管理是否符合预期。把自动化审计当作第一道防线人工测试作为补充两者结合才是完整的 a11y 保障。如果你还没开始建议这周就做一件事在现有项目里跑一次 axe 扫描看看有多少问题。数字可能会吓到你但只有看见了才有机会改。TaoToken 的接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的参数说明和示例需要长期在 CI 里跑 Agent 类任务的团队可以看看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 把审计和修复建议生成纳入统一的额度管理。