common.py

scripts/analytics/common.py(409 行)

common.py
"""共通処理: 設定読込・認証・ログ・URL正規化・リトライ・DB初期化/upsert。

GA4(fetch_ga4.py) と Search Console(fetch_gsc.py / fetch_url_inspection.py) の
両方から使う。
"""

from __future__ import annotations

import logging
import os
import sqlite3
import time
from datetime import date, timedelta
from typing import Any, Callable, Iterable, Sequence
from urllib.parse import urlsplit

import yaml

# --------------------------------------------------------------------------- #
# 設定
# --------------------------------------------------------------------------- #

# このファイルのあるディレクトリ(scripts/analytics/)。db_schema.sql の置き場所。
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# プロジェクトのルート(scripts/analytics/ の2つ上)。config.yaml の既定の置き場所で、
# config 内の相対パスもここを基準に解決する。
PROJECT_ROOT = os.path.dirname(os.path.dirname(BASE_DIR))

# GA4 + Search Console 両方の読み取り専用スコープ(OAuth / サービスアカウント共通)
SCOPES = [
    "https://www.googleapis.com/auth/analytics.readonly",
    "https://www.googleapis.com/auth/webmasters.readonly",
]


def _resolve(path: str) -> str:
    """config 内の相対パスを、プロジェクトのルート基準の絶対パスに変換する。"""
    if os.path.isabs(path):
        return path
    return os.path.normpath(os.path.join(PROJECT_ROOT, path))


def load_config(config_path: str | None = None) -> dict[str, Any]:
    """config.yaml の analytics: 節を読み込み、パス系のキーを絶対パスに解決して返す。

    設定ファイルはプロジェクト全体で1つ(config.yaml)。そのうちアクセス解析が使うのは
    analytics: 節だけなので、その中身を返す(呼び出し側は従来どおり cfg["database"] 等で読める)。
    """
    config_path = config_path or os.path.join(PROJECT_ROOT, "config.yaml")
    if not os.path.exists(config_path):
        raise FileNotFoundError(
            f"設定ファイルがありません: {config_path}\n"
            "→ プロジェクトのルートで config.example.yaml を config.yaml にコピーして値を埋めてください。"
        )
    with open(config_path, "r", encoding="utf-8") as f:
        whole = yaml.safe_load(f) or {}
    cfg = whole.get("analytics")
    if not isinstance(cfg, dict):
        raise ValueError(
            f"設定ファイルに analytics: 節がありません: {config_path}\n"
            "→ config.example.yaml の analytics: 節を config.yaml に写して値を埋めてください。"
        )

    path_keys = (
        "service_account_file", "oauth_client_file", "oauth_token_file",
        "database", "csv_export_dir", "log_file",
    )
    for key in path_keys:
        if key in cfg and cfg[key]:
            cfg[key] = _resolve(cfg[key])
    register_path_aliases(cfg)
    return cfg


# --------------------------------------------------------------------------- #
# 認証(OAuthユーザー認証 / サービスアカウント)
# --------------------------------------------------------------------------- #

def load_credentials(cfg: dict[str, Any], logger: logging.Logger | None = None):
    """auth_mode に応じて Google API のクレデンシャルを返す(GA4 / SC 共用)。

    oauth: auth_setup.py が生成したトークンファイルを読み、失効していれば自動更新する。
    service_account: サービスアカウントの鍵ファイルを読む。
    失敗時は分かりやすいメッセージで RuntimeError を送出する。
    """
    mode = (cfg.get("auth_mode") or "service_account").lower()

    if mode == "oauth":
        from google.auth.transport.requests import Request
        from google.oauth2.credentials import Credentials as UserCredentials

        token_file = cfg.get("oauth_token_file")
        if not token_file or not os.path.exists(token_file):
            raise RuntimeError(
                f"OAuthトークンがありません: {token_file}\n"
                "→ 先に `python -X utf8 scripts/analytics/auth_setup.py` を実行してブラウザ認証してください。"
            )
        creds = UserCredentials.from_authorized_user_file(token_file, SCOPES)
        if not creds.valid:
            if creds.expired and creds.refresh_token:
                if logger:
                    logger.info("OAuthトークンを自動リフレッシュします。")
                creds.refresh(Request())
                with open(token_file, "w", encoding="utf-8") as f:
                    f.write(creds.to_json())
            else:
                raise RuntimeError(
                    "OAuthトークンが無効/失効しています。"
                    "`python -X utf8 scripts/analytics/auth_setup.py --force` で再認証してください。"
                )
        return creds

    # --- service_account ---
    from google.oauth2 import service_account
    sa_file = cfg.get("service_account_file")
    if not sa_file or not os.path.exists(sa_file):
        raise RuntimeError(f"サービスアカウントの鍵ファイルがありません: {sa_file}")
    return service_account.Credentials.from_service_account_file(sa_file, scopes=SCOPES)


# --------------------------------------------------------------------------- #
# ログ
# --------------------------------------------------------------------------- #

def setup_logging(log_file: str) -> logging.Logger:
    """ファイル + 標準出力の両方に出すロガーを構成する。

    ログは月次ローテーション(batch.log -> batch_YYYY-MM.log)。
    月が替わると新ファイルに切り替わり、古いファイルは削除せず保持する。
    """
    base, ext = os.path.splitext(log_file)
    log_file = f"{base}_{date.today().strftime('%Y-%m')}{ext}"
    os.makedirs(os.path.dirname(log_file), exist_ok=True)
    logger = logging.getLogger("analytics_pipeline")
    logger.setLevel(logging.INFO)
    logger.handlers.clear()  # 二重ハンドラ防止(再実行・import時)

    fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")

    fh = logging.FileHandler(log_file, encoding="utf-8")
    fh.setFormatter(fmt)
    logger.addHandler(fh)

    sh = logging.StreamHandler()
    sh.setFormatter(fmt)
    logger.addHandler(sh)
    return logger


# --------------------------------------------------------------------------- #
# 日付ヘルパ(データ鮮度ルール)
# --------------------------------------------------------------------------- #

def ymd(d: date) -> str:
    return d.strftime("%Y-%m-%d")


def ga4_date_range(reprocess_days: int, today: date | None = None) -> tuple[str, str]:
    """GA4 日次系の取得範囲。直近 reprocess_days 日を取り直す。

    終端は昨日(today-1)。GA4 は直近のデータが後から再処理されて確定するため、
    毎回さかのぼって取り直す。
    """
    today = today or date.today()
    end = today - timedelta(days=1)                  # 昨日
    start = today - timedelta(days=reprocess_days)   # reprocess_days 日前
    return ymd(start), ymd(end)


def gsc_date_range(
    lag_days: int, rolling_days: int, today: date | None = None
) -> tuple[str, str]:
    """Search Console の取得範囲。終端 today-lag_days、そこから rolling_days 日ぶん遡る。"""
    today = today or date.today()
    end = today - timedelta(days=lag_days)               # データ遅延ぶん手前を終端にする
    start = end - timedelta(days=rolling_days - 1)       # rolling_days 日のウィンドウ
    return ymd(start), ymd(end)


# --------------------------------------------------------------------------- #
# URL正規化(GA4 と Search Console の URL 表記差を吸収)
# --------------------------------------------------------------------------- #

# サイト別の名寄せ規則(domain -> 規則 dict)。
# load_config() が config.yaml の analytics.sites から登録する。normalize_path() は domain を
# 受け取ってこの登録簿を引くので、取得側のコードはサイトごとの規則を意識しなくてよい。
# 未登録のドメインは規則なし(共通の正規化だけが掛かる)。
_PATH_ALIASES: dict[str, dict[str, Any]] = {}


def register_path_aliases(cfg: dict[str, Any]) -> None:
    """config の sites から名寄せ規則を登録する(load_config から呼ばれる)。"""
    _PATH_ALIASES.clear()
    for site_cfg in cfg.get("sites", []) or []:
        domain = site_cfg.get("domain")
        if not domain:
            continue
        rule = {
            "strip_html": bool(site_cfg.get("strip_html_suffix")),
            "strip_index": bool(site_cfg.get("strip_index_html")),
            "strip_prefix": site_cfg.get("strip_path_prefix") or None,
        }
        if rule["strip_html"] or rule["strip_index"] or rule["strip_prefix"]:
            _PATH_ALIASES[domain] = rule


def normalize_path(url_or_path: str, domain: str | None = None) -> str:
    """ドメイン無しの正規化パスに統一する。

    - (1) ドメイン除去(フルURLでもパスのみでもOK)
    - (2) クエリ文字列除去
    - (3) フラグメント除去
    - (4) サイト別の名寄せ(strip_html_suffix / strip_index_html / strip_path_prefix)
    - (5) 末尾スラッシュ統一(ルート '/' 以外は末尾スラッシュを削る)

    GA4 の pagePath(/blog/xxx)と Search Console の page(https://example.com/blog/xxx)
    を同じキー '/blog/xxx' に揃えて、テーブル間で JOIN できるようにする。

    (4) は「サーバ側の書き換えで同じページになる別表記」を1キーに畳むためのもの。
    サイトの URL 設計に合うものだけを config で有効にする:

    - strip_html_suffix: 末尾の `.html` を落とす(/about.html -> /about、
      /index.html -> /、/foo/index.html -> /foo)。
      拡張子なしURLと .html 付きURLが同じページを指すサイト向け。
      `.html` 付きが正規URLのサイトでは有効にしないこと(別ページが混ざる)。
    - strip_index_html: パスが `/index.html` で終わるときだけ親に丸める
      (/index.html -> /、/foo/index.html -> /foo)。それ以外の `.html` には触らない。
      ディレクトリ index だけが二重表記になるサイト向け。
    - strip_path_prefix: 指定プレフィックス配下のパスからプレフィックスを剥がす
      ("/articles" なら /articles/foo -> /foo)。プレフィックスそのもの(/articles)は
      一覧ページとみなして畳まない。
    """
    if url_or_path is None:
        return "/"
    s = url_or_path.strip()
    if not s:
        return "/"

    parts = urlsplit(s)
    path = parts.path if (parts.scheme or parts.netloc) else s

    # スキーム無しの入力にクエリ/フラグメントが残っている場合の保険
    if "?" in path:
        path = path.split("?", 1)[0]
    if "#" in path:
        path = path.split("#", 1)[0]

    if not path.startswith("/"):
        path = "/" + path

    # (4) サイト別の名寄せ(登録があるドメインのみ)
    rule = _PATH_ALIASES.get(domain) if domain else None
    if rule:
        if rule["strip_html"] and path.lower().endswith(".html"):
            path = path[:-5]
            # ディレクトリ index: /index -> / 、/foo/index -> /foo
            if path == "/index":
                path = "/"
            elif path.endswith("/index"):
                path = path[:-6] or "/"
        elif rule.get("strip_index") and path.lower().endswith("/index.html"):
            # /index.html -> / 、/foo/index.html -> /foo (それ以外の .html は触らない)
            path = path[: -len("/index.html")] or "/"
        prefix = rule["strip_prefix"]
        if prefix:
            prefix = "/" + prefix.strip("/")
            # プレフィックスそのものは畳まない。配下だけ剥がす。
            if path.startswith(prefix + "/"):
                path = path[len(prefix):] or "/"

    # (5) 末尾スラッシュ統一(ルート以外)
    if len(path) > 1:
        path = path.rstrip("/")
        if not path:
            path = "/"
    return path


# --------------------------------------------------------------------------- #
# リトライ(指数バックオフ・最大N回)
# --------------------------------------------------------------------------- #

def with_retry(
    func: Callable[..., Any],
    *args: Any,
    max_retries: int = 3,
    base_delay: float = 2.0,
    logger: logging.Logger | None = None,
    give_up_if: Callable[[Exception], bool] | None = None,
    **kwargs: Any,
) -> Any:
    """func を指数バックオフでリトライ実行する。最後まで失敗したら例外を再送出。

    give_up_if を渡すと、その述語が True を返す例外はリトライせず即座に再送出する
    (設定不備のように、何度投げても直らないエラーで待ち時間を浪費しないため)。
    """
    attempt = 0
    while True:
        try:
            return func(*args, **kwargs)
        except Exception as exc:  # noqa: BLE001 - API例外は種類が多いので包括
            if give_up_if is not None and give_up_if(exc):
                raise
            attempt += 1
            if attempt > max_retries:
                if logger:
                    logger.error("リトライ上限(%d)に到達: %s", max_retries, exc)
                raise
            delay = base_delay * (2 ** (attempt - 1))
            if logger:
                logger.warning(
                    "API失敗 (試行%d/%d) %ss後に再試行: %s",
                    attempt, max_retries, delay, exc,
                )
            time.sleep(delay)


# --------------------------------------------------------------------------- #
# DB
# --------------------------------------------------------------------------- #

def init_db(db_path: str, schema_path: str | None = None) -> None:
    """db_schema.sql を流してテーブルを作成する(IF NOT EXISTS なので冪等)。"""
    schema_path = schema_path or os.path.join(BASE_DIR, "db_schema.sql")
    with open(schema_path, "r", encoding="utf-8") as f:
        ddl = f.read()
    db_dir = os.path.dirname(db_path)
    if db_dir:
        os.makedirs(db_dir, exist_ok=True)  # data/ などのフォルダが無ければ作る
    conn = sqlite3.connect(db_path)
    try:
        conn.executescript(ddl)
        conn.commit()
    finally:
        conn.close()


def connect(db_path: str) -> sqlite3.Connection:
    return sqlite3.connect(db_path)


def upsert(
    conn: sqlite3.Connection,
    table: str,
    columns: Sequence[str],
    rows: Iterable[Sequence[Any]],
) -> int:
    """INSERT OR REPLACE で行を upsert する。書き込んだ行数を返す。"""
    rows = list(rows)
    if not rows:
        return 0
    placeholders = ",".join("?" * len(columns))
    col_list = ",".join(columns)
    sql = f"INSERT OR REPLACE INTO {table} ({col_list}) VALUES ({placeholders})"
    conn.executemany(sql, rows)
    return len(rows)


def delete_date_range(
    conn: sqlite3.Connection, table: str, site: str, start: str, end: str
) -> int:
    """再取得前に [site, date∈[start,end]] の既存行を削除する。

    upsert(INSERT OR REPLACE) だけでは「API が返さなくなった行」が消えない
    (例: GA4 が後から流入元を解決して (not set) 行を返さなくなると、古い行が残る)。
    再取得対象期間を delete→insert にすることで、消えた行も正しく取り除く。
    """
    cur = conn.execute(
        f"DELETE FROM {table} WHERE site=? AND date BETWEEN ? AND ?", (site, start, end)
    )
    return cur.rowcount


def record_batch_run(
    conn: sqlite3.Connection,
    site: str,
    run_date: str,
    source: str,
    status: str,
    rows_written: int,
    message: str,
    finished_at: str,
) -> None:
    """batch_runs に実行結果を記録(upsert)。"""
    upsert(
        conn,
        "batch_runs",
        ["site", "run_date", "source", "status", "rows_written", "message", "finished_at"],
        [[site, run_date, source, status, rows_written, message, finished_at]],
    )
    conn.commit()


# --------------------------------------------------------------------------- #
# 値変換ヘルパ(API が返す文字列値を安全に数値化)
# --------------------------------------------------------------------------- #

def to_int(v: Any, default: int = 0) -> int:
    try:
        return int(float(v))
    except (TypeError, ValueError):
        return default


def to_float(v: Any, default: float = 0.0) -> float:
    try:
        return float(v)
    except (TypeError, ValueError):
        return default

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