🦁

Lighthouseのプラグインを作る

2025/03/03に公開

Lighthouseのドキュメントを調べていたら、カスタムプラグインを作れるらしいのに気づきました。

https://github.com/GoogleChrome/lighthouse/blob/main/docs/plugins.md

カスタムな Audit を作りたかったので、やっていきます。

この記事の知識を多少要求します。

https://zenn.dev/mizchi/articles/lighthouse-reading

tl;dr

  • Lighthouseのカスタムプラグインは「Gatherer」と「Audit」の2つのコンポーネントで構成される
  • Gathererはデータを収集し、Auditはそのデータを使ってスコアを計算する
  • Audit.meta.requiredArtifacts で必要なGathererを指定すると、自動的に Gatherer#getArtifact の結果が渡される
  • カスタムプラグインを使えば、カスタム指標評価が作れる

最終的にこういうのが出来ました

// Gatherer の生成物
🔍 MyGatherer { result: "gatherer-result" }

// Custom Auditの出力
🔍  {
  id: "my-custom-audit",
  title: "My Custom Audit Failed",
  description: "This is a custom audit to check for headers",
  score: 0.1,
  scoreDisplayMode: "numeric",
  details: { type: "debugdata", customArifacts: { result: "gatherer-result" } },
}

Lighthouseプラグインの基本的な仕組み

大きく分けて2つのコンポーネントから構成されています:

  1. Gatherer: ウェブページからデータを収集して、 Artifact を生成する
  2. Audit: Artifacts を使ってスコアを計算する

最小限のプラグイン実装

まずは動くものを作ってみましょう。以下のコードは、最小限のLighthouseプラグインの実装です。

deno で実装してるので、node から使う場合は npm を外してください

/**
 * Lighthouse Gatherer Audit Sample
 * tl;dr Audit.meta.requiredArtifacts => Gatherer#getArtifact
 */

import lighthouse, {
  Config as LhConfig,
  Flags as LhFlags,
  Gatherer,
  Audit,
} from "lighthouse";
// NOTE: lightouse internal types
import type LH from "lighthouse/types/lh";

type MyGathererResult = {
  result: string;
};
class MyGatherer extends Gatherer {
  // @ts-ignore
  meta = {
    supportedModes: ["snapshot", "navigation"],
  };
  override startInstrumentation(
    _pass: LH.Gatherer.Context
  ): Promise<void> | void {
    console.log("MyGatherer:startInstrumentation");
  }
  override startSensitiveInstrumentation(
    _pass: LH.Gatherer.Context
  ): Promise<void> | void {
    console.log("MyGatherer:startSensitiveInstrumentation");
  }
  override stopSensitiveInstrumentation(
    _pass: LH.Gatherer.Context
  ): Promise<void> | void {
    console.log("MyGatherer:stopSensitiveInstrumentation");
  }
  override stopInstrumentation(
    _pass: LH.Gatherer.Context
  ): Promise<void> | void {
    console.log("MyGatherer:stopInstrumentation");
  }
  override getArtifact(_pass: LH.Gatherer.Context) {
    const result: MyGathererResult = {
      result: "gatherer-result",
    };
    return result as any as LH.Gatherer.PhaseResult;
  }
}

interface CustomArifacts extends LH.Artifacts {
  MyGatherer: MyGathererResult;
}

class MyCustomAudit extends Audit {
  static override meta: LH.Audit.Meta = {
    id: "my-custom-audit",
    title: "My Custom Audit",
    failureTitle: "My Custom Audit Failed",
    description: "This is a custom audit to check for headers",
    requiredArtifacts: ["DevtoolsLog", MyGatherer.name as any],
  };

  static override async audit(
    artifacts: CustomArifacts,
    ctx: LH.Audit.Context
  ): Promise<LH.Audit.Product> {
    console.log("🔍 MyGatherer", artifacts.MyGatherer);
    return {
      score: 0.1,
      scoreDisplayMode: "numeric",
      details: {
        type: "debugdata", // NOTE: required for custom audit
        customArifacts: artifacts.MyGatherer,
      },
    };
  }
}

// Run
import { killAll, launch } from "chrome-launcher";
import chromeFinder from "chrome-finder";

async function runLighthouse(url: string) {
  const LH_FLAGS: LhFlags = {
    logLevel: "error",
    output: "json",
    onlyAudits: ["my-custom-audit"],
    disableFullPageScreenshot: true,
  };
  const CHROME_OPTIONS = {
    chromePath: chromeFinder(),
    chromeFlags: ["--headless=new", "--user-data-dir=_tmp"],
  };

  const chrome = await launch(CHROME_OPTIONS);
  const flags: LhFlags = {
    ...LH_FLAGS,
    port: chrome.port,
  };
  const config: LhConfig = {
    extends: "lighthouse:default",
    artifacts: [
      {
        id: "MyGatherer",
        gatherer: {
          implementation: MyGatherer as any,
        },
      },
    ],
    audits: [{ path: "my-custom-audit", implementation: MyCustomAudit }],
  };

  const results = await lighthouse(url, flags, config);
  const auditResults = results?.lhr.audits || {};
  console.log("🔍 ", auditResults["my-custom-audit"]);
  chrome.kill();
  killAll();
  Deno.exit(0);
}

// スクリプトを実行
if (import.meta.main) {
  const url = Deno.args[0] || "https://example.com";
  await runLighthouse(url).catch(console.error);
}

このコードは、シンプルなGathererとAuditを実装しています。実際には何も有用なことはしていないんですが、基本的な構造を理解するには十分です。

Gathererの役割と実装方法

Gathererは必要なデータを収集するコンポーネントです。特定のタイミングで呼び出されるライフサイクルメソッドを持っています。

class MyGatherer extends Gatherer {
  // メタデータ - サポートするモードを指定
  meta = {
    supportedModes: ["snapshot", "navigation"],
  };
  
  // 計測開始時に呼ばれる
  override startInstrumentation(_pass: LH.Gatherer.Context): Promise<void> | void {
    console.log("MyGatherer:startInstrumentation");
  }
  
  // ユーザー操作が必要な計測の開始時
  override startSensitiveInstrumentation(_pass: LH.Gatherer.Context): Promise<void> | void {
    console.log("MyGatherer:startSensitiveInstrumentation");
  }
  
  // ユーザー操作が必要な計測の終了時
  override stopSensitiveInstrumentation(_pass: LH.Gatherer.Context): Promise<void> | void {
    console.log("MyGatherer:stopSensitiveInstrumentation");
  }
  
  // 計測終了時に呼ばれる
  override stopInstrumentation(_pass: LH.Gatherer.Context): Promise<void> | void {
    console.log("MyGatherer:stopInstrumentation");
  }
  
  // 最も重要なメソッド - 収集したデータを返す
  override getArtifact(_pass: LH.Gatherer.Context) {
    const result: MyGathererResult = {
      result: "gatherer-result",
    };
    return result as any as LH.Gatherer.PhaseResult;
  }
}

大事なのは多分、 startSensitiveInstrumentation と getArtifact です。仕込んで取り出すという感じ

サンプルコードでは、各ライフサイクルメソッドでコンソールログを出力しているだけですが、実際のプラグインでは、これらのメソッド内でページの情報を収集します。

例えば、ページ内のすべてのメタタグを収集するGathererは以下のように実装できます:

class MetaTagGatherer extends Gatherer {
  meta = {
    supportedModes: ["snapshot"],
  };
  
  async getArtifact(context) {
    const driver = context.driver;
    
    // JavaScriptを実行してメタタグを取得
    const metaTags = await driver.executionContext.evaluate(() => {
      const tags = document.querySelectorAll('meta');
      return Array.from(tags).map(tag => ({
        name: tag.getAttribute('name'),
        content: tag.getAttribute('content'),
        property: tag.getAttribute('property'),
      }));
    }, { args: [] });
    
    return metaTags;
  }
}

このGathererは、ページ内のすべてのメタタグを収集し、その情報をAuditに渡します。

Auditの役割と実装方法

Auditは、Gathererが収集したデータを使って、スコアを計算するコンポーネントです。

class MyCustomAudit extends Audit {
  // メタデータ - Auditの基本情報と必要なGathererを指定
  static override meta: LH.Audit.Meta = {
    id: "my-custom-audit",
    title: "My Custom Audit",
    failureTitle: "My Custom Audit Failed",
    description: "This is a custom audit to check for headers",
    requiredArtifacts: ["DevtoolsLog", MyGatherer.name as any],
  };

  // 実際の評価ロジック
  static override async audit(
    artifacts: CustomArifacts,
    ctx: LH.Audit.Context
  ): Promise<LH.Audit.Product> {
    console.log("🔍 MyGatherer", artifacts.MyGatherer);
    return {
      score: 0.1,  // 0〜1の範囲でスコアを返す
      scoreDisplayMode: "numeric",
      details: {
        type: "debugdata",
        customArifacts: artifacts.MyGatherer,
      },
    };
  }
}

ここで重要なのは meta.requiredArtifacts プロパティです。このプロパティで、Auditが必要とするGathererを指定します。Lighthouseは、このプロパティを見て、必要なGathererを実行し、その結果をAuditに渡します。

サンプルコードでは、"DevtoolsLog""MyGatherer" を指定しています。"DevtoolsLog" はLighthouseの標準Gathererで、"MyGatherer" は自作のGathererです。

例えば、先ほどのMetaTagGathererを使って、特定のメタタグが存在するかをチェックするAuditは以下のように実装できます:

class RequiredMetaTagsAudit extends Audit {
  static meta = {
    id: 'required-meta-tags',
    title: 'Required meta tags are present',
    failureTitle: 'Missing required meta tags',
    description: 'Checks if all required meta tags are present',
    requiredArtifacts: ['MetaTagGatherer'],
  };
  
  static audit(artifacts) {
    const metaTags = artifacts.MetaTagGatherer;
    
    // 必須メタタグのリスト
    const requiredTags = [
      { name: 'description' },
      { property: 'og:title' },
      { property: 'og:description' },
    ];
    
    // 必須メタタグが存在するかチェック
    const results = requiredTags.map(required => {
      const found = metaTags.some(tag => 
        (required.name && tag.name === required.name) ||
        (required.property && tag.property === required.property)
      );
      
      return {
        tag: required.name || required.property,
        found,
      };
    });
    
    const allFound = results.every(r => r.found);
    
    return {
      score: allFound ? 1 : 0,
      details: {
        type: 'table',
        headings: [
          { key: 'tag', itemType: 'text', text: 'Tag' },
          { key: 'found', itemType: 'text', text: 'Found' },
        ],
        items: results,
      },
    };
  }
}

このAuditは、必須メタタグが存在するかをチェックし、存在しない場合はスコアを0、すべて存在する場合はスコアを1とします。

余談: 実際のLighthouseスコアって結局何

Auditoはカテゴリごとに分類されおり、カテゴリ内の重要度を加味した加重平均がいつもみているスコアになります。

プラグインの設定と実行方法

実際にLighthouseプラグインを実行するには、以下のような設定が必要です:

const config: LhConfig = {
  extends: "lighthouse:default",  // デフォルト設定を継承

  // gatherer を追加
  artifacts: [
    {
      id: "MyGatherer",
      gatherer: {
        implementation: MyGatherer as any,
      },
    },
  ],

  // audit を追加
  audits: [{ path: "my-custom-audit", implementation: MyCustomAudit }],
};

本アリはJSONの設定データを受け付ける部分ですが、実装をそのまま注入します。ここがドキュメントになく、型を読み解いて実装必要があり、大変でした…

この設定では、MyGathererMyCustomAudit をLighthouseに登録しています。

実行部分は以下のようになります:

async function runLighthouse(url: string) {
  // Chromeの起動オプション(なんでもいい)
  const CHROME_OPTIONS = {
    chromePath: chromeFinder(),
    chromeFlags: ["--headless=new", "--user-data-dir=_tmp"],
  };

  // Lighthouseのフラグ
  const LH_FLAGS: LhFlags = {
    logLevel: "error",
    output: "json",
    onlyAudits: ["my-custom-audit"],  // 自作のAuditのみ実行
    disableFullPageScreenshot: true,
  };

  // Chromeを起動
  const chrome = await launch(CHROME_OPTIONS);
  const flags: LhFlags = {
    ...LH_FLAGS,
    port: chrome.port,
  };

  // Lighthouseを実行
  const results = await lighthouse(url, flags, config);
  const auditResults = results?.lhr.audits || {};
  console.log("🔍 ", auditResults["my-custom-audit"]);
  
  // クリーンアップ
  chrome.kill();
  killAll();
  Deno.exit(0);
}

このコードでは、Chromeをヘッドレスモードで起動し、指定したURLに対してLighthouseを実行しています。結果は、results.lhr.audits オブジェクトに格納されます。

まとめ

基本的な流れは:

  1. Gathererを実装してデータを収集
  2. Auditを実装してスコアを計算
  3. 設定を作成してLighthouseを実行

Denoだと直接Lighthouseを実行できるのが便利でした。

おわり

Discussion