yellow_lamp.py

scripts/analytics/yellow_lamp.py(199 行)

yellow_lamp.py
"""下降トレンドの早期警告(黄色ランプ判定)。

蓄積した Search Console データから「検索での露出が縮み続けていないか」を機械的に判定する。
点灯は「すぐ何かをする」合図ではなく、「次の見直しの議題に上げる」ための目印として使う。

判定指標:
  (1) 表示回数の四半期(91日)合計の成長率が 2四半期連続でマイナス
  (2) 「四半期末の直近7日に検索表示があったページ数」が 前々→前→現 と連続で減少

仕様:
  - 四半期の区切りは DB 内の GSC データの最新日を基準にする(データ遅延を自動で吸収)
  - page_path は /index.html をディレクトリに統合して数える
  - データが3四半期分に満たない指標は「判定不能(データ蓄積中)」

使い方:
  (プロジェクトのルートで実行する)
  python -X utf8 scripts/analytics/yellow_lamp.py                    # config.yaml の analytics.database を読み、全サイト判定
  python -X utf8 scripts/analytics/yellow_lamp.py --site site_a      # 1サイトだけ
  python -X utf8 scripts/analytics/yellow_lamp.py --db data/analytics.db  # DBファイルを直接指定
  from yellow_lamp import connect, judge_site  # 他のスクリプトからの import も可
"""

from __future__ import annotations

import argparse
import os
import sqlite3
from datetime import date, timedelta
from pathlib import Path

QUARTER_DAYS = 91  # 四半期 = 91日ブロックで近似

# /index.html をディレクトリに統合する SQL 式
NORM = """CASE WHEN page_path='/index.html' THEN '/'
               WHEN page_path LIKE '%/index.html' THEN substr(page_path, 1, length(page_path)-11)
               ELSE page_path END"""


def connect(db_path: str) -> sqlite3.Connection:
    """DB を読み取り専用で開く。"""
    uri = Path(db_path).resolve().as_uri() + "?mode=ro"
    return sqlite3.connect(uri, uri=True)


def _quarter_ranges(anchor: date) -> list[tuple[str, str]]:
    """anchor(=GSC最新日)から遡る3つの四半期 [(start,end)] を新しい順に返す。"""
    ranges = []
    end = anchor
    for _ in range(3):
        start = end - timedelta(days=QUARTER_DAYS - 1)
        ranges.append((start.isoformat(), end.isoformat()))
        end = start - timedelta(days=1)
    return ranges  # [Q0(現), Q1(前), Q2(前々)]


def judge_site(site: str, conn: sqlite3.Connection) -> dict:
    """1サイトの判定。dict(lamp / 各指標の詳細)を返す。

    lamp: True=点灯 / False=消灯 / None=判定不能
    """
    q = conn.execute
    row = q(
        "SELECT MIN(date), MAX(date) FROM gsc_page_metrics WHERE site=?", (site,)
    ).fetchone()
    if not row or not row[1]:
        return {"site": site, "lamp": None, "reason": "GSCデータなし"}
    data_min, anchor = date.fromisoformat(row[0]), date.fromisoformat(row[1])
    quarters = _quarter_ranges(anchor)
    # 3四半期ぶんのデータが揃っているか(Q2の開始がデータ最古日以降か)
    enough = date.fromisoformat(quarters[2][0]) >= data_min

    # --- 指標(1): 四半期ごとの表示回数合計 → 成長率が2回連続マイナス ---
    imps = []
    for s, e in quarters:
        v = q(
            """SELECT COALESCE(SUM(impressions),0) FROM gsc_page_metrics
               WHERE site=? AND date BETWEEN ? AND ?""",
            (site, s, e),
        ).fetchone()[0]
        imps.append(int(v))

    def growth(cur: int, prev: int):
        if prev <= 0:
            return None  # 分母0は判定不能扱い
        return (cur - prev) / prev

    g0 = growth(imps[0], imps[1])  # 現 vs 前
    g1 = growth(imps[1], imps[2])  # 前 vs 前々
    if not enough or g0 is None or g1 is None:
        ind1 = {"lit": None, "note": "判定不能(データ蓄積中)"}
    else:
        ind1 = {"lit": g0 < 0 and g1 < 0}
    ind1.update({
        "impressions_q": imps,
        "growth_recent": round(g0, 4) if g0 is not None else None,
        "growth_prior": round(g1, 4) if g1 is not None else None,
    })

    # --- 指標(2): 各四半期末7日間の「表示があったページ数」→ 2段連続で減少 ---
    pages = []
    for _, e in quarters:
        e_d = date.fromisoformat(e)
        s7 = (e_d - timedelta(days=6)).isoformat()
        v = q(
            f"""SELECT COUNT(DISTINCT {NORM}) FROM gsc_page_metrics
                WHERE site=? AND date BETWEEN ? AND ? AND impressions > 0""",
            (site, s7, e),
        ).fetchone()[0]
        pages.append(int(v))
    # 最も古い7日窓がデータ最古日より前にはみ出す場合は判定不能
    oldest_window_start = date.fromisoformat(quarters[2][1]) - timedelta(days=6)
    enough2 = oldest_window_start >= data_min
    if not enough2:
        ind2 = {"lit": None, "note": "判定不能(データ蓄積中)"}
    else:
        ind2 = {"lit": pages[0] < pages[1] and pages[1] < pages[2]}
    ind2["active_pages_q"] = pages  # [現, 前, 前々]

    lit_flags = [v["lit"] for v in (ind1, ind2)]
    lamp = True if any(f is True for f in lit_flags) else (
        None if all(f is None for f in lit_flags) else False
    )
    return {
        "site": site,
        "anchor": anchor.isoformat(),
        "quarters": quarters,
        "indicator1": ind1,
        "indicator2": ind2,
        "lamp": lamp,
    }


def format_report(r: dict) -> str:
    """人間が読める形式の判定結果(レポートの冒頭にそのまま貼れる)。"""
    if r.get("lamp") is None and "reason" in r:
        return f"[{r['site']}] 黄色ランプ: 判定不能({r['reason']})"
    lines = []
    head = {True: "[警告] 黄色ランプ点灯", False: "[OK] 黄色ランプ消灯",
            None: "[保留] 判定不能(データ蓄積中)"}[r["lamp"]]
    lines.append(f"[{r['site']}] {head}(基準日: {r['anchor']})")
    i1, i2 = r["indicator1"], r["indicator2"]
    q_imp = i1["impressions_q"]
    g0, g1 = i1.get("growth_recent"), i1.get("growth_prior")
    pct = lambda v: f"{v*100:+.1f}%" if v is not None else "—"  # noqa: E731
    mark = lambda lit: {True: "警告", False: "OK", None: "保留"}[lit]  # noqa: E731
    lines.append(
        f"  指標(1) 表示回数の四半期成長: {mark(i1['lit'])} "
        f"(前々→前 {pct(g1)}, 前→現 {pct(g0)} / 四半期計 {q_imp[2]:,}→{q_imp[1]:,}→{q_imp[0]:,})"
    )
    p = i2["active_pages_q"]
    lines.append(
        f"  指標(2) 検索表示ページ数(各四半期末7日): {mark(i2['lit'])} "
        f"({p[2]}→{p[1]}→{p[0]} ページ)"
    )
    if r["lamp"]:
        lines.append("  → 点灯は「すぐ対応」の合図ではありません。次回の見直しの議題に上げてください。")
    return "\n".join(lines)


def _resolve_db_path(args) -> str:
    """--db があればそれを、無ければ設定ファイルの analytics.database を使う。"""
    if args.db:
        return args.db
    import common  # 設定ファイルを読むときだけ必要(PyYAML に依存)
    return common.load_config(args.config)["database"]


def main() -> int:
    parser = argparse.ArgumentParser(description="下降トレンドの早期警告(黄色ランプ判定)")
    parser.add_argument("--site", default=None, help="サイト指定(省略時は DB 内の全サイト)")
    parser.add_argument("--db", default=None, help="SQLite ファイルのパス(省略時は設定ファイルの analytics.database)")
    parser.add_argument("--config", default=None, help="設定ファイルのパス(既定: プロジェクトのルートの config.yaml。読むのは analytics: 節)")
    args = parser.parse_args()

    db_path = _resolve_db_path(args)
    if not os.path.exists(db_path):
        print(f"[ERROR] DBファイルがありません: {db_path}")
        return 2

    conn = connect(db_path)
    try:
        if args.site:
            sites = [args.site]
        else:
            sites = [r[0] for r in conn.execute(
                "SELECT DISTINCT site FROM gsc_page_metrics ORDER BY site")]
        if not sites:
            print("黄色ランプ: 判定不能(データ不足: GSCデータがまだ1件もありません)")
            return 0
        for s in sites:
            print(format_report(judge_site(s, conn)))
            print()
    finally:
        conn.close()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

MIT ライセンスで配布しています。