o2o-infinith-demo/scripts/ga4_ai_traffic.py
Haewon Kam 8776a15183 feat(ga4): AI 유입 트래픽 연동 뼈대 + 리포트 "측정 가능 범위" 절
2026-09-08 작업분. GA4 에서 AI 답변엔진 유입을 채널로 갈라 보는 뼈대를 세웠다.

- data/ai_channels.json: AI 답변엔진 리퍼러 목록(채널 그룹 정의의 근거)
- scripts/ga4_ai_traffic.py: GA4 Data API 로 AI 유입을 뽑는 러너. --mock 으로 합성
  데이터 검증까지 마쳤다. 실제 속성 연결은 GCP 계정이 정해진 뒤다
- supporters/src/layouts/Base.astro + 템플릿 site.json 2곳: ga4MeasurementId 태그.
  쿠키 고지 배너 결정 전까지 미리보기에는 ID 를 넣지 않는다
- scripts/build_discovery_report.ts: Integrity 절에 "측정 가능 범위" 를 넣어
  무엇을 재고 무엇을 못 재는지 리포트가 스스로 밝히게 했다
- 원진 진단 리포트 v2.1(HTML·PDF) 재생성분

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 10:53:54 +09:00

150 lines
12 KiB
Python

#!/usr/bin/env python3
"""GA4 → AI 검색 유입 리포트. GA4 Data API(runReport)로 세션 출처·랜딩 페이지·키 이벤트를 받아 data/ai_channels.json 기준으로 AI 채널을 분류한다.
연동 방식(B2B): 병원(또는 서포터즈 사이트)의 GA4 속성에 우리 서비스 계정 이메일을 '뷰어'로 추가하면 끝. 코드 설치 없음.
1) Google Cloud 프로젝트에서 Google Analytics Data API 켜기 → 서비스 계정 키(JSON) 발급
2) GA4 관리 > 속성 액세스 관리 > 서비스 계정 이메일을 뷰어로 추가
3) .env: GA4_SERVICE_ACCOUNT_JSON=<키 파일 경로> (속성 ID는 --property 인자)
사용:
python3 scripts/ga4_ai_traffic.py --property 123456789 --days 90 --clinic wonjin --out docs/reports/wonjin/05_ai_traffic
python3 scripts/ga4_ai_traffic.py --from-csv <GA4 탐색 내보내기.csv> --clinic wonjin --out ... # API 없이 (열: date, sessionSource, sessionMedium, landingPage, sessions, keyEvents)
python3 scripts/ga4_ai_traffic.py --mock --clinic demo --out /tmp/ga4demo # 합성 데이터로 파이프라인 검증
산출: <out>/ai_traffic.json (일별·채널별·랜딩별 집계) + <out>/AI_Traffic_Report.md ("측정 가능 범위" 표 포함)
원칙: GA4 로 잴 수 없는 것(AI Overview·네이버 AI 브리핑·리퍼러 없는 유입·노출만 된 경우)은 숫자 없이 '측정 불가'로 적는다. 추정치를 만들지 않는다.
"""
import argparse, csv, datetime as dt, json, os, random, sys, collections
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CHANNELS = json.load(open(os.path.join(ROOT, "data", "ai_channels.json"), encoding="utf-8"))
def load_env():
p = os.path.join(ROOT, ".env")
if not os.path.exists(p): return
for line in open(p, encoding="utf-8"):
if "=" in line and not line.strip().startswith("#"):
k, v = line.split("=", 1); os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
# ---------- 분류 ----------
def classify(source: str, medium: str = "", utm_source: str = "") -> str | None:
"""세션 출처를 AI 채널 id 로. 아니면 None. 부분 문자열이 아니라 호스트 경계로 본다."""
s = (source or "").lower().strip(); u = (utm_source or source or "").lower().strip()
for ch in CHANNELS["channels"]:
for host in ch["sources"]:
h = host.lower()
if s == h or s.endswith("." + h) or s.startswith(h + "/") or s == h.replace("www.", ""): return ch["id"]
for us in ch["utm_sources"]:
if u == us.lower(): return ch["id"]
return None
LABEL = {c["id"]: c["label"] for c in CHANNELS["channels"]}
MEASURABLE = {c["id"]: c["measurable"] for c in CHANNELS["channels"]}
# ---------- 데이터 소스 ----------
def rows_from_api(property_id: str, days: int):
try:
from google.oauth2 import service_account
from google.auth.transport.requests import AuthorizedSession
except ImportError:
sys.exit("google-auth 가 필요합니다: pip install google-auth requests")
load_env()
key_path = os.environ.get("GA4_SERVICE_ACCOUNT_JSON")
if not key_path or not os.path.exists(key_path): sys.exit("GA4_SERVICE_ACCOUNT_JSON(서비스 계정 키 파일 경로) 필요 (.env)")
creds = service_account.Credentials.from_service_account_file(key_path, scopes=["https://www.googleapis.com/auth/analytics.readonly"])
sess = AuthorizedSession(creds)
body = {"dateRanges": [{"startDate": f"{days}daysAgo", "endDate": "yesterday"}],
"dimensions": [{"name": "date"}, {"name": "sessionSource"}, {"name": "sessionMedium"}, {"name": "landingPagePlusQueryString"}],
"metrics": [{"name": "sessions"}, {"name": "keyEvents"}, {"name": "engagedSessions"}],
"limit": 100000}
r = sess.post(f"https://analyticsdata.googleapis.com/v1beta/properties/{property_id}:runReport", json=body, timeout=60)
if r.status_code != 200: sys.exit(f"GA4 Data API {r.status_code}: {r.text[:300]}")
j = r.json()
for row in j.get("rows", []):
d = [x["value"] for x in row["dimensionValues"]]; m = [x["value"] for x in row["metricValues"]]
yield {"date": f"{d[0][:4]}-{d[0][4:6]}-{d[0][6:]}", "source": d[1], "medium": d[2], "landing": d[3].split("?")[0], "utm_source": _utm(d[3]),
"sessions": int(float(m[0])), "key_events": int(float(m[1])), "engaged": int(float(m[2]))}
def _utm(landing_with_qs: str) -> str:
if "utm_source=" not in landing_with_qs: return ""
return landing_with_qs.split("utm_source=")[1].split("&")[0]
def rows_from_csv(path: str):
with open(path, encoding="utf-8-sig") as f:
for r in csv.DictReader(f):
g = lambda *ks: next((r[k] for k in ks if k in r and r[k] not in (None, "")), "")
yield {"date": g("date", "날짜"), "source": g("sessionSource", "세션 소스", "source"), "medium": g("sessionMedium", "세션 매체", "medium"),
"landing": g("landingPage", "landingPagePlusQueryString", "방문 페이지", "landing").split("?")[0], "utm_source": _utm(g("landingPagePlusQueryString", "landing")),
"sessions": int(float(g("sessions", "세션수", "세션") or 0)), "key_events": int(float(g("keyEvents", "주요 이벤트", "conversions") or 0)), "engaged": int(float(g("engagedSessions", "참여 세션수") or 0))}
def rows_mock(days: int = 60):
random.seed(7); today = dt.date.today()
srcs = [("google", "organic", 40), ("(direct)", "(none)", 18), ("chatgpt.com", "referral", 6), ("perplexity.ai", "referral", 2), ("gemini.google.com", "referral", 1), ("search.naver.com", "organic", 25), ("instagram.com", "referral", 4)]
pages = ["/", "/posts/consult-questions", "/posts/procedure-eye", "/visit", "/videos", "/newsroom"]
for i in range(days):
d = (today - dt.timedelta(days=days - i)).isoformat()
for s, m, w in srcs:
for p in random.sample(pages, 3):
n = max(0, int(random.gauss(w / 3, w / 6)))
if n: yield {"date": d, "source": s, "medium": m, "landing": p, "utm_source": "chatgpt.com" if s == "chatgpt.com" else "", "sessions": n, "key_events": sum(1 for _ in range(n) if random.random() < 0.06), "engaged": int(n * 0.6)}
# ---------- 집계 ----------
def aggregate(rows):
daily = collections.defaultdict(lambda: collections.Counter()); by_ch = collections.Counter(); ke_ch = collections.Counter()
landing = collections.defaultdict(lambda: collections.Counter()); total = collections.Counter(); direct = 0; weeks = collections.defaultdict(lambda: collections.Counter())
for r in rows:
total["sessions"] += r["sessions"]; total["key_events"] += r["key_events"]
ch = classify(r["source"], r["medium"], r.get("utm_source", ""))
if r["source"] in ("(direct)", "direct"): direct += r["sessions"]
if not ch: continue
daily[r["date"]][ch] += r["sessions"]; by_ch[ch] += r["sessions"]; ke_ch[ch] += r["key_events"]
landing[r["landing"]][ch] += r["sessions"]; landing[r["landing"]]["_ke"] += r["key_events"]
y, w, _ = dt.date.fromisoformat(r["date"]).isocalendar(); weeks[f"{y}-W{w:02d}"][ch] += r["sessions"]
ai_total = sum(by_ch.values())
return {"total_sessions": total["sessions"], "total_key_events": total["key_events"], "direct_sessions": direct,
"ai_sessions": ai_total, "ai_share_pct": round(ai_total / total["sessions"] * 100, 2) if total["sessions"] else 0,
"by_channel": [{"id": c, "label": LABEL[c], "measurable": MEASURABLE[c], "sessions": n, "key_events": ke_ch[c], "cvr_pct": round(ke_ch[c] / n * 100, 1) if n else 0} for c, n in by_ch.most_common()],
"weekly": {w: dict(v) for w, v in sorted(weeks.items())}, "daily": {d: dict(v) for d, v in sorted(daily.items())},
"landing": sorted([{"page": p, "ai_sessions": sum(n for k, n in v.items() if k != "_ke"), "key_events": v["_ke"], "by": {k: n for k, n in v.items() if k != "_ke"}} for p, v in landing.items()], key=lambda x: -x["ai_sessions"])[:20]}
# ---------- 리포트 ----------
def scope_table_md():
lines = ["| 채널 | GA4로 측정 | 비고 |", "|---|---|---|"]
M = {"yes": "가능", "partial": "일부만", "no": "불가"}
for c in CHANNELS["channels"]: lines.append(f"| {c['label']} | **{M[c['measurable']]}** | {c['note']} |")
lines += ["", "GA4로 잴 수 없는 것:", ""] + [f"- {x}" for x in CHANNELS["not_measurable_by_ga4"]]
return "\n".join(lines)
def report_md(agg, clinic, source_desc, days):
L = [f"# AI 검색 유입 리포트 · {clinic}", "", f"기간 최근 {days}일 · 데이터 {source_desc} · 분류 기준 `data/ai_channels.json` v{CHANNELS['version']} · 작성 {dt.date.today()}", "",
"## 1. 요약", "", "| 지표 | 값 |", "|---|---|",
f"| 전체 세션 | {agg['total_sessions']:,} |", f"| AI 검색 유입 세션(측정 가능분) | {agg['ai_sessions']:,} ({agg['ai_share_pct']}%) |",
f"| AI 유입의 키 이벤트(전화 탭·예약·홈페이지 이동) | {sum(c['key_events'] for c in agg['by_channel']):,} |", f"| direct 세션(리퍼러 없음, AI 유입 일부가 여기 섞임) | {agg['direct_sessions']:,} |", "",
"## 2. 채널별", "", "| 채널 | 세션 | 키 이벤트 | 전환율 | 측정 |", "|---|---|---|---|---|"]
M = {"yes": "가능", "partial": "일부만", "no": "불가"}
for c in agg["by_channel"]: L.append(f"| {c['label']} | {c['sessions']:,} | {c['key_events']} | {c['cvr_pct']}% | {M[c['measurable']]} |")
for c in CHANNELS["channels"]:
if c["measurable"] == "no": L.append(f"| {c['label']} | 측정 불가 | | | 불가 |")
L += ["", "## 3. AI 유입 랜딩 페이지 (상위)", "", "| 페이지 | AI 세션 | 키 이벤트 | 채널 |", "|---|---|---|---|"]
for p in agg["landing"][:12]: L.append(f"| {p['page']} | {p['ai_sessions']} | {p['key_events']} | {', '.join(f'{LABEL[k]} {n}' for k, n in sorted(p['by'].items(), key=lambda x: -x[1]))} |")
L += ["", "## 4. 주별 추이", "", "| 주 | " + " | ".join(LABEL[c["id"]] for c in agg["by_channel"]) + " | 합 |", "|---|" + "---|" * (len(agg["by_channel"]) + 1)]
for w, v in agg["weekly"].items(): L.append(f"| {w} | " + " | ".join(str(v.get(c["id"], 0)) for c in agg["by_channel"]) + f" | {sum(v.values())} |")
L += ["", "## 5. 측정 가능 범위", "", "이 리포트의 숫자는 GA4가 리퍼러나 utm으로 구분할 수 있는 유입만 셉니다. 아래 표의 '일부만'과 '불가'는 실제보다 적게 잡히거나 전혀 잡히지 않는 채널입니다. AI 답변에 병원이 얼마나 나오는지(노출)는 이 리포트가 아니라 답변엔진 실측(질문 뱅크)으로 잽니다. 두 숫자를 같은 시간축에 놓고 읽어야 합니다.", "", scope_table_md(), "",
"## 6. 연동 방식", "", "GA4 속성 액세스 관리에서 INFINITH 서비스 계정 이메일을 '뷰어'로 추가하면 매일 자동으로 갱신됩니다. 코드 설치는 없습니다. 병원 사이트에 GA4가 없으면 태그 설치가 먼저이고, 서포터즈 사이트는 INFINITH가 직접 계측합니다."]
return "\n".join(L) + "\n"
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--property"); ap.add_argument("--days", type=int, default=90); ap.add_argument("--from-csv"); ap.add_argument("--mock", action="store_true")
ap.add_argument("--clinic", required=True); ap.add_argument("--out", required=True)
a = ap.parse_args()
if a.mock: rows, desc = list(rows_mock(a.days if a.days < 90 else 60)), "합성 데이터(파이프라인 검증용)"
elif a.from_csv: rows, desc = list(rows_from_csv(a.from_csv)), f"GA4 내보내기 {os.path.basename(a.from_csv)}"
elif a.property: rows, desc = list(rows_from_api(a.property, a.days)), f"GA4 Data API 속성 {a.property}"
else: sys.exit("--property 또는 --from-csv 또는 --mock 중 하나")
agg = aggregate(rows)
os.makedirs(a.out, exist_ok=True)
json.dump({"clinic": a.clinic, "source": desc, "days": a.days, "generatedAt": dt.date.today().isoformat(), "channels_version": CHANNELS["version"], **agg}, open(os.path.join(a.out, "ai_traffic.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
open(os.path.join(a.out, "AI_Traffic_Report.md"), "w", encoding="utf-8").write(report_md(agg, a.clinic, desc, a.days))
print(f"세션 {agg['total_sessions']:,} · AI 유입 {agg['ai_sessions']:,} ({agg['ai_share_pct']}%) · 채널 {', '.join(f'{c['label']} {c['sessions']}' for c in agg['by_channel'])} → {a.out}")