main.py

scripts/analytics/main.py(219 行)

main.py
"""エントリ: config読込 → DB初期化 → 全サイトループ(GA4/SC取得→upsert) → CSV出力 → ログ。

毎日1回、無人で実行する取得バッチ。分析は蓄積された SQLite を別途読んで行う。

使い方:
    (プロジェクトのルートで実行する)
    python -X utf8 scripts/analytics/main.py                 # config.yaml の全サイトを取得
    python -X utf8 scripts/analytics/main.py --config x.yaml # 別の設定ファイルを指定
    python -X utf8 scripts/analytics/main.py --no-csv        # CSVエクスポートをスキップ

設計方針:
- 部分失敗を許容: 1サイト/1ソースの失敗が全体を止めない。失敗は batch_runs に記録。
- 冪等: 日次系・GSC は取得窓を delete→insert、他は INSERT OR REPLACE。
  同じ日に2回流しても行数は増えない。
"""

from __future__ import annotations

import argparse
import csv
import os
import sqlite3
from datetime import date, datetime

import common
import fetch_ga4
import fetch_gsc
import fetch_url_inspection

# CSVエクスポート対象テーブル
EXPORT_TABLES = [
    "page_metrics", "channel_metrics", "landing_metrics", "user_type_metrics",
    "device_geo_metrics", "hourly_metrics", "event_metrics",
    "affiliate_click_metrics",
    "gsc_query_metrics", "gsc_page_metrics", "gsc_page_query_metrics",
    "inspect_metrics", "batch_runs",
]

# ga4_property_id が未設定とみなす値(空 / サンプル設定のプレースホルダ)
UNSET_PROPERTY_IDS = ("", "PROPERTYID", "000000000")


def _now() -> str:
    return datetime.now().isoformat(timespec="seconds")


def export_csv(db_path: str, export_dir: str, run_date: str, logger) -> None:
    """各テーブルを <export_dir>/<run_date>/<table>.csv にダンプ(確認/バックアップ用)。"""
    out_dir = os.path.join(export_dir, run_date)
    os.makedirs(out_dir, exist_ok=True)
    conn = sqlite3.connect(db_path)
    try:
        for table in EXPORT_TABLES:
            cur = conn.execute(f"SELECT * FROM {table}")
            cols = [d[0] for d in cur.description]
            path = os.path.join(out_dir, f"{table}.csv")
            with open(path, "w", newline="", encoding="utf-8-sig") as f:
                w = csv.writer(f)
                w.writerow(cols)
                w.writerows(cur.fetchall())
        logger.info("CSVエクスポート完了: %s", out_dir)
    finally:
        conn.close()


def run_source(conn, source, fetch_fn, client, site_cfg, cfg, today, run_date, logger):
    """1サイト×1ソースを実行し、結果を batch_runs に記録。失敗しても例外を飲み込む。"""
    site = site_cfg["site"]
    try:
        rows = fetch_fn(conn, client, site_cfg, cfg, today, logger)
        common.record_batch_run(
            conn, site, run_date, source, "success", rows,
            "ok", _now(),
        )
        return rows
    except Exception as exc:  # noqa: BLE001
        logger.exception("[%s][%s] 取得失敗: %s", site, source, exc)
        # 未コミットの delete/insert を破棄してから失敗を記録する。
        # record_batch_run は commit するため、rollback しないと取得処理の途中で
        # 行った削除だけが確定して既存データが消えてしまう。
        conn.rollback()
        try:
            common.record_batch_run(
                conn, site, run_date, source, "failed", 0,
                str(exc)[:500], _now(),
            )
        except Exception:  # noqa: BLE001
            logger.error("[%s][%s] batch_runs記録も失敗", site, source)
        return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="アクセス解析バッチ (GA4 + Search Console → SQLite)")
    parser.add_argument("--config", default=None, help="設定ファイルのパス(既定: プロジェクトのルートの config.yaml。読むのは analytics: 節)")
    parser.add_argument("--no-csv", action="store_true", help="CSVエクスポートをスキップ")
    parser.add_argument("--inspect-now", action="store_true",
                        help="URL Inspectionを曜日に関わらず今すぐ実行")
    parser.add_argument("--inspect-limit", type=int, default=None,
                        help="URL Inspectionの検査URL数上限(テスト用)")
    args = parser.parse_args()

    cfg = common.load_config(args.config)
    if args.inspect_limit is not None:
        cfg["inspect_max_urls"] = args.inspect_limit
    logger = common.setup_logging(cfg["log_file"])

    today = date.today()
    run_date = common.ymd(today)
    logger.info("=" * 60)
    logger.info("バッチ開始: run_date=%s", run_date)

    # 認証クレデンシャルを取得(auth_mode: oauth / service_account)
    logger.info("認証方式: %s", cfg.get("auth_mode", "service_account"))
    try:
        credentials = common.load_credentials(cfg, logger)
    except Exception as exc:  # noqa: BLE001
        logger.error("認証に失敗しました: %s", exc)
        return 2

    # DB初期化(IF NOT EXISTS で冪等)
    common.init_db(cfg["database"])
    conn = common.connect(cfg["database"])

    # クライアントは1回だけ構築(サイト間で再利用)
    ga4_client = None
    gsc_service = None
    try:
        ga4_client = fetch_ga4.build_client(credentials)
    except Exception as exc:  # noqa: BLE001
        logger.exception("GA4クライアント構築失敗: %s", exc)
    try:
        gsc_service = fetch_gsc.build_service(credentials)
    except Exception as exc:  # noqa: BLE001
        logger.exception("SCサービス構築失敗: %s", exc)

    total_rows = 0
    failures = 0
    for site_cfg in cfg.get("sites", []) or []:
        site = site_cfg["site"]
        logger.info("-" * 40)
        logger.info("サイト処理: %s", site)

        # GA4 property_id 未設定の保険
        if str(site_cfg.get("ga4_property_id") or "").upper() in UNSET_PROPERTY_IDS:
            logger.warning("[%s] ga4_property_id が未設定です。GA4取得をスキップ。", site)
            common.record_batch_run(
                conn, site, run_date, "ga4", "failed", 0,
                "ga4_property_id未設定", _now(),
            )
            failures += 1
        elif ga4_client is None:
            failures += 1
            common.record_batch_run(
                conn, site, run_date, "ga4", "failed", 0,
                "GA4クライアント未構築", _now(),
            )
        else:
            n = run_source(conn, "ga4", fetch_ga4.fetch, ga4_client,
                           site_cfg, cfg, today, run_date, logger)
            total_rows += n
            if n == 0:
                failures += 1

            # アフィリエイトクリック(任意)。サイトに ga4_affiliate_event が
            # 設定されているときだけ実行する。失敗しても failures には数えない
            # (本体の取得結果に影響させない)。
            if site_cfg.get("ga4_affiliate_event"):
                n = run_source(conn, "ga4_affiliate", fetch_ga4.fetch_affiliate, ga4_client,
                               site_cfg, cfg, today, run_date, logger)
                total_rows += n

        if gsc_service is None:
            failures += 1
            common.record_batch_run(
                conn, site, run_date, "gsc", "failed", 0,
                "SCサービス未構築", _now(),
            )
        else:
            n = run_source(conn, "gsc", fetch_gsc.fetch, gsc_service,
                           site_cfg, cfg, today, run_date, logger)
            total_rows += n

        # URL Inspection(インデックス状況・既定は週1回)。gsc_service を流用。
        inspect_weekday = cfg.get("inspect_weekday")
        run_inspect = (
            args.inspect_now
            or inspect_weekday is None
            or today.weekday() == inspect_weekday
        )
        if gsc_service is None or not site_cfg.get("sitemap_url"):
            pass  # SC未構築 or sitemap未設定のサイトは対象外
        elif not run_inspect:
            common.record_batch_run(
                conn, site, run_date, "url_inspection", "skipped", 0,
                f"inspect_weekday={inspect_weekday}(本日 weekday={today.weekday()})", _now(),
            )
            logger.info("[%s][url_inspection] 本日は実行日でないためスキップ", site)
        else:
            n = run_source(conn, "url_inspection", fetch_url_inspection.fetch,
                           gsc_service, site_cfg, cfg, today, run_date, logger)
            total_rows += n

    conn.close()

    # CSVエクスポート
    if not args.no_csv:
        try:
            export_csv(cfg["database"], cfg["csv_export_dir"], run_date, logger)
        except Exception as exc:  # noqa: BLE001
            logger.exception("CSVエクスポート失敗: %s", exc)

    logger.info("バッチ完了: 書込総行数=%d, 失敗数=%d", total_rows, failures)
    logger.info("=" * 60)
    # 全滅(行0かつ失敗あり)なら非ゼロ終了。部分成功は0で返す。
    return 1 if (total_rows == 0 and failures > 0) else 0


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

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