こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
このサイトは Next.js で静的な HTML を書き出して公開しています。書き出すページは日付・記事・難易度ごとに増えていき、検査を始めた時点で 1199 ページありました。
PageSpeed Insights で見ていた項目をリポジトリの中の検査に移すとき、検査を 2 段に分けました。
- HTML を読めば判定できる項目は、全ページに掛けます。
- 描画しないと分からない項目は、ページを種類に分けて、種類ごとに 1 枚だけ Lighthouse で測ります。
どちらの段も、検査するページを人が名指しせず、書き出したファイルの一覧から機械で決めます。この記事では、2 段に分けた理由と、それぞれの段で対象の漏れを防ぐ方法を説明します。
動機: 名指しの検査は新しいページを見ない
それまでの状態は次のとおりでした。
- PageSpeed Insights は本番の URL を外部の API で測るもので、月に 1 回、人が結果を見るだけでした。
- SEO の E2E テストは、特定のページを名指しして canonical や og:image を確かめていました。
名指しの検査には、ページを足したときに spec を足し忘れると、そのページが黙って対象から外れるという問題があります。実際に、月の満ち欠けを表示するページを新しく作ったとき、このページは SEO の検査の対象に入っていませんでした。テストは失敗しないので、外れていることには後から気づきました。
ページの種類が増えるサイトでは、検査対象を「どのページを見るか」で列挙すると、増えた分だけ漏れが生まれます。そこで、対象は書き出したディレクトリの走査で決めることにしました。
Lighthouse を全ページに掛けない理由
全ページを走査するなら、全ページに Lighthouse を掛ければよいように見えます。しかし Lighthouse は 1 ページ・1 条件で 10〜30 秒かかります。1,200 ページを mobile と desktop の 2 条件で測ると半日かかり、日常的に回す検査になりません。
一方で、Lighthouse の項目の中には HTML を読むだけで判定できるものが多くあります。title が 1 つあるか、見出しのレベルが飛んでいないか、img に width と height があるか、nav に名前があるか、といった項目です。これらは描画しなくても判定でき、全ページを読んでも数秒で終わります。
そこで、項目を「HTML で判定できるか」で分けました。
- HTML で判定できるもの(title・description・canonical・alt・見出しの順・lang など)は全ページを走査します。軽いので、代表を選ぶ理由がありません。
- 描画しないと分からないもの(LCP / CLS / TBT・色のコントラスト・タップ目標の大きさ・未使用の JS / CSS)は Lighthouse で実際に測ります。こちらは重いので、ページの種類ごとに 1 枚に絞ります。
1 段目: 書き出した全 HTML を読む
1 段目は、書き出した index.html と 404.html を全部読み、項目ごとに判定します。1 件でも違反があれば、ページのパスと項目を出して終了コード 1 で終わります。
判定は Lighthouse の監査項目に対応させています。たとえば見出しの飛び(Lighthouse の heading-order)は次のように見ています。
function checkHeadingOrder(page: PageDoc): Violation[] {
const out: Violation[] = [];
let previous = 0;
for (const el of page.$('h1, h2, h3, h4, h5, h6').toArray()) {
if (page.$(el).closest('[aria-hidden="true"], [hidden]').length > 0) continue;
const level = Number(el.tagName.slice(1));
if (previous > 0 && level > previous + 1) {
out.push(violation(page, 'a11y:heading-order', `h${previous} の次が h${level}`));
}
previous = level;
}
return out;
}
読み上げから隠してある見出しは、読み上げの順に出てこないので対象から外しています。同じ深さが続く場合や、浅いレベルへ戻る場合は違反にしません。
HTML のパーサは、既に依存に入っていた cheerio を使いました。cheerio の既定のパーサ(parse5)では 1199 ページの判定に 9.0 秒かかり、htmlparser2 を使う版(cheerio/slim)に替えると 2.1 秒になりました。テストの一段として組み込んだ後の所要時間は 1199 ページで 8.3 秒で、判定以外の時間はスクリプトの起動とディレクトリの走査です。この長さなら、commit のたびに回す検査に入れられます。
意図して規則から外れるページ(404 ページを sitemap に載せない、検索ページを noindex にする、など)は、除外一覧に理由を付けて並べます。一覧に無いページは全部検査します。
期待値は書き出しの設計に合わせる
最初に書いた判定には、検査側の思い込みが 1 つ入っていました。英語版のページでは <html lang="en"> になっているはずだと考えて判定を書いたところ、109 件の違反が出ました。
しかしこのサイトの英語版は、<html lang> を ja のままにして、本文の言語を <main lang="en"> で持つ設計です。判定を <main lang> を見るように直すと、違反は 65 件になりました。この 65 件は、<main lang="en"> を付け忘れていたページ(年・月・単元・天気・検索などの英語版)で、本当に直すべき違反でした。
同じ理由で、canonical や og:url の突き合わせに使うサイトの origin も、検査側に定数として持たないようにしました。書き出した robots.txt の Sitemap: 行から取ります。ローカルの origin で書き出した出力でも本番の origin で書き出した出力でも、違反の数が一致することを確かめてあります。
検査の期待値を検査側に書き写すと、書き出しの設計とずれたときに、正しいページを違反として数えます。期待値は書き出しの側が既に持っている決定から読むほうが安全です。
違反を全部直してから有効にする
最初に回した時点で、違反は 4391 件ありました。多かったのは次の 2 つです。
- nav の名前の重複(2423 件): 記事ページはパンくずリストを上と下に出していて、2 つの
<nav>が同じaria-labelを持っていました。Lighthouse(axe)のlandmark-uniqueに当たります。 - 見出しの飛び(240 件): 目次の見出しに MUI の Typography の
subtitle2を使っていて、これが既定で<h6>を出していました。h2の次がh6になります。
この状態で検査を「警告」として有効にすることはしませんでした。警告は誰も読まないので、違反が増えても気づけません。違反を除外一覧に入れて先に有効にする案も採りませんでした。除外一覧は「規則のほうが当たらないページ」のためのもので、直すべき違反を入れると、検査は今の出力を追認するだけになります。項目ごとに直して違反を 0 件にしてから、テストの一段として有効にしました。
2 段目: URL を種類に分けて 1 枚ずつ測る
2 段目は、書き出した HTML をローカルの静的サーバで配り、Playwright が持っている Chromium に繋いで Lighthouse を回します。外部の API は使わないので、キーも回数の制限も要らず、まだ公開していないページも測れます。
測るページは、URL を正規表現で種類に分けて決めます。次のコードは種類の一部だけを残したもので、コメントは省いています。
const rules: { kind: PageKind; pattern: RegExp }[] = [
{ kind: 'top', pattern: new RegExp('^/$') },
{ kind: 'notFound', pattern: new RegExp('^(?:/404(?:\\.html|/)|/_not-found/)$') },
{ kind: 'weather', pattern: new RegExp('^/weather/$') },
{ kind: 'article', pattern: new RegExp(`^/\\d{4}/\\d{2}/\\d{2}/${SEG}/articles/${UUID}/$`) },
{ kind: 'day', pattern: new RegExp(`^/\\d{4}/\\d{2}/\\d{2}/${SEG}/$`) },
{ kind: 'month', pattern: new RegExp(`^/\\d{4}/\\d{2}/(?:${SEG}/)?$`) },
];
export function classifyUrl(url: string): PageKind | null {
return rules.find(rule => rule.pattern.test(url))?.kind ?? null;
}
export function selectPages(urls: readonly string[]) {
const byKind = new Map<PageKind, string[]>();
const unclassified: string[] = [];
for (const url of urls) {
const kind = classifyUrl(url);
if (kind === null) {
unclassified.push(url);
continue;
}
byKind.set(kind, [...(byKind.get(kind) ?? []), url]);
}
const picked = kinds.filter(kind => byKind.has(kind)).map(kind => ({
kind,
url: pickOne(byKind.get(kind) ?? []),
}));
return { picked, unclassified };
}
規則は上から順に当て、最初に当たった種類にします。記事の URL は日付のページの URL で始まるので、記事の規則を日付のページより前に置いています。
分類できない URL があれば落とす
unclassified が 1 件でもあれば、検査は失敗します。ページの種類を足したとき、分類の規則を足すまで検査が通らないので、新しい形のページが黙って計測の外に出ることがありません。
実際に最初に回したとき、Next.js が書き出す /_not-found/ がどの規則にも当たらずに失敗し、そこで初めてこの出力の存在に気づきました。中身は /404.html と同じなので、404 の種類に入れています。
代表の選び方
同じ種類のページが複数あるときは、1 枚を機械的に選びます。
- このサイトにはページごとに「ふつう」「やさしい」「英語」の 3 つの版があります。版で作りが変わるもの(トップ・天気・月)は版ごとに別の種類として数え、版で作りが変わらないもの(日付のページ・記事・月・年など)は 1 つの種類にまとめて、ふつうの版を代表にします。
- 日付が入るものは新しいほうを選びます。直近に書き出したページを測ることになります。
- 404 のように同じ中身が複数の形で書き出されているときは、ホストが実際に返す
/404.htmlを選びます。
最初の実測で分かったこと
本番の origin で書き出した出力を、19 種類 × mobile / desktop の 38 枚で測りました。所要時間は約 25 分でした。
この結果で下限を決めると、38 枚のうち 7 枚が落ちました。落ちた理由は 2 つだけで、どちらも測る機械の混み具合ではなく、ページの作りの問題でした。
- 天気ページの 3 つの版で CLS が大きく(mobile 0.41〜0.44・desktop 0.22〜0.24)、それに引きずられて desktop の Performance も下がっていました。
- 英語版の天気ページで SEO が 92 でした。Lighthouse の
link-textが「Learn more」というリンクの文字を、行き先の分からない文字として数えたためです。
どちらも、HTML を読むだけの 1 段目では見つけられない項目です。
種類ごとに 1 枚しか測らないので、同じ種類の他のページだけで起きる問題(その記事にだけ大きい画像がある、など)は 2 段目では見逃します。その分は、全ページを読む 1 段目で img の寸法などを見て補っています。
Lighthouse の段は 1 回に 5〜10 分かかるので、commit のたびには回しません。見た目・レイアウト・画像・スクリプトの量を変えたときに手で回します。
一般化できる部分
- 検査の項目を「HTML を読めば判定できるか」で分けると、重い測定を回す対象を大きく減らせます。判定できる項目は全ページに掛けても数秒で終わります。
- 検査の対象をページの名指しで決めると、ページを足したときに漏れます。書き出したファイルの走査で決めれば、足したページは自動で対象に入ります。
- 代表を選んで測る場合は、分類できないものを失敗にします。こうすると、新しい種類が増えたことを検査が知らせます。
- 期待値は検査側に書き写さず、書き出し側の決定から読みます。書き写した期待値は、設計とずれたときに正しいページを違反として数えます。
- 違反が多い状態で検査を有効にするときは、警告や除外一覧で先に通すのではなく、違反を 0 件にしてから有効にします。
まとめ
- HTML で判定できる項目は全ページを走査(1199 ページで 8.3 秒)
- 描画が要る項目は URL を種類に分けて 1 枚ずつ Lighthouse で実測
- 分類できない URL があれば失敗にして、新しいページを計測の外に出さない
- 期待値は書き出しの設計から読む(英語版の lang は
<main>側) - 違反を全部直してから検査を有効にする