機械学習APIでWebサイトの改善点を提案するサービスを作った話


まえがき

アクセシビリティーの観点からWebサイトを診断し、AIプラットフォームを利用して得た情報をもとにベストプラクティスを提案してくれるオープンソースのWebサービス「Visible」を開発しました。

GoogleのLighthouseなど、Webサイトの診断を行ってくれるサービスは以前からありましたが、診断だけではなく改善点の提案も行う新しいサービスになっています。また、アクセシビリティーに関する理解を深めてもらえるように工夫をした設計にしていたり、コマンドライン版ではスタンドアロンで実行可能なようになっています。

2020年度の「独創的アイデアと卓越した技術を持つ小中高生クリエータ支援プログラム」未踏ジュニアに採択されていて、技術・資金面で援助を頂いており、11月1日にYouTube Liveで最終成果報告会が行われる予定です!

機能の紹介

WebサイトのURLを入力することでページを診断し、改善点を自動で提案してくれます。

以下に提案する修正の一例を紹介します。

alt属性

<img>要素はスクリーンリーダーやSEOのクローラーに画像の内容を説明するためにalt属性が提供されることが推奨されており、Google Cloud PlatformのVision APIを利用してキャプションを生成することで改善のヒントを提案します。

lang属性

Webページの言語が明示的に指定されていないと、言語情報を必要とするユーザーエージェントで問題があるためページの内容からTranslate APIから言語情報を検出し提案します。

色コントラスト比

もちろん機械学習を使わない改善提案も可能です。色コントラスト比が低いと色覚特性を持ったユーザーが使いづらいため、コントラストを上げる修正を提案します。

アクセシビリティーに関するベストプラクティスはW3CによってWCAGという名前で標準化されており、他にも標準に基づいたルールがいくつかあります。

提案の仕組み

診断プログラムの実行にはChromeをヘッドレスで実行できるPuppeteerを使っていて、チェックポイント(Rule)のインターフェイスを実装したプログラムを実行し、各ruleから返された値をもとにファイルの情報と紐付けて最終的に差分として表示できるようになっています。

全体はコア、プラグイン、アプリケーションの3つのコンポーネントから構成されていて、コアで公開されている最低限の実装とインターフェイスを実装する形でプラグインで実際の処理を書いています。プラグインの形式はESLintを参考にしています。

改善点の生成アルゴリズムやヘッドレスブラウザーもruleと同様にプラグインとして拡張可能なため、Google Cloud Platformに限らず別の方法を使うことも可能です。

使った技術

プロジェクトはTypeScriptで開発されています。Tech stacksは以下のとおりです。

コア部分

  • Puppeteer - Chromeを使ったヘッドレスブラウザーです。診断プログラムの実行に使っています
  • domhandler - HTMLのASTです
  • PostCSS - プリプロセッサーとして知られていますが、stylelintを参考にASTとして使っています

Webバックエンド

  • Clean Architecture - 4層にレイヤー分けした有名なバックエンドの設計方法です。
  • TypeORM - TypeScript向けのORMです。
  • Bull - Redisを使ったジョブキューのフレームワークです
  • Apollo - Node.jsのGraphQL実装です

Webフロントエンド

  • Next.js - SSR/SSG/LambdaをやってくれるReact向けのBFFです
  • Apollo - Node.jsのGraphQL実装です
  • Tailwind CSS - Utility-firstのCSSフレームワークです
  • i18next - JS向けの国際化ライブラリです。

その他

  • GitHub Actions - CI/CDに使っています
  • Docker - Web版のデプロイに使っています
  • Yargs - CLI版に使っているフレームワークです
  • Lerna - JSのモノリポジトリのためのツールです

開発時のエピソード

Webアクセシビリティーという技術には前から興味があった一方で、僕自身は支援技術の利用を迫られたことがなく、正しいマークアップを心がけるモチベーションは専ら検索エンジンへの最適化くらいだったため、知識はあってもアクセシビリティーを欠いたWebサイトを作ってしまうことが多くあり、ESLintみたいに修正の方法も教えてくれるソフトウェアがあればいいと考えていました。

同時に、未踏ジュニアに応募できる年齢制限が17歳までで、当時僕はすでに17歳だったため最後にチャレンジしてみたいと考えていて、丁度いい機会にそれを作って応募してみようとプロトタイプを制作し始めました。

プロトタイプ

僕自身は中学生のときからプログラムを書いていてアルバイトだったりもしてるのでコードはかなり書いていましたが、それでも応募まで数ヶ月しかない状態でプロトタイプを仕上げるのは結構厳しく(正直採択後よりも応募段階のほうが忙しかったかもしれない)明確にゴールを設定する必要がありました。

応募自体にはプロトタイプは必須ではなくプロダクトの概要を書いた書類を送れば良かったものの、採択されるには僕自身が持っている技術を示して最後まで作り上げることができることを証明する必要がありました。逆に、それを示せればどこが面白いのかは伝わると判断し診断できる項目は最低限に絞り、「修正の提案」はバッサリ捨てて、「URLを入力したら診断結果が出てくる」ところまでをやることにしました。あとは脳内のふわふわした概念をノートに書いてドメインモデルに落とし込み、一番慣れている技術スタックでコードを書き始めました。

応募時のプロトタイプのスクリーンショット

大体2ヶ月くらいで動くプロトタイプが出来上がり、無事書類選考も通過しました。その後は面接を受けることになっていて、オンラインでメンター陣にプロダクトに関する質問を受けました。正直何を言ったかよく覚えていませんが、確かプロダクト自体が将来的にどういう目標があるかみたいなことを訊かれて、漠然とした回答しかできなかったのは覚えています...。

採択後

採択後は、既にプロトタイプがあったためアジャイルで開発しました。未踏ジュニアでは定期的にメンタリングを受けられることになっていて、僕のプロジェクトでは週に一度メンターに進捗を報告しフィードバックを頂いていたので、だいたい各週でマイルストーンを設けてそれまでに小分けにした機能の開発を進めました。

ユーザーに使ってもらうまでのタスクの優先順位は完全に僕の勘で「これができるようになったぞ」って言ったときのインパクトが強い順でやっていましたが、今考えるとあんまりいい方法ではなかったかなと思います。ただ、それがあったおかげでビジネスロジックには拘ってもフレームワークに関する細かいところを弄りすぎることはなくてスピードは上がっていた気がします。

インパクト重視な機能の例

初ユーザーテスト

未踏ジュニアでは期間中、採択直後と中間時点と最後の3回登壇する機会があり(今年はオンラインでしたが)、一回目のプレゼンの機会がやってきたので既にあるプロトタイプ+αの段階のものを発表しました。

その際に、その時点のものをデプロイしたURLを共有し聴いていた方たちに実際に使っていただいたのですが、dockerの共有メモリの設定をミスっていたり非同期にするべきところを同期でレスポンスしたいたりして発表直後にサーバーがダウンしてしまい、期待していたほどのフィードバックは得られませんでした (トホホ〜)

A11yが専門の方々へのインタビュー

期間の四分の一を過ぎたあたりでメンター陣のご協力もあり某社のアクセシビリティーチームの方にフィードバックしていただく機会を頂けました。

アクセシビリティーの現場でどんなプロセスが行われているのかや、どんなツールを使っているか、チーム開発特有の問題を伺うことができ、このインタビューでストーリーラインを具体化してユーザー層を絞ることができました。タスク優先度もここらへんから明確になってきたと思います。

その後の改善

未踏ジュニアでの2回目の発表(もちろん負荷対策はしました...)やTwitterのフォロワーなどを活用して、実際に使ってもらいアンケートに答えてもらうというフィードバックループを小分けに回しました。

フィードバックは「How to create a good survey」と検索して上に出てきた良いとされる質問をパクってフィードバック用のフォームをGoogle Formsで作成したものと、Google Analyticsのタグを埋め込んだもので多面的に得られるようにしています。

特にユーザーがどの情報を欲しがっているかは重視していて、例えば未実装の機能にもURLを割り振ってそのURLにどれくらいトラフィックが発生したかで機能の優先度を付けることで開発に反映しました。

詰まったところ

すげーニッチな内容かもしれませんが、開発にあたって詰まったところのメモを書いておきます。

CSSのDOMから取れる情報とASTのマッピングができない

HTMLやCSSなどのソースコードはブラウザでパースされたあとにDOMに変換されてJavaScriptから利用可能になりますが、getComputedStlyeなどのメソッドから取得できるCSSの情報からは、どのファイルや宣言が適用されているのかわかりません。

そこで、Google Chromeの開発者ツールのAPIである Chrome Devtools Protocol を使うことにしました。CDPはCSS.styleSheetAdded というイベントから読み込まれたスタイルシートの情報を取得できるため、問題検出時にNodeのIDと該当のCSSプロパティから対応するCSSファイルを探し出し、PostCSSのASTに変換して扱うことができました。

Clean ArchitectureでORMをどこに置くか問題

書籍では「Interface adapter層はframework層が必要としているデータ形式に変換するレイヤー」と説明されており、そのためSQLクエリを発行する部分は interface adapter層、それを具体的なRDBMSで実行するのはframework層という扱いになっているのですが、ORMではこの2つのプロセスの境界が曖昧で、ググっても色んな人が全然違うことを言ってるっぽかったです。

TypeORMは(もちろん限度はありますが)どのRDBMSを使うかが抽象化されていて、最終的にormconfigで決定するようになっているので、詳細について言及していないと割り切ってinterface adapter層でDALを実装することにしました。

余談ですが、ドメインモデルをAPIの形に変換するpresenterもGraphQLの定義を直接扱うのではなくpresenter側で定義した型を使うことで依存の方向を守っています。

モノレポ(Yarn Workspace)でDockerをやるのが辛すぎる

マイクロサービスとかだとモジュール同士が依存することはあんまり無いのかと思いますが、フロントエンドとバックエンドで共有するパッケージがあるみたいなケースでYarn workspaceとDockerを使いたくなってしまうと、パッケージごとにlockfileを作れなかったりnode_modules以下がシンボリックリンクになっているから単純にコピーしても動かなかったりして詰みます。

今の所ごちゃごちゃなワークアラウンドを書いてなんとか動いています。Yarn v2 (berry) では yarn workspace focus という機能が提供されて、ほしいパッケージの依存だけをインストールして独立して動かせるようになっているっぽいので早く移行したいなと思っているのですが、Plug'n'Play周りが理解しきれていないためまだ手を付けられていません。

styled-componentsが辛い

初めてちゃんとデザインシステム的なUIコンポーネントを作ったんですが、一つのコンポーネントが複数のバリアントを持ってるみたいなとき(下記)にstyled-componentsでやると可読性が最悪で最終的にTailwindに逃げました。

const Button = styled.button`
  font-size: 12px;

  ${
    (props) => props.variant === 'primary'
      ? css`
        color: ${props.theme.primaryFgColor};
        background-color: ${props.theme.primaryBgColor};
      `
      : css`
        color: ${props.theme.secondaryFgColor};
        background-color: ${props.theme.secondaryBgColor}
      `;
  }
`;

詳細: https://qiita.com/rigarashi/private/5c97be5ed8fb15ea2d96

Utilify-firstをCSS-in-JSに輸入したstyled-systemとかxstyledというやつもあるっぽいんですが、テーマの型が静的に付かなかったので見送りました。

Puppeteerで並列処理

puppeteer-clusterというライブラリがいい感じっぽかったのですが、unmaintained気味なのと、pageのインスタンスをコールバックで受け取って簡単な処理をすることしかできず、例えばObservableに変換するみたいな今回のユースケースだと厳しかったのでやめました。

代わりに、いわゆるObject Poolingでbusyなインスタンスとそうでないインスタンスを管理して、暇そうなインスタンスに処理を投げるようにしました。こういうのは多分ワーカープロセスをforkしていくのがいいのかと思いますが、Puppeteerを起動した時点ですでにChromiumのプロセスと分かれるのであんまり意味がなかったのと、GraphQLのsubscriptionのソースになっているのがただのシングルトンで、redisとかを挟んでなくて同じプロセスで呼ばないとブラウザに伝えられないためです。

あとがき

蔑ろにされがちなアクセシビリティーで人々の気を引くにはどうすればいいか考えた結果、若干地雷っぽいタイトルになってしまったことをお赦しください。

また、web版等公開していますのでぜひお試しいただきフィードバックをお訊かせいただけると嬉しいです。

関連リンク