Lighthouse CIをGitHubActionsで動かして、Laravelに自動保存してみた1

2025.09.23 09:00
2025.09.28 20:46
Lighthouse CIをGitHubActionsで動かして、Laravelに自動保存してみた1

Webサイトのパフォーマンスを定点観測したくて、Lighthouse CIをGitHub Actionsで回す仕組みを作ってみました。
前回は「GitHub ActionsでLighthouse CIを動かす」ところまででしたが、今回はその続きで「取得したスコアをLaravelにPOSTして保存する」までやってみたメモです。
細かい設計は手探りでしたが、最小構成で毎月のスコアが自動的にDBに溜まっていくようになったので紹介します。

1. やりたいこと

  • 毎月サイトを自動で計測して、スコア履歴を残したい
  • desktop / mobile で分けて保存したい
  • URLごとのrun結果は平均化して記録したい
  • Laravel側にAPIを用意して、GitHub Actionsから直接叩く

2. 流れ

  • GitHubActionsを実行
  • GitHubActionsからLightHouseCIを実行して結果をjsonに保存
  • 保存したjsonをPythonが読んで、LaravelのAPIにPOST
  • LaravelがDBに保存
  • 完了

今回はまずLaravelにPOSTするまでを書いてみます。

3. jsonデータ設計

まずはLaravelに送るペイロード(json送信内容)を決めました。
月単位でユニークにしたかったので、site_id + month(YYYY-MM-01) + preset + url_hash をキーにしています。

{
  "site_id": 1,
  "month": "2025-09-01",
  "preset": "mobile",
  "url": "https://example.com/",
  "url_hash": "sha256(url)",

  "performance": 0.91,
  "accessibility": 0.97,
  "seo": 0.98,
  "best_practices": 0.92,

  "raw_json": { "runs": 3, "site_name": "example-com", "domain": "example.com" }
}

スコアは 0.0〜1.0 で、raw_jsonにメタ情報や平均値を入れる方針です。
このデータはGitHubActions内で作成します。

4. GitHubActionsの設定

次にGitHubActionsの設定をします。GitHubActionsでは全体の処理のコントロールを行います。
コードはこんな感じ。

name: Lighthouse Monthly Report CI to API

on:
  workflow_dispatch:
  # schedule:
  #   - cron: "0 18 1 * *" # UTC 18:00 = JST 03:00(毎月2日03:00相当)

jobs:
  prepare:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.read.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - id: read
        run: |
          echo "matrix=$(jq -c . ci/config/lighthouse_sites.json)" >> "$GITHUB_OUTPUT"

  run-lhci:
    needs: prepare
    runs-on: ubuntu-latest
    timeout-minutes: 45
    strategy:
      matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install Lighthouse CI
        run: npm i -g @lhci/cli@0.13.0

      - name: Run LHCI for each preset
        env:
          SITE_NAME: ${{ matrix.name }}
          URLS_JSON: ${{ toJSON(matrix.urls) }}
          PRESETS_JSON: ${{ toJSON(matrix.presets) }}
          RUNS: ${{ matrix.numberOfRuns }}

        run: |
          set -euo pipefail
          mkdir -p "lhci_reports/${SITE_NAME}"

          for PRESET in $(echo "$PRESETS_JSON" | jq -r '.[]'); do
            OUT="lhci_reports/${SITE_NAME}/${PRESET}"
            mkdir -p "$OUT"

            if [ "$PRESET" = "desktop" ]; then
              # デスクトップは preset=desktop を使う
              jq -n \
                --argjson urls "$URLS_JSON" \
                --argjson runs "$RUNS" \
                --arg outdir "$OUT" \
                '{
                   ci:{
                     collect:{
                       url:$urls,
                       numberOfRuns:$runs,
                       settings:{ preset:"desktop" }
                     },
                     assert:{
                       assertions:{
                         "categories:performance":["warn",{minScore:0.8}],
                         "categories:accessibility":["warn",{minScore:0.9}],
                         "categories:seo":["warn",{minScore:0.9}]
                       }
                     },
                     upload:{target:"filesystem",outputDir:$outdir}
                   }
                 }' > .lighthouserc.effective.json
            else
              # モバイル:presetは使わず、formFactor指定(または設定自体を省略でも可)
              jq -n \
                --argjson urls "$URLS_JSON" \
                --argjson runs "$RUNS" \
                --arg outdir "$OUT" \
                '{
                   ci:{
                     collect:{
                       url:$urls,
                       numberOfRuns:$runs,
                       settings:{
                         formFactor:"mobile",
                         screenEmulation:{mobile:true}
                       }
                     },
                     assert:{
                       assertions:{
                         "categories:performance":["warn",{minScore:0.8}],
                         "categories:accessibility":["warn",{minScore:0.9}],
                         "categories:seo":["warn",{minScore:0.9}]
                       }
                     },
                     upload:{target:"filesystem",outputDir:$outdir}
                   }
                 }' > .lighthouserc.effective.json
            fi

            lhci autorun --config=.lighthouserc.effective.json || true
          done


      # ここからPythonで集計→LaravelへPOST
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install Python deps
        run: |
          python -m pip install --upgrade pip
          pip install requests

      - name: Post Lighthouse summary via Python
        env:
          API_BASE: ${{ secrets.APP_API_BASE }}   # 例: https://example.com/api
          JOBS_TOKEN: ${{ secrets.JOBS_TOKEN }}   # トークン
          DOMAIN: ${{ matrix.domain }}
          SITE_NAME: ${{ matrix.name }}
          SITE_ID:    ${{ matrix.site_id }}
          MONTH: ${{ inputs.month || '' }}

        run: |
          python ci/scripts/lighthouse_report_to_api.py

      - name: Upload raw reports (artifact)
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: lighthouse-reports-${{ matrix.name }}-${{ github.run_number }}
          path: lhci_reports
          retention-days: 30
          if-no-files-found: warn

上記の大まかな流れは以下のとおりです。

  1. 計測したいサイト情報が載っているci/config/lighthouse_sites.json を読み、マトリクス化
  2. run-lhcilhci autorun を preset(desktop/mobile)ごとに回し、Pythonで集計
  3. 集計したデータをPythonでjsonに保存
  4. jsonをPythonで読み込んでLaravelにPOST

lighthouse_sites.jsonはこんな感じ。

{
  "include": [
    {
      "name": "hogehoge1",
      "domain": "hogehoge1.com",
      "site_id": 1,
      "urls": [
        "https://hogehoge1.com",
        "https://hogehoge1.com/page1",
        "https://hogehoge1.com/page2"
      ],
      "presets": ["desktop", "mobile"],
      "numberOfRuns": 3
    },
    {
      "name": "hogehoge2",
      "domain": "hogehoge2.com",
      "site_id": 2,
      "urls": [
        "https://hogehoge2.com",
        "https://hogehoge2.com/page1",
        "https://hogehoge2.com/page2"
      ],
      "presets": ["desktop", "mobile"],
      "numberOfRuns": 3
    }
  ]
}

2サイト、それぞれ3ページをPCとモバイルの両方計測する設定です。
ちなみに「numberOfRuns」はLighthouse CI で同じURLを何回測定するか の回数指定です。

5. GitHub Actionsの環境変数

このワークフローでは Secretsから受け取る値を環境変数としてPythonに渡しています。
値はリポジトリの「Settings > Secrets and variables > Actions」に登録しておきます。

  • APP_API_BASE:Laravel APIのベースURL。
    例: https://example.com/api
  • JOBS_TOKEN:Laravel側で検証するトークン。
    APIヘッダ X-Job-Token として送られます。

※ これらは公開リポジトリでも漏れないように必ずSecrets管理します。

6. Pythonで集計してPOST

上記のjsonデータをPythonで読み込んでLaravelのAPIにPOSTします。
GitHubActionsから直接POSTしてもいいのですが、複数runの平均化 や raw_jsonの整理など、ちょっと大変だったので、間にPythonを挟みました。
LHCIの出力は「lhci_reports/{SITE_NAME}/{PRESET}/*.report.json」に貯め、Python側でURL別に集約して送ります。

コードはこんな感じ。

#!/usr/bin/env python3
# LighthouseCIの *.lighthouse.json を集計してLaravel APIに送る

import os, sys, json, glob, hashlib, statistics, requests
from datetime import datetime

API_BASE   = os.getenv("API_BASE", "").rstrip("/")
JOBS_TOKEN = os.getenv("JOBS_TOKEN", "")
SITE_ID    = int(os.getenv("SITE_ID", "0"))
SITE_NAME  = os.getenv("SITE_NAME", "").strip()
DOMAIN     = os.getenv("DOMAIN", "").strip()
MONTH_STR  = os.getenv("MONTH", "").strip() or datetime.now().strftime("%Y-%m")
MONTH_YMD  = f"{MONTH_STR}-01"

def normalize_url(u: str) -> str:
    return u.strip().rstrip("/").lower()

def sha256(s: str) -> str:
    return hashlib.sha256(s.encode("utf-8")).hexdigest()

def avg(values):
    vals = [v for v in values if isinstance(v, (int, float))]
    return round(statistics.fmean(vals), 4) if vals else None

headers = {"X-Job-Token": JOBS_TOKEN, "Content-Type": "application/json"}

for preset in ("mobile","desktop"):
    files = glob.glob(f"lhci_reports/{SITE_NAME}/{preset}/*.report.json")
    if not files: continue

    per_url = {}
    for fpath in files:
        rep = json.load(open(fpath))
        url = normalize_url(rep.get("requestedUrl") or rep.get"(finalUrl"))
        cats = rep.get("categories",{})
        per_url.setdefault(url,{"performance":[],"accessibility":[],"seo":[],"best_practices":[]})
        for k in per_url[url].keys():
            v = cats.get(k.replace("_","-"),{}).get("score")
            if isinstance(v,(int,float)): per_url[url][k].append(v)

    for url, scores in per_url.items():
        payload = {
            "site_id": SITE_ID,
            "month": MONTH_YMD,
            "preset": preset,
            "url": url,
            "url_hash": sha256(url),
            "performance":    avg(scores["performance"]) or 0.0,
            "accessibility":  avg(scores["accessibility"]) or 0.0,
            "seo":            avg(scores["seo"]) or 0.0,
            "best_practices": avg(scores["best_practices"]) or 0.0,
            "raw_json": {
                "site_name": SITE_NAME, "domain": DOMAIN,
                "preset": preset, "month": MONTH_STR,
                "runs": len(scores["performance"]),
            }
        }
        r = requests.post(f"{API_BASE}/lighthouse", headers=headers, json=payload, timeout=60)
        print(r.status_code, url, preset)

とりあえずここまで。次回はPOSTしたデータを受けるLaravel側を書いてみたいと思います。

今回は以上です!