アクセシビリティ違反を自動検知する方法

はじめに

ウェブアクセシビリティ(以下、アクセシビリティ)は、すべてのユーザーがウェブサイトや情報システムを利用できるようにするための重要な要素である。しかし、アクセシビリティの重要性を認識しているものの、実際にどのようにしてアクセシビリティ違反を検知・改善すればよいのか分からない開発者も少なくない。

この記事では、アクセシビリティ違反を機械的に検知する方法に焦点を当て実装方法について解説する。

検証ツール

検証ツールの利用は、アクセシビリティ違反の発見と改善において非常に有効である。検証ツールには様々な種類が存在するが、たとえばChrome DevToolsに組み込まれているLighthouseを利用すれば、手軽にアクセシビリティの検証ができる。

Chrome DevToolsのLighthouseで検証した結果

なお、Lighthouse1はアクセシビリティの監査エンジンにaxe-coreを利用しており、これを直接利用することでより詳細な情報を取得が可能である。このように検証ツールを使うことで、具体的な問題点が明確になり、それに基づいた改善策を立てることができる。

axe-core とは

axe-coreは、Webサイト向けのアクセシビリティ・テスティングライブラリである。開発元はアクセシビリティ関連の大手ベンダーのDeque Systems

axe-coreは、WCAG 2.0、2.1、2.22のレベルA、AA、AAAに準拠するさまざまなルールを提供している。これには、各ページにh1見出しがあることを確認するなどの一般的なアクセシビリティのベストプラクティス3も含まれている。ルールは、WCAGレベルとベストプラクティスごとにグループ化されている4

また、ブラウザ拡張機能やVS Codeの拡張機能(axe Accessibility Linter)の提供もある。

利用方法

axe-coreは、Puppeteerと連携に便利な@axe-core/puppeteerも提供している。

axe-core自体は対象のサイトにライブラリを埋め込んで実行する必要があり、外部からaxeを挿入して実行する機能はない。そのため、外部から検証をする場合はヘッドレスブラウザなどを利用する必要があり、ヘッドレスブラウザ経由で検証したいのであれば@axe-core/puppeteerを利用するのが便利である。

axe-core-npmでは、ほかにも以下のようなパッケージが提供されている。

利用用途に応じて適切なパッケージを選択すると良い。

また、axe-coreの検証結果をレポートに出力するためのツールとして、有志によってaxe-reportsというパッケージも提供されている。axe-reportsは、axe-coreの検証結果をCSVやTSVの形式で出力できるもので、これらを組み合わせて理想的な検証結果を生成できる。

インストール

まずは、各種パッケージをインストールする。

npm install puppeteer @axe-core/puppeteer axe-reports

コード例

公式で記載されている実装例にaxe-reportsを組み合わせた形で実装すると以下のようになる。

import { AxePuppeteer } from '@axe-core/puppeteer';
import puppeteer from 'puppeteer';
import AxeReports from 'axe-reports';

(async () => {
  const browser = await puppeteer.launch({ headless: 'new' });
  const page = await browser.newPage();

  await page.setBypassCSP(true);
  await page.goto('https://dequeuniversity.com/demo/mars/');

  try {
    AxeReports.createCsvReportHeaderRow();
    const results = await new AxePuppeteer(page).analyze();
    AxeReports.createCsvReportRow(results);
  } catch (e) {
    // do something with the error
  }

  await browser.close();
})();

対象のURLにヘッドレスブラウザでアクセスをして、Pageをaxe(AxePuppeteer)に渡して検証する。検証結果は、axe-reportsによって、CSVの形式で出力する。

大まかな実装は上記の通りだが、これを使い勝手の良いように整えていく。

検証するルール(規格)を指定する

withTags()メソッドを使うことで、検証するルール(規格)の指定もできる。

await new AxePuppeteer(page).withTags(['wcag2a', 'wcag2aa', 'wcag2aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa']).analyze();

指定できるタグは以下の通り。用途に応じて適切な規格を指定できる。

Tag NameAccessibility Standard / Purpose
wcag2aWCAG 2.0 Level A
wcag2aaWCAG 2.0 Level AA
wcag2aaaWCAG 2.0 Level AAA
wcag21aWCAG 2.1 Level A
wcag21aaWCAG 2.1 Level AA
wcag22aaWCAG 2.2 Level AA
best-practiceCommon accessibility best practices

Section 2: API Reference - Axe-core Tagsより抜粋

たとえば、対象のサイトがWCAG 2.2 Level AAに準拠していることを確認したい場合は wcag22aa を指定するといった具合である。

実装

使い勝手の良いように実装していく。以下の仕様で実装をする。

  • 複数の指定されたURLの全てのページを検証する
  • 出力結果を日本語化する

以降、それぞれの実装のコードを説明用に抜粋したものを紹介していく(完全なコードは別途公開する)。

複数の指定されたURLの全てのページを検証する

  1. urls.txt というURLの設定ファイルを用意する(1行毎にURLを記載する)

    #例
    https://example.com/
    https://example.jp/
    https://example.jp/aaa
    
  2. 設定ファイルからURLを読み込み、それぞれのURLに対して検証をしていく

    import fs from 'node:fs';
    
    const readUrls = async () => {
      const urlsFile = await fs.promises.readFile('./urls.txt', 'utf-8');
    
      const urls = urlsFile
        .replace(/\r\n?/g, '\n')
        .split('\n')
        .filter((url) => url);
    
      return urls;
    };
    

readUrls()を組み込むと以下のような形になる。urlに対して非同期処理の並列実行をPromise.all()で行う。

import { AxePuppeteer } from '@axe-core/puppeteer';
import puppeteer from 'puppeteer';
import AxeReports from 'axe-reports';

const setupAndRunAxeTest = async (url, browser) => {
  const page = await browser.newPage();
  await page.setBypassCSP(true);
  await page.goto(url);

  try {
    const results = await new AxePuppeteer(page).analyze();
    AxeReports.createCsvReportRow(results);
  } catch (e) {}
};

(async () => {
  AxeReports.createCsvReportHeaderRow();

  const urls = await readUrls();
  const browser = await puppeteer.launch({ headless: 'new' });

  try {
    await Promise.all(urls.map((url) => setupAndRunAxeTest(url, browser)));
  } catch (error) {
    console.error(`Error during tests: ${error}`);
  } finally {
    await browser.close();
  }
})();

出力結果を日本語化する

英語のままの出力で問題なければ以下の実装は不要。

見出しの日本語化

AxeReportsが出力するCSVヘッダは英語の固定値になっており変更できない(TSVも同様)。

// このような形で出力される
'URL,Volation Type,Impact,Help,HTML Element,Messages,DOM Element\r';

ここのロケールを変えたり、任意の文字を指定できないため、日本語で出力したい場合はAxeReports.createCsvReportHeaderRow()を使わず自前で出力する必要がある。

単純に時前でCSVファイルを作成するだけである。

import fs from 'node:fs';

const CSV_FILE_PATH = `./result.csv`;

// 元のヘッダー部分を日本語化したもの
const CSV_HEADER = 'URL,種別,影響度,ヘルプ,HTML要素,メッセージ,DOM要素\r';

// 既存のCSVファイルがあれば削除
if (fs.existsSync(CSV_FILE_PATH)) {
  fs.rmSync(CSV_FILE_PATH);
}

// 新しいCSVファイルを作成
fs.writeFileSync(CSV_FILE_PATH, CSV_HEADER);

// 中略

AxeReports.createCsvReportRow(results);

検証結果の日本語化

axeの検証結果は標準では英語で出力されるが、日本語のロケール(axe-core/locales/ja.json)が用意されているため、それを利用して日本語化できる。
ロケールの指定は、以下のようにAxePuppeteerのconfigure()メソッドの引数に指定することで日本語化できる。

import AXE_LOCALE_JA from 'axe-core/locales/ja.json';

// 中略

const results = await new AxePuppeteer(page).configure({ locale: AXE_LOCALE_JA }).analyze();
検証結果の影響度

メッセージ部分はロケールを指定することで日本語化されるが、影響度は英語のままで出力される。

影響度として、criticalseriousmoderateminorが定義されている。出力した際に分かりやすいように以下のように置き換える。

英語日本語
critical緊急(Critical)
serious深刻(Serious)
moderate普通(Moderate)
minor軽微(Minor)

AxePuppeteerのanalyze()メソッドの戻り値に対して、指定の影響度の文字列を置き換える。AxeResultsの値に応じて置換をしていく。

import type { AxeResults, ImpactValue } from 'axe-core';

type AxeResultsKeys = keyof Omit<
  AxeResults,
  'toolOptions' | 'testEngine' | 'testRunner' | 'testEnvironment' | 'url' | 'timestamp'
>;

const CSV_TRANSLATE_RESULT_GROUPS: AxeResultsKeys[] = ['inapplicable', 'violations', 'incomplete', 'passes'];
const CSV_TRANSLATE_IMPACT_VALUE = {
  critical: '緊急 (Critical)',
  serious: '深刻 (Serious)',
  moderate: '普通 (Moderate)',
  minor: '軽微 (Minor)',
};

const replaceImpactValues = (axeResult: AxeResults): AxeResults => {
  const result = { ...axeResult };

  for (const key of CSV_TRANSLATE_RESULT_GROUPS) {
    if (result[key] && Array.isArray(result[key])) {
      const updatedItems = [];
      for (const item of result[key]) {
        if (item.impact && CSV_TRANSLATE_IMPACT_VALUE[item.impact]) {
          updatedItems.push({
            ...item,
            impact: CSV_TRANSLATE_IMPACT_VALUE[item.impact] as ImpactValue,
          });
        } else {
          updatedItems.push(item);
        }
      }
      result[key] = updatedItems;
    }
  }

  return result;
};

// 中略

const results = await new AxePuppeteer(page)
  .configure({ locale: AXE_LOCALE_JA })
  .analyze()
  .then((analyzeResults) => replaceImpactValues(analyzeResults));

その他

axeとは直接関係ない部分を紹介する。

デバイスの指定

以下のようにpage.emulate()メソッドを使うことでデバイス指定ができる。

// モバイルの指定をした場合
const userAgent = await browser.userAgent();
await page.emulate({
  userAgent,
  viewport: {
    width: 375,
    height: 812,
    isMobile: true,
    hasTouch: true,
  },
});

page.emulateuserAgent は必須項目のため、現状の browser.userAgent() を利用する。

さらにデバイスのフラグを.envファイルにもたせるなどして、切り替えできるようにしておくと良い。

ページ最下部までスクロールする

スクロールすることで読み込まれるコンテンツを検証するために、ページの最下部までスクロールする。

無限スクロールが実装されているページなどでは永久にスクロールが終わらなくなってしまうため、スクロール回数の上限を設けている。

/**
 * 指定した時間だけ待機する関数
 * @param ms 待機時間(ミリ秒)
 */
const waitForTimeout = (ms: number): Promise<void> => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

/**
 * ページの最下部までスクロールする
 */
const scrollToBottom = async (page: Page, maxScrolls = 10, waitTime = 3000): Promise<void> => {
  let previousHeight = 0;
  let scrollCount = 0;

  while (scrollCount < maxScrolls) {
    // 現在のページの高さを取得
    const currentHeight: number = await page.evaluate(() => document.body.scrollHeight);

    // 前回と高さが変わらなければ終了
    if (previousHeight === currentHeight) break;

    // ページの最下部までスクロール
    await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
    previousHeight = currentHeight;

    // 指定された時間だけ待機
    await waitForTimeout(waitTime);

    // スクロール回数をカウント
    scrollCount++;
  }
};

完成したコード例

これまでの実装例を組み合わせると、以下のような形になった。実際はもう少しファイル分割をすると良い。

import 'dotenv/config';

import fs from 'node:fs';
import { AxePuppeteer } from '@axe-core/puppeteer';
import type { Spec, AxeResults, ImpactValue } from 'axe-core';
import AxeReports from 'axe-reports';
import puppeteer, { Browser, Page } from 'puppeteer';
import AXE_LOCALE_JA from 'axe-core/locales/ja.json';

import type { AxeResultsKeys } from './types';

export const FILE_NAME = 'result';
export const FILE_EXTENSION = 'csv';
export const CSV_FILE_PATH = `./${FILE_NAME}.${FILE_EXTENSION}`;
export const CSV_HEADER = 'URL,種別,影響度,ヘルプ,HTML要素,メッセージ,DOM要素\r';
export const CSV_TRANSLATE_RESULT_GROUPS: AxeResultsKeys[] = ['inapplicable', 'violations', 'incomplete', 'passes'];
export const CSV_TRANSLATE_IMPACT_VALUE = {
  critical: '緊急 (Critical)',
  serious: '深刻 (Serious)',
  moderate: '普通 (Moderate)',
  minor: '軽微 (Minor)',
};

/**
 * URLをファイルから非同期で読み込む
 */
const readUrls = async (): Promise<string[]> => {
  const urlsFile = await fs.promises.readFile('./urls.txt', 'utf-8');

  const urls = urlsFile
    .replace(/\r\n?/g, '\n')
    .split('\n')
    .filter((url) => url);

  return urls;
};

/**
 * 指定した時間だけ待機する関数
 * @param ms 待機時間(ミリ秒)
 */
const waitForTimeout = (ms: number): Promise<void> => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

/**
 * ページの最下部までスクロールする
 */
const scrollToBottom = async (page: Page, maxScrolls = 10, waitTime = 3000): Promise<void> => {
  let previousHeight = 0;
  let scrollCount = 0;

  while (scrollCount < maxScrolls) {
    const currentHeight: number = await page.evaluate(() => document.body.scrollHeight);

    if (previousHeight === currentHeight) break;

    await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
    previousHeight = currentHeight;

    await waitForTimeout(waitTime);

    scrollCount++;
  }
};

/**
 * Axeの結果の影響度の値を日本語に置き換える
 */
const replaceImpactValues = (axeResult: AxeResults): AxeResults => {
  const result = { ...axeResult };

  for (const key of CSV_TRANSLATE_RESULT_GROUPS) {
    if (result[key] && Array.isArray(result[key])) {
      const updatedItems = [];
      for (const item of result[key]) {
        if (item.impact && CSV_TRANSLATE_IMPACT_VALUE[item.impact]) {
          updatedItems.push({
            ...item,
            impact: CSV_TRANSLATE_IMPACT_VALUE[item.impact] as ImpactValue,
          });
        } else {
          updatedItems.push(item);
        }
      }
      result[key] = updatedItems;
    }
  }

  return result;
};

/**
 * Axeによるアクセシビリティテストを実行する
 */
const runAxeTest = async (page: Page, url: string): Promise<AxeResults> => {
  console.log(`Testing ${url}...`);

  // 指定されたURLにアクセス
  await page.goto(url, { waitUntil: ['load', 'networkidle2'] }).catch(() => {
    console.error(`Connection failed: ${url}`);
  });

  console.log(`page title: ${await page.title()}`);

  await scrollToBottom(page);

  const results = await new AxePuppeteer(page)
    .configure({ locale: AXE_LOCALE_JA } as unknown as Spec)
    .withTags(['wcag2a', 'wcag21a', 'best-practice'])
    .analyze()
    .then((analyzeResults) => replaceImpactValues(analyzeResults));

  return results;
};

/**
 * URLごとにページを設定し、アクセシビリティテストを実行する
 */
async function setupAndRunAxeTest(url: string, browser: Browser) {
  const page = await browser.newPage();
  await page.setBypassCSP(true);

  /**
   * process.env.DEVICE_TYPE
   * @type {"0" | "1" | undefined}
   * @description "0" はデスクトップ / "1" はモバイル
   */
  if (process.env.DEVICE_TYPE === '1') {
    const userAgent = await browser.userAgent();
    await page.emulate({
      userAgent,
      viewport: {
        width: 375,
        height: 812,
        isMobile: true,
        hasTouch: true,
      },
    });
  }

  try {
    const results = await runAxeTest(page, url);
    AxeReports.processResults(results, FILE_EXTENSION, FILE_NAME);
  } catch (error) {
    console.error(`Error testing ${url}:`, error);
  } finally {
    await page.close();
  }
}

(async () => {
  const urls = await readUrls();

  if (fs.existsSync(CSV_FILE_PATH)) {
    fs.rmSync(CSV_FILE_PATH);
  }
  fs.writeFileSync(CSV_FILE_PATH, CSV_HEADER);

  const browser = await puppeteer.launch({ headless: 'new' });

  try {
    await Promise.all(urls.map((url) => setupAndRunAxeTest(url, browser)));
  } catch (error) {
    console.error(`Error during tests: ${error}`);
  } finally {
    await browser.close();
  }
})();

検証結果

完成したコードでデジタル庁のURLを指定してアクセシビリティの検証をしてみる。

  • URL
    https://www.digital.go.jp/
  • ルール
    withTags(['wcag2a', 'wcag21a', 'best-practice']);
    
  • その他
    • Node.js上で実行するが、TypeScriptで実装しているため、ts-nodeもしくはnode -r esbuild-registerなどを使って実行する

スクリプトの実行後、以下のような結果がCSV出力された。日本語化の対応によって、影響度やヘルプ(のURL)、メッセージが日本語で出力されていることが確認できる。

URL,種別,影響度,ヘルプ,HTML要素,メッセージ,DOM要素
https://www.digital.go.jp/,heading-order,普通 (Moderate),https://dequeuniversity.com/rules/axe/4.8/heading-order?application=axe-puppeteer&lang=ja,<h5 class="card-image__title text-r">マイナンバー制度・マイナンバーカード</h5>,見出しの順序が無効です,a[href$="mynumber"] > .card-image__text > h5
https://www.digital.go.jp/,page-has-heading-one,普通 (Moderate),https://dequeuniversity.com/rules/axe/4.8/page-has-heading-one?application=axe-puppeteer&lang=ja,<html lang="ja" dir="ltr" prefix="og: https://ogp.me/ns#" class=" js">,,html
https://www.digital.go.jp/,region,普通 (Moderate),https://dequeuniversity.com/rules/axe/4.8/region?application=axe-puppeteer&lang=ja,<div class="template__pagetop">,ページの一部のコンテンツがランドマークに含まれていません,.template__pagetop
https://www.digital.go.jp/,svg-img-alt,深刻 (Serious),https://dequeuniversity.com/rules/axe/4.8/svg-img-alt?application=axe-puppeteer&lang=ja,<svg role="img" class="icon icon--12px icon--arrow-rightwards">  <path xmlns="http://www.w3.org/2000/svg" d="M7.3813 1.67358L12.3591 6.59668L7.3813 11.5198L6.4582 10.5967L9.85825 7.19663H2.08008V5.99663H9.85816L6.4582 2.59668L7.3813 1.67358Z"></path></svg>,要素にタイトルを示す子要素が存在しません--aria-label属性が存在しない、または空です--aria-labelledby属性が存在しない、存在しない要素を参照している、または空の要素を参照しています--要素にtitle属性が指定されていません,a[href$="newgraduates/"] > .mdcontainer-button-inner__text > .svg-wrapper > .icon--arrow-rightwards.icon--12px.icon

出力された結果を見ると、以下のようなアクセシビリティ違反がある。

  • imgロールを持つ<svg>要素には代替テキストが存在しなければなりません
  • 見出しのレベルは1つずつ増加させなければなりません
  • ページにはレベル1の見出しが含まれていなければなりません
  • ページのすべてのコンテンツはlandmarkに含まれていなければなりません

出力結果には、Deque Universityへのリンクが含まれているため、そこから詳細を確認できる(例:https://dequeuniversity.com/rules/axe/4.8/heading-order?application=axe-puppeteer&lang=ja)

また、axe DevToolsでも同様の設定で実行して、同様の結果が得られている。

axe DevToolsの検査結果

おわりに

  • 検証ツールはアクセシビリティ違反の発見において非常に有効であり、ウェブサイトのアクセシビリティ向上に不可欠である。これらのツールは技術的な問題を迅速に特定し、改善策の策定を容易にする。
  • ただし、検証ツールではアクセシビリティの問題のすべてを検出できないため5、ツールに頼りすぎず、人間の目によるチェックや実際のユーザー体験に基づく評価も重要である。
  • 検証ツールはアクセシビリティ向上の一助となるものであり、これを補完する形で継続的な監視と改善が求められる。最終的には、これらの組み合わせた取り組みが、全てのユーザーにとってより良いアクセス可能なウェブ体験を実現することにつながる。

注釈

  1. PageSpeed Insightsを利用しても同様の結果は得られる。

  2. WCAG(Web Content Accessibility Guidelines)は、W3Cが作成しているWebコンテンツがよりアクセシブルになるための国際標準ガイドラインである。これには、視覚障害者、聴覚障害者、運動障害者など、さまざまな障害を持つユーザーがWebコンテンツを容易にアクセスできるようにするための具体的な基準が含まれている。

  3. 必ずしもWCAGの成功基準に適合しているわけではないが、ユーザー体験を向上させるために業界で受け入れられている慣行であるルール。

  4. axe-coreのルール一覧

  5. 「チェックツールで見つけられる問題は、ウェブアクセシビリティの問題の2割から3割程度」といわれている。(ウェブアクセシビリティ 導入ガイドブック