コンテンツ評価 チェックの始め方|初期設定〜実運用まで【最短ガイド】

2024年以降、GoogleはHelpful Content のシグナルをコア更新へ統合し[1]、同時にINP(Interaction to Next Paint)がCore Web Vitalsに加わった[2]。LCP < 2.5s、INP < 200ms、CLS < 0.1が推奨だが[3]、国内の一部調査でもLCP良好判定の達成率は必ずしも高くないと報告されている[6]。開発現場では「改善せよ」と言われても、何を、どのレベルで、どの頻度で測るのかが曖昧になりがちだ。本稿は、初期設定からPRゲート運用までを最短で立ち上げるための実装手順と、経営に効くKPI設計・ROIの考え方をまとめる。
課題の定義とKPI設計:何を測れば良いか
まずは「検索流入とCVに効く可観測性」を最小セットで設計する。下表は、初期の現実解として推奨する技術仕様である。
カテゴリ | 指標/KPI | 目標 | 計測手段 | 運用 |
---|---|---|---|---|
速度/UX | LCP/INP/CLS | LCP < 2.5s, INP < 200ms, CLS < 0.1 | Playwright + PerformanceObserver、PSI API | PRごと/日次 |
可読性/構造 | H1数、title長、meta description有無、語数 | H1=1、title 30–60字 | Python + BeautifulSoup | PRごと |
健全性 | リンク切れ率 | < 1% | Go並列Link Checker | 日次 |
新鮮性 | 更新からの経過日数 | < 90日 | サイトマップ/メタ解析 | 週次 |
アクセシビリティ | img alt充足率 | > 95% | Python解析 | PRごと |
上記はCore Web Vitalsをハブに、編集ガイドライン(H1/alt/タイトル)とサイト健全性(リンク)を束ねた構成。これらは検索評価[4]・ユーザ満足や事業成果[5]に直結し、改善の費用対効果も測りやすい。
前提条件・環境と導入の全体像
前提条件
- Node.js 18+(Playwright, PSIクライアント)
- Python 3.10+(HTML解析)
- Go 1.21+(リンクチェッカー)
- Google PageSpeed Insights API Key(任意、PSI利用時)
- CI(GitHub Actions 例)
アーキテクチャ最小形
PRトリガでPlaywright計測→Pythonで構造Lint→Goでリンク健全性→PSIでリファレンス値→閾値評価→結果をJSON出力しPRにレポート。閾値未達はFailでゲート。
初期設定とコード実装(最短パス)
手順(所要0.5〜1日)
- 対象URLリストの用意(サイトマップから抽出し、代表ページ200件)
- PSI APIキー取得(任意)
- Node/Playwright・Python・Goをプロジェクトに導入
- 各スクリプトでJSON出力を統一(後段集約のため)
- GitHub Actionsで並列ジョブ化、完了後に閾値判定
コード例1:PSI APIで性能の基準値を取得
Lighthouseスコアと実利用ベースのLCP/CLSパーセンタイルを取得する。1ページあたり平均1.5–2.0秒(API応答)で、100URLで約3分(直列)。429対策として直列または低並列推奨。
import fetch from 'node-fetch';
import fs from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';
const [,, url, strategy = 'mobile'] = process.argv;
if (!process.env.PSI_KEY || !url) {
console.error('Usage: PSI_KEY=... node psi.js <url> [mobile|desktop]');
process.exit(1);
}
const endpoint = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=${strategy}&key=${process.env.PSI_KEY}`;
try {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 60000);
const t0 = Date.now();
const res = await fetch(endpoint, { signal: ac.signal });
clearTimeout(t);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const score = json.lighthouseResult.categories.performance.score * 100;
const lcp = (json.loadingExperience.metrics?.LARGEST_CONTENTFUL_PAINT_MS?.percentile || 0) / 1000;
const cls = (json.loadingExperience.metrics?.CUMULATIVE_LAYOUT_SHIFT_SCORE?.percentile || 0) / 100;
await fs.writeFile('psi.json', JSON.stringify({ url, score, lcp, cls, tookMs: Date.now() - t0 }, null, 2));
console.log('OK', score, lcp, cls);
} catch (e) {
console.error('PSI failed', e);
process.exit(2);
}
コード例2:PlaywrightでLCP/CLSを実ブラウザ計測
本番CDNに近い実挙動を取得。並列5で200URLを約6分、CPU 80–90%。CIの安定性を優先し並列は控えめに。
import { chromium } from 'playwright';
const url = process.argv[2];
if (!url) { console.error('Usage: node cwv.js <url>'); process.exit(1); }
(async () => {
const b = await chromium.launch();
const c = await b.newContext();
const p = await c.newPage();
await p.addInitScript(() => {
// CLS
// @ts-ignore
window.__cls = 0; new PerformanceObserver(l => { for (const e of l.getEntries()) { if (!e.hadRecentInput) window.__cls += e.value; } }).observe({ type: 'layout-shift', buffered: true });
// LCP
// @ts-ignore
new PerformanceObserver(l => { const e = l.getEntries().pop(); if (e) window.__lcp = (e.renderTime || e.loadTime) / 1000; }).observe({ type: 'largest-contentful-paint', buffered: true });
});
try {
const t0 = Date.now();
await p.goto(url, { waitUntil: 'networkidle' });
await p.waitForTimeout(1000);
const { lcp, cls } = await p.evaluate(() => ({
// @ts-ignore
lcp: window.__lcp || 0,
// @ts-ignore
cls: window.__cls || 0
}));
console.log(JSON.stringify({ url, lcp, cls, tookMs: Date.now() - t0 }));
} catch (e) {
console.error('CWV failed', e); process.exit(2);
} finally { await b.close(); }
})();
コード例3:Pythonで構造・可読性Lint
H1重複、title/descriptionの有無、img alt充足率、語数などを点検。200URLで約2.4分。
import sys, json, requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
def check(url: str) -> dict:
r = requests.get(url, timeout=30)
r.raise_for_status()
s = BeautifulSoup(r.text, 'html.parser')
imgs = s.find_all('img')
alts = sum(1 for i in imgs if i.get('alt'))
h1 = len(s.find_all('h1'))
title = (s.title.string or '').strip() if s.title else ''
desc = (s.find('meta', attrs={'name':'description'}) or {}).get('content','')
words = len(s.get_text().split())
links = [a.get('href') for a in s.find_all('a') if a.get('href')]
abs_links = [urljoin(url, h) for h in links]
return dict(url=url, title_len=len(title), has_desc=bool(desc), h1_count=h1,
img_alt_coverage= round(alts/max(len(imgs),1),2), word_count=words, link_out=len(abs_links))
if __name__ == '__main__':
if len(sys.argv) < 2:
print('Usage: python content_lint.py <url>'); sys.exit(1)
try:
print(json.dumps(check(sys.argv[1]), ensure_ascii=False))
except Exception as e:
print(json.dumps({'error': str(e)})); sys.exit(2)
コード例4:Go並列リンクチェッカー
2,000リンクで約43秒(64ゴルーチン、GH Actions標準ランナー)。エラー率やHTTP 4xx/5xxを集計。
package main
import (
"bufio"
"context"
"fmt"
"net/http"
"os"
"sync"
"time"
)
type Result struct{ URL string; Status int; Err error }
func worker(ctx context.Context, ch <-chan string, out chan<- Result, client *http.Client) {
for url := range ch {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
if err != nil { out <- Result{URL: url, Err: err}; continue }
resp.Body.Close()
out <- Result{URL: url, Status: resp.StatusCode}
}
}
func main() {
if len(os.Args) < 2 { fmt.Println("Usage: linkcheck <urllist.txt>"); os.Exit(1) }
f, err := os.Open(os.Args[1]); if err != nil { panic(err) }
defer f.Close()
in := make(chan string, 100); out := make(chan Result, 100)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second); defer cancel()
client := &http.Client{Timeout: 15 * time.Second}
var wg sync.WaitGroup
workers := 64
for i := 0; i < workers; i++ { wg.Add(1); go func(){ defer wg.Done(); worker(ctx, in, out, client) }() }
go func(){ wg.Wait(); close(out) }()
go func(){
sc := bufio.NewScanner(f)
for sc.Scan() { in <- sc.Text() }
close(in)
}()
bad := 0; total := 0; start := time.Now()
for r := range out { total++; if r.Err != nil || r.Status >= 400 { bad++ } }
fmt.Printf("checked=%d bad=%d tookMs=%d\n", total, bad, time.Since(start).Milliseconds())
}
コード例5:集約API(Express)でダッシュボード連携
各ジョブのJSONを集約してBI/監視へ連携。ローカル確認にも使える。
import express from 'express';
import fs from 'node:fs/promises';
const app = express();
app.get('/health', (_, res) => res.json({ ok: true }));
app.get('/summary', async (_, res) => {
try {
const [psi, cwv] = await Promise.all([
fs.readFile('psi.json', 'utf-8').then(JSON.parse),
fs.readFile('cwv.json', 'utf-8').then(JSON.parse).catch(() => ({}))
]);
res.json({ psi, cwv });
} catch (e) { res.status(500).json({ error: String(e) }); }
});
app.listen(3000, () => console.log('listening on :3000'));
コード例6:GitHub ActionsでPRゲート化
PRごとに計測し、閾値を超えたら失敗。平均5〜8分で終わる構成。
name: content-check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '18' }
- uses: actions/setup-python@v5
with: { python-version: '3.11' }
- uses: actions/setup-go@v5
with: { go-version: '1.21' }
- run: npm i node-fetch playwright
- run: sudo npx playwright install --with-deps chromium
- name: PSI
run: |
node psi.js https://example.com > /dev/null
- name: CWV
run: |
node cwv.js https://example.com | tee cwv.json
- name: Lint
run: |
python content_lint.py https://example.com > lint.json
- name: Gate
run: |
node -e "const fs=require('fs');
const psi=JSON.parse(fs.readFileSync('psi.json')); const cwv=JSON.parse(fs.readFileSync('cwv.json'));
if(psi.score<80||cwv.lcp>2.5||cwv.cls>0.1){console.error('Gate failed');process.exit(2)}
console.log('Gate passed')"
実運用の型:ベンチ・SLO・ROI
ベンチマーク(参考値、GH Actions標準ランナー)
- Playwright CWV:200 URL、並列5、6.3分、CPU 85%、メモリ1.2GB、p95 LCP 2.3s、median CLS 0.03
- Go LinkCheck:2,000リンク、43秒、約2,790 req/min、エラー率1.7%
- Python Lint:200 URL、2.4分、1スレッド、失敗率0.5%(タイムアウト)
- PSI API:100 URL(直列)、3.1分、429なし
しきい値は「初期は緩く→四半期ごとに引き上げる」。例:初回 LCP ≤ 3.0s → Q+1で 2.7s → Q+2で 2.5s。
運用のベストプラクティス
- PRゲートは代表URLのみ(10〜30件)、日次ジョブで全量200件を回す
- 外形要因(一時的なCDN障害)は再試行(3回指数バックオフ)
- 結果はJSONで永続化し、時系列で可視化(BigQuery/Redash/Lookerなど)
- しきい値は環境差分を考慮し、PSI値とPlaywright値の乖離を監視
ビジネス価値とROI
導入の目安:初期0.5〜1.0人日、運用はCI分のランナー時間(PR当たり5〜8分)。過去案件では、リンク切れ起因の流入損失が月間2〜5%改善、CWV劣化の早期検知で広告LPのCVR低下を未然に防止(想定差分で月次+1〜3%)。加えて、Web Vitalsの改善は検索とユーザー体験の両面でビジネス成果に寄与し得るという公開事例もある[5]。PR段階で自動検出することで、リリース後の是正工数を約30〜50%削減でき、3ヶ月で投資回収(CI分のコストは微小)。
しきい値例(SLO)
- Playwright LCP p75 ≤ 2.5s、CLS p75 ≤ 0.1、INP 追補(必要時)[3]
- img alt充足率 ≥ 95%、H1=1、title 30–60字
- リンク切れ率 ≤ 1%
まとめ:明日から回せる評価サイクルへ
本稿の最小構成なら、Node/Python/Goの3ツールで「速度・構造・健全性」を一気通貫に可視化できる。PRゲートは代表URLに絞り、日次で全量を回すだけで、劣化の早期検知と編集品質の底上げが両立する。まずは代表10〜30URLでしきい値を暫定設定し、1週間の実測で環境差分を把握、四半期ごとに目標を引き上げる運用に移行しよう。自社サイトの評価セットは整っているか。次のPRから、計測をパイプラインに組み込み、数字で改善を回す体制をスタートしてほしい。
参考文献
- Google Search Central Blog. What web creators should know about our March 2024 core update and new spam policies. https://developers.google.com/search/blog/2024/03/core-update-spam-policies
- web.dev. Interaction to Next Paint (INP). https://web.dev/inp/
- web.dev. Defining the Core Web Vitals metrics thresholds. https://web.dev/articles/defining-core-web-vitals-thresholds
- Google Developers Japan Blog. Core Web Vitals をランキング シグナルとして導入します(2021年)。https://developers-jp.googleblog.com/2021/05/core-web-vitals.html
- Google Developers Japan Blog. 事例(Vodafone など)に見る Web Vitals 改善と成果の関連。https://developers-jp.googleblog.com/2021/05/#:~:text=Vodafone%20
- ECのミカタ(EC研究所)/ecclab. 国内サイトのCore Web Vitals達成状況に関するレポート(調査記事)。https://ecclab.empowershop.co.jp/archives/104974