From 2815ccdf9ea9ff0519b280985efa6359ca17a431 Mon Sep 17 00:00:00 2001 From: seungwonme Date: Wed, 30 Sep 2026 17:20:46 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20GA4=EC=99=80=20Clarity=20=EB=B0=A9?= =?UTF-8?q?=EB=AC=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20UTM=EB=B3=84?= =?UTF-8?q?=EB=A1=9C=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=EC=99=80=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 21 +++++++ scripts/analytics-report.sh | 112 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100755 scripts/analytics-report.sh diff --git a/AGENTS.md b/AGENTS.md index 6ef447f..76b3093 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,6 +259,27 @@ GA4, Microsoft Clarity, Search Console, 네이버 서치어드바이저, Bing 분석 도구를 새로 붙이면 `src/pages/privacy.astro`에도 항목을 더한다. - Bing은 Search Console에서 가져오기로 연결한다. 토큰 없이 확인되고 사이트맵도 따라온다. +### 방문 데이터 조회 + +`scripts/analytics-report.sh`로 GA4와 Clarity 데이터를 유입 경로(UTM)별로 본다. 읽기만 한다. + +```bash +scripts/analytics-report.sh ga 7 # GA4 최근 7일 +scripts/analytics-report.sh ga 7 <캠페인> # utm_campaign 하나만 +scripts/analytics-report.sh clarity 1 # Clarity 최근 1일 +``` + +- **GA4는 키 파일 없이 서비스 계정을 가장해 읽는다.** GCP 프로젝트 `nomadamas-analytics`의 서비스 계정 + `ga-reader`가 GA4 속성(`555475692`)에 뷰어로 들어가 있다. 이 서비스 계정의 토큰을 받을 권한 + (`roles/iam.serviceAccountTokenCreator`)은 조직 공용 Google 계정에만 있다. 스크립트는 `gcloud auth list`에서 + 이 권한이 있는 계정을 찾아 쓰고, 없으면 그 계정으로 `gcloud auth login`부터 한다. +- **Clarity는 `CLARITY_API_TOKEN`으로 읽는다.** 환경변수가 없으면 `agents-env` 전역 스토어에서 꺼낸다. + 토큰은 Clarity 프로젝트 설정의 Data Export에서 발급한다. +- **Clarity는 하루 10회, 최근 1~3일만 조회된다.** 7일 이상의 추이는 GA4로 본다. + Clarity의 `sessions`는 봇을 뺀 수이고, `bot_sessions`만 있는 경로는 사람 방문이 아니다. +- **GA4 표준 보고서는 반영이 늦다.** 방문 뒤 하루에서 이틀까지 걸려, 공유 직후 몇 시간은 Clarity에만 잡힐 수 있다. +- **권한을 끊을 때**는 GA4 관리의 속성 액세스 관리에서 서비스 계정을 빼고, Clarity 설정에서 토큰을 지운다. + ## 나중에: 커스텀 도메인 `blog.nomadamas.org` 지금은 `nomadamas.github.io`로 서비스한다. 자체 도메인으로 옮길 때만 아래를 한다. diff --git a/scripts/analytics-report.sh b/scripts/analytics-report.sh new file mode 100755 index 0000000..bda6183 --- /dev/null +++ b/scripts/analytics-report.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# 블로그 방문 데이터를 유입 경로(UTM)별로 조회한다. 읽기만 하고 아무것도 바꾸지 않는다. +# +# scripts/analytics-report.sh ga [일수] [캠페인] GA4, 기본 최근 7일 +# scripts/analytics-report.sh clarity [일수] Clarity, 기본 최근 1일(최대 3일) +# +# 인증과 권한, 호출 한도는 AGENTS.md "방문 데이터 조회" 절에 있다. +set -euo pipefail + +GA_PROPERTY_ID="${GA_PROPERTY_ID:-555475692}" +GA_SERVICE_ACCOUNT="${GA_SERVICE_ACCOUNT:-ga-reader@nomadamas-analytics.iam.gserviceaccount.com}" +GA_SCOPE="https://www.googleapis.com/auth/analytics.readonly" +CLARITY_ENDPOINT="https://www.clarity.ms/export-data/api/v1/project-live-insights" + +usage() { + local code="${1:-0}" fd=1 + [ "$code" -eq 0 ] || fd=2 + sed -n '2,7p' "$0" | sed 's/^# \{0,1\}//' >&"$fd" + exit "$code" +} + +need() { + command -v "$1" >/dev/null 2>&1 || { echo "필요한 명령이 없습니다: $1" >&2; exit 1; } +} + +as_table() { + column -t -s "$(printf '\t')" +} + +# 서비스 계정 토큰을 받을 수 있는 gcloud 로그인 계정을 찾아 토큰을 낸다. +# 키 파일 없이 가장(impersonation)으로 받으므로 권한이 있는 계정이 로그인돼 있어야 한다. +ga_token() { + local accounts a tok + if [ -n "${GA_ACCOUNT:-}" ]; then + accounts="$GA_ACCOUNT" + else + accounts=$(gcloud auth list --format='value(account)') + fi + for a in $accounts; do + # 재로그인이 필요한 계정이 비밀번호를 묻고 멈추지 않게 입력을 닫는다 + if tok=$(gcloud auth print-access-token --account="$a" \ + --impersonate-service-account="$GA_SERVICE_ACCOUNT" \ + --scopes="$GA_SCOPE" /dev/null) && [ -n "$tok" ]; then + printf '%s' "$tok" + return 0 + fi + done + echo "서비스 계정 토큰을 받을 수 있는 gcloud 계정이 없습니다. AGENTS.md \"방문 데이터 조회\" 절을 보세요." >&2 + return 1 +} + +ga_report() { + local days="${1:-7}" campaign="${2:-}" filter='{}' body tok + need gcloud; need curl; need jq + case "$days" in '' | *[!0-9]*) echo "일수는 숫자로 적습니다." >&2; usage 1 ;; esac + if [ -n "$campaign" ]; then + filter=$(jq -nc --arg c "$campaign" \ + '{dimensionFilter: {filter: {fieldName: "sessionCampaignName", stringFilter: {value: $c}}}}') + fi + body=$(jq -nc --arg start "${days}daysAgo" --argjson f "$filter" '{ + dateRanges: [{startDate: $start, endDate: "today"}], + dimensions: [{name: "sessionSource"}, {name: "sessionMedium"}, + {name: "sessionCampaignName"}, {name: "sessionManualAdContent"}], + metrics: [{name: "sessions"}, {name: "activeUsers"}, {name: "screenPageViews"}], + orderBys: [{metric: {metricName: "sessions"}, desc: true}] + } + $f') + tok=$(ga_token) + curl -sS -X POST "https://analyticsdata.googleapis.com/v1beta/properties/${GA_PROPERTY_ID}:runReport" \ + -H "Authorization: Bearer $tok" -H "Content-Type: application/json" -d "$body" | + jq -r 'if .error then ("GA 오류 \(.error.code): \(.error.message)\n" | halt_error(1)) else + (["source", "medium", "campaign", "content", "sessions", "users", "views"] | @tsv), + (.rows // [] | .[] | [.dimensionValues[].value, .metricValues[].value] + | map(if . == "" then "-" else . end) | @tsv) + end' | as_table +} + +clarity_report() { + local days="${1:-1}" url resp code + need curl; need jq + case "$days" in 1 | 2 | 3) ;; *) echo "Clarity는 최근 1~3일만 조회할 수 있습니다." >&2; usage 1 ;; esac + url="${CLARITY_ENDPOINT}?numOfDays=${days}&dimension1=Source&dimension2=Medium&dimension3=Campaign" + if [ -n "${CLARITY_API_TOKEN:-}" ]; then + resp=$(curl -sS -w '\n%{http_code}' "$url" -H "Authorization: Bearer $CLARITY_API_TOKEN") + elif command -v agents-env >/dev/null 2>&1; then + resp=$(agents-env run CLARITY_API_TOKEN -- \ + curl -sS -w '\n%{http_code}' "$url" -H 'Authorization: Bearer {{CLARITY_API_TOKEN}}') + else + echo "CLARITY_API_TOKEN이 없습니다. AGENTS.md \"방문 데이터 조회\" 절을 보세요." >&2 + exit 1 + fi + code=${resp##*$'\n'} + resp=${resp%$'\n'*} + case "$code" in + 200) ;; + 429) echo "Clarity 하루 호출 한도(10회)를 넘었습니다. 내일 다시 조회하세요." >&2; exit 1 ;; + *) echo "Clarity 오류 HTTP $code: $resp" >&2; exit 1 ;; + esac + printf '%s' "$resp" | jq -r ' + def v(k): (.[k] // "") | tostring | if . == "" then "-" else . end; + (["source", "medium", "campaign", "sessions", "bot_sessions"] | @tsv), + (.[] | select(.metricName == "Traffic") | .information[] + | [v("Source"), v("Medium"), v("Campaign"), + v("totalSessionCount"), v("totalBotSessionCount")] | @tsv) + ' | as_table +} + +case "${1:-}" in + ga) shift; ga_report "$@" ;; + clarity) shift; clarity_report "$@" ;; + -h | --help) usage 0 ;; + *) usage 1 ;; +esac