diff --git a/data/ai_channels.json b/data/ai_channels.json new file mode 100644 index 0000000..39193ba --- /dev/null +++ b/data/ai_channels.json @@ -0,0 +1,34 @@ +{ + "_comment": "AI 검색 유입 분류 기준(단일 원본). GA4 세션 출처(sessionSource)·리퍼러·utm_source 를 이 표로 AI 채널에 묶는다. measurable 은 리포트의 '측정 가능 범위' 표에 그대로 나간다. 수정하면 scripts/ga4_ai_traffic.py 와 리포트 빌더가 같이 바뀐다.", + "version": "2026-09-08", + "channels": [ + { "id": "chatgpt", "label": "ChatGPT", "sources": ["chatgpt.com", "chat.openai.com", "openai.com"], "utm_sources": ["chatgpt.com", "chatgpt", "openai"], + "measurable": "yes", "note": "링크에 utm_source=chatgpt.com 을 붙여 보내므로 리퍼러가 비어도 잡힌다." }, + { "id": "perplexity", "label": "Perplexity", "sources": ["perplexity.ai", "www.perplexity.ai"], "utm_sources": ["perplexity", "perplexity.ai"], + "measurable": "partial", "note": "리퍼러를 자주 비운다. 잡히는 건수는 실제 유입의 일부다. 실측 언급률과 함께 읽는다." }, + { "id": "gemini", "label": "Gemini", "sources": ["gemini.google.com", "bard.google.com"], "utm_sources": ["gemini"], + "measurable": "yes", "note": "gemini.google.com 리퍼러로 분리된다." }, + { "id": "copilot", "label": "Copilot", "sources": ["copilot.microsoft.com", "bing.com/chat", "edgeservices.bing.com"], "utm_sources": ["copilot"], + "measurable": "partial", "note": "Bing 검색과 리퍼러가 섞이는 경우가 있다." }, + { "id": "claude", "label": "Claude", "sources": ["claude.ai"], "utm_sources": ["claude"], + "measurable": "yes", "note": "claude.ai 리퍼러." }, + { "id": "google_aio", "label": "Google AI Overview · AI Mode", "sources": [], "utm_sources": [], + "measurable": "no", "note": "구글 자연검색(google / organic)과 같은 리퍼러라 GA4로는 구분되지 않는다. 타사 도구의 'AI Overview 유입'도 추정치다. 가시성은 답변엔진 실측으로만 잰다." }, + { "id": "naver_ai", "label": "네이버 AI 브리핑", "sources": [], "utm_sources": [], + "measurable": "no", "note": "네이버 검색 리퍼러(search.naver.com)와 구분되지 않는다. 네이버 서치어드바이저 유입 키워드로 간접 확인만 가능." }, + { "id": "other_ai", "label": "기타 AI 검색", "sources": ["you.com", "search.brave.com", "phind.com", "deepseek.com", "chat.deepseek.com", "grok.com", "x.ai", "meta.ai", "kagi.com", "liner.com", "getliner.com", "wrtn.ai"], "utm_sources": ["deepseek", "grok", "you.com", "wrtn"], + "measurable": "partial", "note": "리퍼러가 있을 때만 잡힌다." } + ], + "not_measurable_by_ga4": [ + "AI 답변에 병원이 언급됐지만 사용자가 링크를 누르지 않은 경우(노출만 된 경우). 이건 답변엔진 실측(질문 뱅크)으로 잰다.", + "AI 크롤러(GPTBot·PerplexityBot·ClaudeBot·Google-Extended)의 방문. 사람 방문이 아니라 GA4에 잡히지 않는다. 서버 로그로 잰다.", + "리퍼러를 비운 채 넘어온 AI 유입. GA4에는 direct 로 들어간다.", + "전화 통화·오프라인 방문으로 이어진 전환. 사이트의 전화 탭·예약 클릭까지만 잰다." + ], + "conversion_events": [ + { "name": "click_tel", "label": "전화 탭", "match": "a[href^='tel:']" }, + { "name": "click_reservation", "label": "상담 예약 클릭", "match": "a.cta, a[data-ga='reservation']" }, + { "name": "click_clinic_site", "label": "병원 홈페이지 이동", "match": "a[data-ga='clinic-site']" }, + { "name": "click_map", "label": "지도·길찾기 클릭", "match": "a[data-ga='map']" } + ] +} diff --git a/docs/INFINITH_GA4_AI_Traffic_Integration.md b/docs/INFINITH_GA4_AI_Traffic_Integration.md new file mode 100644 index 0000000..877468b --- /dev/null +++ b/docs/INFINITH_GA4_AI_Traffic_Integration.md @@ -0,0 +1,57 @@ +# GA4 AI 검색 유입 연동 안내 v0.1 + +작성 2026-09-08 · 스크립트 `scripts/ga4_ai_traffic.py` · 분류 기준 `data/ai_channels.json` · 태그 `supporters/src/layouts/Base.astro` + +## 1. 구조 + +| 대상 | 누가 계측하는가 | 필요한 것 | +|---|---|---| +| 서포터즈 사이트 | INFINITH가 직접. `site.json`의 `ga4MeasurementId`(G-XXXX)를 넣으면 빌드 시 태그가 들어간다 | INFINITH GA4 속성 1개(병원별 데이터 스트림) | +| 병원 공식 사이트 | 병원이 이미 쓰는 GA4 속성에 INFINITH 서비스 계정을 뷰어로 추가 | 병원 GA4 관리자 권한자 1명, 5분 | + +코드 설치는 없다. 병원 사이트에 GA4가 없으면 태그 설치가 먼저이고 그건 병원 웹 담당자 일이다. + +## 2. 연동 절차 (병원 사이트) + +1. INFINITH: Google Cloud 프로젝트에서 Google Analytics Data API 사용 설정, 서비스 계정 생성, 키(JSON) 발급. `.env`에 `GA4_SERVICE_ACCOUNT_JSON=<경로>`. 계정은 haewon이 지정한다(이전 GCP 재인증 이슈 있음). +2. 병원: GA4 관리 → 속성 액세스 관리 → 사용자 추가 → 서비스 계정 이메일, 역할 **뷰어**. +3. 병원: 속성 ID(숫자 9자리) 전달. +4. INFINITH: `python3 scripts/ga4_ai_traffic.py --property --days 90 --clinic --out docs/reports//05_ai_traffic`. +5. 월간 실행 → 리포트 카드와 병원 확인 패널에 붙인다(다음 단계). + +API 없이 받을 때: 병원이 GA4 탐색에서 날짜·세션 소스·세션 매체·방문 페이지·세션수·주요 이벤트를 CSV로 내보내면 `--from-csv`로 같은 리포트를 만든다. + +## 3. AI 채널 분류 규칙 + +`data/ai_channels.json` 한 곳에서 관리한다. 세션 출처 호스트(경계 일치)와 `utm_source`로 묶는다. + +| 채널 | 측정 | 이유 | +|---|---|---| +| ChatGPT | 가능 | 링크에 utm_source=chatgpt.com | +| Gemini, Claude | 가능 | 고유 리퍼러 | +| Perplexity, Copilot, 기타 AI | 일부만 | 리퍼러를 자주 비움 → direct로 섞임 | +| Google AI Overview · AI Mode | 불가 | google / organic과 구분되지 않음 | +| 네이버 AI 브리핑 | 불가 | search.naver.com과 구분되지 않음 | + +리포트마다 "측정 가능 범위" 절이 자동으로 들어간다. 불가 채널에는 숫자를 만들지 않는다. + +## 4. 키 이벤트 + +서포터즈 사이트 태그가 보내는 이벤트. GA4에서 **주요 이벤트로 표시**해야 전환으로 집계된다. + +| 이벤트 | 조건 | +|---|---| +| click_tel | `tel:` 링크 | +| click_reservation | 헤더 상담 예약 버튼(`a.cta`) 또는 `data-ga="reservation"` | +| click_clinic_site | `data-ga="clinic-site"` (병원 홈페이지 이동) | +| click_map | `data-ga="map"` (지도·길찾기) | + +## 5. 개인정보 + +GA4 태그는 쿠키를 쓴다. `anonymize_ip`는 켜 두었다. 쿠키 고지 배너를 넣을지, 쿠키 없는 대안(서버 로그, Vercel Web Analytics)으로 갈지는 haewon 결정 대기. 결정 전까지 서포터즈 미리보기(noindex)에는 측정 ID를 넣지 않는다. + +## 6. 남은 일 + +- 리포트 카드(`build_discovery_report.ts`)에 AI 유입 요약 카드와 실측 언급률 동일 시간축 그래프. +- 병원 확인 패널(`/supporters/:id`)에 "GA4 뷰어 추가" 안내와 속성 ID 입력란. +- 월간 실행을 워커 또는 cron에. diff --git a/docs/reports/wonjin/01_readiness_report/Wonjin_AI_Answer_Readiness_Report_v2.1_2026-09-08.html b/docs/reports/wonjin/01_readiness_report/Wonjin_AI_Answer_Readiness_Report_v2.1_2026-09-08.html new file mode 100644 index 0000000..3800ca7 --- /dev/null +++ b/docs/reports/wonjin/01_readiness_report/Wonjin_AI_Answer_Readiness_Report_v2.1_2026-09-08.html @@ -0,0 +1,155 @@ + + +원진성형외과 AI Answer Readiness Report v2.0 + + + + +
+
INFINITH AI Discovery · AEO / GEO Audit v2.0
+

AI Answer Readiness

+
원진성형외과
+
성형외과 (강남역) · https://www.k-wonjin.co.kr
+

AI 답변엔진(ChatGPT·Perplexity·Gemini·네이버 AI 브리핑)이 이 병원을 얼마나 읽고, 믿고, 인용할 수 있는지를 실측 신호로 채점한 리포트입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고, 확인하지 못한 항목은 점수에서 뺍니다.

+
+
15/ 100AEO 등급 D
45/ 100GEO 등급 C
+
+
25실측 항목 / 36
+
49v1.0 종합 (C)
+
5핵심 발견
+
6실행 항목
+
+
+

실측일 2026-09-08 · 리포트 작성 2026-09-08 · 미검증 11개 항목은 분모에서 제외했습니다. 조건은 마지막 장에 적었습니다.

+
+ +
+
Findings

AI가 지금 원진성형외과를 어떻게 읽는지 정리했습니다

+
+
강점

크롤러 접근은 열려 있습니다. 서버 렌더링이라 AI가 본문을 읽습니다

robots.txt가 AI 검색봇 12종을 모두 허용하고, IIS가 본문을 서버에서 렌더링해 JS 없이도 14,079자를 읽을 수 있습니다. TTFB 0.09초, 초기 HTML 153KB로 가볍습니다. 다만 www 유무에 따라 최종 URL이 갈라지고(정본 미수렴), sitemap이 robots.txt에 선언돼 있지 않습니다.

핵심 과제

연결은 됐지만 병원 자체가 스키마로 정의돼 있지 않습니다

Organization.sameAs 10개로 SNS·지도·버티컬을 잘 묶었고 주소 표기도 한 가지입니다. 그러나 MedicalClinic·Physician 스키마가 없어 주소·전화·진료시간·의료진 17명이 구조화돼 있지 않고, 전화번호는 6종이 섞여 있습니다. 영문 사이트(wonjinbeauty.com)는 hreflang으로 연결되지 않았습니다.

핵심 과제

인용할 문장이 부족합니다. H1도 표도 정의문도 없습니다

메인·시술 페이지에 H1이 없고 HTML 표가 없으며 병원 정의문도 없습니다. 눈매교정(CFS)·트임 페이지에는 수술 시간·마취·실밥·일상 복귀 수치가 텍스트로 있어 인용 구조의 출발점이 될 수 있지만, 쌍꺼풀·눈매교정 나머지 페이지는 수치가 없습니다. 최신 갱신 신호가 2025-02-18(sitemap)로 18개월 전입니다.

보완

유튜브는 강합니다. 736편·누적 3,113만 회

설명형 롱폼(눈성형 관리법·중년 눈성형 비교·지방흡입 QnA 등)이 있어 글의 근거 영상으로 쓸 수 있습니다. 다만 상위 조회 영상의 다수가 브랜드 필름·연예인·전후 형식이라 인용용 정보형 영상은 10편 안팎입니다. 이 채널이 원진의 가장 큰 오픈웹 자산입니다.

보완

이미지 alt 62% 누락, 의료진 경력은 텍스트지만 구조화는 안 됨

메인 이미지 45개 중 28개가 alt 없이 실려 AI가 이미지 내용을 읽지 못합니다. 의료진 17명의 경력은 텍스트로 있어(원장당 12~23줄) Physician 스키마만 붙이면 바로 엔티티가 됩니다. 강남언니·GBP·네이버 플레이스는 이번 자동 채점 범위 밖이라 수동 실측이 남아 있습니다.

+
+
+ +
+
AEO · Answer Readiness

답변 준비도 8개 항목

+

질문을 고르고, 답을 먼저 쓰고, 한 단락으로 완결하고, 결정에 필요한 정보와 다음 행동까지 닿게 하는가.

+ + + +
항목무엇을 보는가점수근거파생된 실측 항목
AEO1질문 수요 충족
사람들이 실제로 묻는 질문에 답하는 페이지가 있는가
0 / 100실측 파생C2 L0
AEO2답변의 직접성
페이지 첫 단락에 답이 먼저 나오는가
0 / 100실측 파생C1 L0
AEO3답변 단위의 자기완결성
한 단락만 떼어내도 누가·어디서·무엇을이 들어 있는가
미검증미검증C8 미검증
AEO4의사결정에 필요한 정보
가격대·시간·회복·조건처럼 결정에 쓰는 정보가 텍스트로 있는가
40 / 100실측 파생C4 L2 · C5 L0
AEO5정보 구조와 가독성
제목이 주제를 서술하고 계층이 논리적인가
0 / 100실측 파생C3 L0
AEO6다음 행동의 명확성
답을 읽은 사람이 무엇을 하면 되는지(예약·전화·방문) 바로 보이는가
미검증미검증
AEO7핵심 답변의 텍스트 추출
답이 이미지가 아니라 텍스트로 존재하고 서버가 렌더하는가
60 / 100실측 파생A3 L3 · F1 L0
AEO8사이트 내 답변 발견
크롤러가 답이 있는 페이지를 찾아갈 수 있는가
47 / 100실측 파생A5 L1 · B6 L2
+ +
GEO · Source Readiness

출처 준비도 8개 항목

+

주장에 근거가 붙어 있고, 누가 책임지는지 보이고, 병원 사실이 표면마다 같고, 갱신·정정 기록이 있으며, 기계가 인용 식별자를 읽을 수 있는가.

+ + + +
항목무엇을 보는가점수근거파생된 실측 항목
GEO1주장과 근거의 일치
수치·수상·효과 주장마다 근거(원문·출처·조건)가 붙어 있는가
미검증미검증
GEO2출처의 적합성과 추적성
인용된 URL이 정본으로 수렴하고 시간이 지나도 같은 곳을 가리키는가
33 / 100실측 파생A4 L1
GEO3전문성과 책임 주체
누가 썼고 누가 의학적으로 책임지는지 텍스트와 스키마로 보이는가
40 / 100실측 파생C7 L2 · B3 L0
GEO4병원 엔티티와 사실의 정합성
이 병원이 누구인지 사이트 안과 밖(강남언니·GBP·플레이스)에서 같은 사실로 확정되는가
63 / 100실측 파생B2 L1 · B4 L3 · B5 L3 · D1 미검증 · D2 미검증 · D4 미검증 · D5 미검증 · D6 L0
GEO5사실의 최신성과 정정
언제 확인한 사실인지 적혀 있고, 틀린 것을 고친 기록이 있는가
33 / 100실측 파생C6 L1
GEO6원천 대비 추가 가치
다른 곳에 있는 문장을 옮긴 것이 아니라 여기서만 얻는 정보(체크리스트·조건 표·실측)가 있는가
미검증미검증
GEO7발행 관계와 투명성
누가 운영하고 누구의 지원을 받는지, 광고와 정보가 구분되는지 밝히는가
미검증미검증
GEO8인용 식별자와 메타데이터 정합성
기계가 읽는 식별자(JSON-LD·메타)가 유효하고 화면 내용과 같은가
67 / 100실측 파생B1 L2
+ +
+ +
+
Category Detail

카테고리별 실측 36개 항목

+
+
A · Access & Crawlability

AI 크롤러 접근성

80 / 100 · 실측 6/7
B · Entity & Structured Data

엔티티 식별·구조화

50 / 100 · 실측 7/7
C · Answer-Ready Content

답변 인용 가능 콘텐츠

25 / 100 · 실측 7/8
D · Off-site Surfaces

AI 인용 표면 확보

53 / 100 · 실측 2/6
E · Measurement

AI 가시성 측정 인프라

0 / 100 · 실측 0/4
F · Technical Hygiene

기술 위생

42 / 100 · 실측 3/4
+
+
+
+

A · AI 크롤러 접근성 AI 검색엔진이 사이트를 읽을 수 있는가. 크롤러 접근이 막히면 나머지 항목은 전부 무효다.

+ + +
ID항목판정실측 근거
A1검색용 AI 봇 허용
배점 5
L3
검색봇 전부 허용 + 200 응답
12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200
A2CDN/WAF AI 차단 없음
배점 3
L3
CDN 차단 없음 확인
Server: Microsoft-IIS/10.0 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음
A3서버 렌더링 본문
배점 3
L3
80% 이상 (SSR/정적)
서버 렌더링. JS 미실행 HTML에서 본문 텍스트 14,079자 추출
A4HTTPS·리다이렉트 정합
배점 2
L1
변형 간 200 중복
변형 간 최종 URL 불일치(중복 콘텐츠 위험): ['https://k-wonjin.co.kr', 'https://www.k-wonjin.co.kr']
A5sitemap.xml 제공
배점 3
L1
있으나 robots 미선언
/sitemap.xml 200이나 robots.txt에 Sitemap 미선언. URL 132개
A6Bing 색인 (ChatGPT 검색 경로)
배점 3
미검증
미검증
[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요 (또는 site: 검색 수동 확인)
A7응답 속도·크롤 예산
배점 1
L3
TTFB < 0.5s, HTML < 300KB
TTFB 0.094s / 초기 HTML 153KB. 시술 페이지 145KB. 모두 기준 이내

B · 엔티티 식별·구조화 AI가 "이 병원이 누구인지"를 기계적으로 확정할 수 있는가. NAP 일관성과 스키마 위생.

+ + +
ID항목판정실측 근거
B1JSON-LD 문법 유효성
배점 3
L2
전부 유효
JSON-LD 4블록 전부 유효 (@graph 미사용)
B2업종 엔티티 스키마 (MedicalClinic / LegalService / LocalBusiness)
배점 5
L1
Organization만
ItemList + Organization만 존재. MedicalClinic/LocalBusiness 없음 → 주소·전화·진료시간이 구조화되어 있지 않음
B3전문가 스키마 (Physician / Attorney)
배점 3
L0
없음
Physician/Attorney 스키마 없음. 전문가 정보는 HTML 텍스트·이미지로만 존재
B4NAP 사이트 내 일관성
배점 3
L3
단일 표기
주소 단일 표기: "서울시 서초구 강남대로 419 파고다타워 12". 전화 6종: 02-3477-3300, 023480-3573, 02392-0330
B5sameAs 연결
배점 2
L3
5개 이상 (SNS + 버티컬 + 지도)
sameAs 10개 (SNS·버티컬·지도 연결)
B6canonical·중복 제거
배점 2
L2
있음
rel=canonical self-referencing 존재
B7다국어 hreflang
배점 2
L0
다국어 페이지 있으나 미연결
다국어 페이지 흔적(https://www.wonjinbeauty.com/en/) 있으나 hreflang 없음. 미연결

C · 답변 인용 가능 콘텐츠 패시지 단위로 잘라내도 자기완결적인 사실 덩어리가 있는가. 정의문·FAQ·수치·표·의료진 근거.

+ + +
ID항목판정실측 근거
C1정의문형 첫 문단
배점 4
L0
없음 (슬로건·이미지만)
홈 본문 1,999자에 "원진성형외과는 …에 있는 … 병원입니다" 형태의 정의문 없음. 소개 문구(25년 헤리티지·강남역)는 메인 비주얼 이미지 alt 에만 있고, 소개 페이지(/hospitalinfo/about) 첫 단락도 슬로건형
C2FAQ 섹션
배점 4
L0
없음
FAQ·자주 묻는 질문·Q&A 섹션 탐지 안 됨 (메인·시술 페이지)
C3제목 계층 의미성
배점 3
L0
H1 없음 또는 5개 이상
메인 H1 0개: 없음. 시술 페이지 H1 없음
C4구체 수치 (가격·시간·회복)
배점 4
L2
시술별 일부 수치
눈매교정(CFS) 페이지에 수술 시간 2시간·단기 수면 또는 부분 마취·실밥 제거 7일·일상생활 3일 텍스트 존재. 트임(오픈아이) 40분·5~7일. 쌍꺼풀·눈매교정(글램아이·더블유착) 페이지는 수치 없음. 가격 표기 0회(비급여 안내 페이지는 별도)
C5표·비교 구조
배점 2
L0
없음
HTML <table> 없음. 비교 정보는 이미지로 추정 (이미지 표 여부는 수동 확인)
C6신선도 신호
배점 3
L1
1년 이상
최신 갱신 신호 2025-02-18 (sitemap lastmod 2025-02-18). 18개월 전. dateModified 마크업 없음
C7전문가 E-E-A-T (의료진 / 변호사)
배점 3
L2
자격·경력 텍스트
의료진 상세 페이지 17명의 직함·전문의 여부·경력(원장당 12~23줄)이 HTML 텍스트로 존재. 학술 활동 페이지 3,760자. 단 Physician 스키마·저자 표기는 없어 구조화되지 않음
C8패시지 자기완결성
배점 2
미검증
미검증
[manual] 핵심 페이지 단락 샘플 10개의 상호·시술명 포함 비율 수동 채점 필요

D · AI 인용 표면 확보 실제로 AI가 인용하는 외부 표면(강남언니·GBP·오픈웹)에 존재하는가. 네이버 자산은 글로벌 AI에 기여도 0.

+ + +
ID항목판정실측 근거
D1업종 버티컬 프로필 (병의원: 강남언니 / 법률: 로톡)
배점 6
미검증
미검증
강남언니 프로필은 공개 페이지 수동 실측 필요 (후기 수·의료진·이벤트·갱신일). 홈페이지 sameAs 에 버티컬 링크 포함 여부는 확인됨(10개 중)
D2Google 비즈니스 프로필
배점 4
미검증
미검증
[manual] Google Maps 검색으로 GBP 소유·리뷰·NAP 수동 실측 필요
D3오픈웹 언급 (티스토리·유튜브·언론)
배점 4
L2
유튜브 + 언론/티스토리
자체 YouTube 채널 @wjwonjin 영상 736편·구독 1.5만·누적 조회 3,113만(2026-09-08 API 집계). 인스타그램 3계정·페이스북·네이버 블로그(popokpop) 존재. 채널 sameAs 10개로 홈페이지와 연결됨. 단 홈페이지 내 외부 링크는 JS 렌더라 HTML에서 미노출
D4표면 간 NAP 완전 일치
배점 3
미검증
미검증
[manual] 홈페이지·버티컬·GBP·플레이스 NAP 대조표 수동 작성 필요
D5네이버 플레이스 (AI 브리핑 전용)
배점 2
미검증
미검증
[semi·사람 판정 필요] 사이트에 네이버 플레이스 링크 없음. 플레이스 존재·홈페이지 역연결·리뷰 수 수동 확인
D6위키데이터/위키백과 엔티티
배점 1
L0
없음
Wikidata에서 "원진성형외과" 검색 결과 없음

E · AI 가시성 측정 인프라 개선을 증명할 수 있는가. 소유 신호(크롤러 로그·GA4 AI 유입) 계측 여부.

+ + +
ID항목판정실측 근거
E1AI 크롤러 로그 (IP 검증)
배점 3
미검증
미검증
[manual] 서버 로그 접근 필요 (AI 크롤러 히트 + IP 검증)
E2GA4 AI 유입 채널
배점 3
미검증
미검증
[manual] GA4 접근 필요 (AI 유입 커스텀 채널그룹 여부)
E3GSC AI Overviews 노출
배점 2
미검증
미검증
[manual] GSC 접근 필요 (AI Overviews 노출)
E4기준선 스팟체크
배점 2
미검증
미검증
[manual] 기준선 스팟체크 미실시 (질의 10개 × 5회 반복은 별도 단계)

F · 기술 위생 이미지 대체텍스트·CMS 보안·페이지 무게·모바일. 보조 지표.

+ + +
ID항목판정실측 근거
F1이미지 대체텍스트
배점 2
L0
50% 이상 누락
메인 페이지 이미지 45개 중 28개(62%) alt 누락 또는 빈 값
F2CMS 보안·최신성
배점 1
미검증
미검증
Server: Microsoft-IIS/10.0, ASP.NET 계열 자체 제작 추정(meta generator 없음). CMS 버전·보안 패치 여부 판정 불가
F3페이지 무게
배점 1
L3
< 300KB
초기 HTML 153KB (300KB 미만)
F4모바일 대응
배점 1
L2
반응형
viewport meta 존재, 반응형. CWV 미측정
+
+ +
+
Action Plan

지금 고칠 것과 4주 안에 만들 것을 나눴습니다

+

P0 · 지금 고칠 것 (1주 안)

1일
P0

정본 URL 수렴 + sitemap 선언

k-wonjin.co.kr → www.k-wonjin.co.kr 로 301 통일, robots.txt 에 Sitemap: 선언, sitemap lastmod 갱신. 전화번호를 대표번호 1개로 통일.

연결 항목 A4 A5 B4
1주
P0

MedicalClinic + Physician 스키마

Organization 을 MedicalClinic 으로 승격(address·telephone·openingHoursSpecification·medicalSpecialty). 의료진 17명 Physician 스키마(이름·직함·전문의·프로필 URL). 영문 사이트와 hreflang 상호 연결.

연결 항목 B2 B3 B7

P1 · 4주 안에 만들 것

2~4주
P1

시술 페이지 인용 구조: H1·표·FAQ

시술 페이지마다 H1 1개, 수술 시간·마취·실밥·일상 복귀를 HTML 표로, FAQ 5문항 + FAQPage 스키마. CFS·트임 페이지의 수치 형식을 다른 눈·코·윤곽 페이지로 확산. 메인에 병원 정의문 한 단락.

연결 항목 C1 C2 C3 C4 C5
1주
P1

이미지 alt 일괄 정비 + 갱신 신호

메인 28개 이미지 alt 작성(무엇을 찍은 사진인지 사실만). 페이지에 dateModified 마크업, sitemap lastmod 를 실제 수정일로.

연결 항목 F1 C6
2~4주
P1

서포터즈 사이트로 오픈웹 원문 공급

자동 빌드 미리보기(supporters-wonjin.vercel.app)의 글 6편을 병원이 검토·승인하면 색인 허용 도메인으로 발행. 유튜브 설명 영상 10편을 글에 연결. 이후 질문 뱅크 기준으로 편집 캘린더 운영.

연결 항목 C2 C4 C5 D3

P2 · 이어서 할 것

지속
P2

표면 실측과 측정 인프라

강남언니·GBP·네이버 플레이스 NAP 대조표 작성, GA4 AI 유입 채널그룹, GSC AI Overviews 확인, 서버 로그의 AI 크롤러 히트 계측. Bing Webmaster 등록.

연결 항목 D1 D2 D4 D5 E1 E2 E3 A6
+
+ +
+
Supporters Preview

홈페이지 자료만으로 서포터즈 사이트 초안을 만들었습니다

+

병원 URL 하나로 근거 수집, 글 생성, 발행 게이트, 미리보기 배포까지 자동으로 돌렸습니다. 모든 글은 의학 검토 대기 상태이고 색인은 막혀 있습니다. 병원이 확인 항목을 입력하고 검토·승인하면 코드 수정 없이 발행됩니다.

+
+
6글 (게이트 통과)
+
736유튜브 영상 집계
+
66사용 가능 이미지 (전후·사례 제외)
+
17의료진 프로필
+
+

미리보기 https://supporters-wonjin.vercel.app · 병원 확인 항목 입력 infinith-demo.vercel.app/supporters/wonjin

+
+
+

자동 생성한 글 6편

+ + +
분류제목근거 영상병원 확인 대기상태
A원진성형외과는 어떤 병원인가요? 진료 분야·의료진 구성·위치0편5건의학 검토 대기
B원진성형외과 방문 안내: 위치·진료시간·주차·예약은 어떻게 하나요?0편3건의학 검토 대기
D원진성형외과 윤곽은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?0편5건의학 검토 대기
D원진성형외과 눈성형은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?3편5건의학 검토 대기
D원진성형외과 레이저안티에이징은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?0편6건의학 검토 대기
C상담에서 꼭 물어봐야 할 질문은 무엇인가요?3편3건의학 검토 대기
+

발견한 채널과 확인 결과

+

홈페이지 역링크, 프로필 제목·설명, 등록부, 검색 결과를 근거로 병원 것인지 판정했습니다. "확인 필요"는 병원이 맞다고 알려 주시면 연결합니다.

+ + +
플랫폼확인됨확인 필요
유튜브WJ원진성형외과 (구독 28,300)
WJ원진성형외과 (구독 14,700)
Wonjin Plastic Surgery Korea
유세윤의 광고100 X 원진성형외과
WONJINPS
WONJIN_OFFICIAL - KOREA No.1 PLASTIC SURGERY
원트성형외과
인스타그램Instagram
Instagram
Instagram
Instagram
Instagram
Instagram
Instagram
Instagram
Instagram
Instagram
Instagram
Instagram
페이스북KwonjinPS
네이버 블로그popokpopnaramalssami1
qeqe0420
vpepwxormtfo
틱톡wonjin_official
카카오톡 채널원진성형외과의원
강남언니원진성형외과의원 | 강남언니 - 내 선택에 확신을
네이버 플레이스찾지 못함
바비톡원진성형외과의원 | 후기∙가격∙이벤트 정보 - 바비톡
+

병원이 확인해 주실 것 9가지

+

홈페이지 공개 자료에 없어 병원만 답할 수 있는 값입니다. 입력 전까지 사이트에는 "확인 대기"로 표시됩니다.

+
1 작성자 실명·직함·공개 프로필
2 글별 의학 검토 원장과 검토일
3 지원 관계 문구
4 견적 기본 포함 항목·결제 방식
5 회복 일정표의 병원 확정값
6 재수술 정책
7 페이지 간 표기 불일치의 정답
8 진료 분야별 담당 원장
9 정식 도메인·색인 허용 시점·홈페이지 링크 위치
+

싣지 않는 것: 전후 사진, 환자 경험담, 다른 병원과의 비교, 효과 보장, 가격 금액. 이미지는 의료법 기준 규칙에 따라 전후·사례·모델·수술 장면·인증 마크를 자동 제외했습니다.

+
+ +
+
Integrity

근거 없는 것은 약속하지 않습니다

+ + +
하지 않는 것이유
llms.txt 제작Google 공식(2026-06) 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없음. 위약이다.
"스키마 넣으면 AI가 인용합니다" 약속인과 증거 없음. 스키마는 엔티티 식별을 돕는 위생 조치로만 설명한다.
단발 조회로 순위·노출 보고LLM 답변 분산 10~34%. 반복 측정 + 신뢰구간으로만 보고한다.
답변엔진 UI 자동 조회OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.
네이버 블로그 증량blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙.
+

측정 조건

+
  • 실측 대상: 메인 페이지 + /contents/eye/cfs 2개 URL, JS 미실행 HTML 기준
  • 크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청
  • semi/manual 항목은 자동 수집 증거만 첨부하고 미검증 처리, 분모 제외 (사람 보정 후 확정)
  • 답변엔진 언급률은 측정하지 않았다 (단발 조회는 무효, 반복 측정은 별도 기준선 단계에서 수행)
+

측정 가능 범위

+

무엇을 어떤 방법으로 재고, 무엇은 잴 수 없는지 먼저 밝힙니다. 잴 수 없는 것은 숫자를 만들지 않고 비워 둡니다.

+ + + + + + +
재는 것방법가능비고
AI 답변 준비도 · 출처 준비도공개 페이지 실측(크롤러·렌더링·스키마·표면)가능오너 계정이 필요한 항목(GA4·GSC·서버 로그)은 미검증으로 표시
답변엔진 언급률 · 감성질문 뱅크를 공식 API로 반복 조회가능ChatGPT · Perplexity 기본, 단발 조회는 쓰지 않음. 소비자 화면 자동 조회는 약관 위반이라 하지 않음
ChatGPT 유입GA4 세션 출처 · utm가능링크에 utm_source=chatgpt.com 을 붙여 보내므로 리퍼러가 비어도 잡힌다.
Perplexity 유입GA4 세션 출처 · utm일부만리퍼러를 자주 비운다. 잡히는 건수는 실제 유입의 일부다. 실측 언급률과 함께 읽는다.
Gemini 유입GA4 세션 출처 · utm가능gemini.google.com 리퍼러로 분리된다.
Copilot 유입GA4 세션 출처 · utm일부만Bing 검색과 리퍼러가 섞이는 경우가 있다.
Claude 유입GA4 세션 출처 · utm가능claude.ai 리퍼러.
Google AI Overview · AI Mode 유입GA4 세션 출처 · utm불가구글 자연검색(google / organic)과 같은 리퍼러라 GA4로는 구분되지 않는다. 타사 도구의 'AI Overview 유입'도 추정치다. 가시성은 답변엔진 실측으로만 잰다.
네이버 AI 브리핑 유입GA4 세션 출처 · utm불가네이버 검색 리퍼러(search.naver.com)와 구분되지 않는다. 네이버 서치어드바이저 유입 키워드로 간접 확인만 가능.
기타 AI 검색 유입GA4 세션 출처 · utm일부만리퍼러가 있을 때만 잡힌다.
AI 크롤러 방문서버 로그(UA · IP 대역)조건부서포터즈 사이트는 로그로 계측 가능. 병원 사이트는 로그 접근 권한이 있을 때만
전화 · 오프라인 방문-불가사이트의 전화 탭 · 예약 클릭까지만 잼
+

채점 방법

+

기준표 v1.0 36개 실측 항목(A 접근성 · B 엔티티 · C 콘텐츠 · D 표면 · E 측정 · F 위생)을 0~3 레벨로 판정하고, v2.0은 그 항목을 답변 준비도(AEO) 8개와 출처 준비도(GEO) 8개로 묶어 각 100점으로 정규화합니다. 미검증 항목은 분모에서 빼고, 치명 게이트(크롤러 차단 등)에 걸리면 상한을 둡니다. 답변엔진 언급률은 단발 조회로는 재지 않습니다.

+
+ +
INFINITH · AI오투오 · 문의 o2oteam@o2o.kr · 이 리포트는 공개 페이지 실측에 근거하며, 오너 계정(GA4·GSC·서버 로그)이 필요한 항목은 미검증으로 두었습니다. 법률 자문이 아닙니다.
+ \ No newline at end of file diff --git a/docs/reports/wonjin/01_readiness_report/Wonjin_AI_Answer_Readiness_Report_v2.1_2026-09-08.pdf b/docs/reports/wonjin/01_readiness_report/Wonjin_AI_Answer_Readiness_Report_v2.1_2026-09-08.pdf new file mode 100644 index 0000000..b53e195 Binary files /dev/null and b/docs/reports/wonjin/01_readiness_report/Wonjin_AI_Answer_Readiness_Report_v2.1_2026-09-08.pdf differ diff --git a/scripts/build_discovery_report.ts b/scripts/build_discovery_report.ts index 58553d0..1433f1c 100644 --- a/scripts/build_discovery_report.ts +++ b/scripts/build_discovery_report.ts @@ -9,6 +9,7 @@ * 화법: 고객용. 판단 제목은 "~합니다", 목차성 제목은 명사구. 엠대시 없음. 숫자는 표와 배지에만. */ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import AI_CHANNELS from '../data/ai_channels.json'; import { DISCOVERY_RESULTS } from '../src/data/discoveryResults'; import { AEO_GEO_RUBRIC } from '../src/data/aeoGeoRubric'; import { AEO_GEO_RUBRIC_V2 } from '../src/data/aeoGeoRubricV2'; @@ -184,6 +185,15 @@ ${status ? `

측정 조건

    ${result.conditions.map((c) => `
  • ${esc(c)}
  • `).join('')}
+

측정 가능 범위

+

무엇을 어떤 방법으로 재고, 무엇은 잴 수 없는지 먼저 밝힙니다. 잴 수 없는 것은 숫자를 만들지 않고 비워 둡니다.

+ + + + ${(AI_CHANNELS as any).channels.map((c: any) => ``).join('')} + + +
재는 것방법가능비고
AI 답변 준비도 · 출처 준비도공개 페이지 실측(크롤러·렌더링·스키마·표면)가능오너 계정이 필요한 항목(GA4·GSC·서버 로그)은 미검증으로 표시
답변엔진 언급률 · 감성질문 뱅크를 공식 API로 반복 조회가능ChatGPT · Perplexity 기본, 단발 조회는 쓰지 않음. 소비자 화면 자동 조회는 약관 위반이라 하지 않음
${esc(c.label)} 유입GA4 세션 출처 · utm${c.measurable === 'yes' ? '가능' : c.measurable === 'partial' ? '일부만' : '불가'}${esc(c.note)}
AI 크롤러 방문서버 로그(UA · IP 대역)조건부서포터즈 사이트는 로그로 계측 가능. 병원 사이트는 로그 접근 권한이 있을 때만
전화 · 오프라인 방문-불가사이트의 전화 탭 · 예약 클릭까지만 잼

채점 방법

기준표 v1.0 ${total}개 실측 항목(A 접근성 · B 엔티티 · C 콘텐츠 · D 표면 · E 측정 · F 위생)을 0~3 레벨로 판정하고, v2.0은 그 항목을 답변 준비도(AEO) ${v2.aeo.items.length}개와 출처 준비도(GEO) ${v2.geo.items.length}개로 묶어 각 100점으로 정규화합니다. 미검증 항목은 분모에서 빼고, 치명 게이트(크롤러 차단 등)에 걸리면 상한을 둡니다. 답변엔진 언급률은 단발 조회로는 재지 않습니다.

diff --git a/scripts/ga4_ai_traffic.py b/scripts/ga4_ai_traffic.py new file mode 100644 index 0000000..dc59867 --- /dev/null +++ b/scripts/ga4_ai_traffic.py @@ -0,0 +1,149 @@ +#!/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 --clinic wonjin --out ... # API 없이 (열: date, sessionSource, sessionMedium, landingPage, sessions, keyEvents) + python3 scripts/ga4_ai_traffic.py --mock --clinic demo --out /tmp/ga4demo # 합성 데이터로 파이프라인 검증 + +산출: /ai_traffic.json (일별·채널별·랜딩별 집계) + /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}") diff --git a/supporters/scripts/template/data/site.json b/supporters/scripts/template/data/site.json index 2ffba6b..c6e9fa2 100644 --- a/supporters/scripts/template/data/site.json +++ b/supporters/scripts/template/data/site.json @@ -21,5 +21,7 @@ "visitReads": [], "newsNote": "", "newsOutletsExample": "", - "sponsorNotice": "" + "sponsorNotice": "", + "ga4MeasurementId": "", + "_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다." } diff --git a/supporters/src/layouts/Base.astro b/supporters/src/layouts/Base.astro index 1ace83c..26f36fd 100644 --- a/supporters/src/layouts/Base.astro +++ b/supporters/src/layouts/Base.astro @@ -16,6 +16,8 @@ const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon. const site = Astro.site?.toString().replace(/\/$/, '') ?? ''; const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); +// GA4: site.json 의 ga4MeasurementId(G-XXXX) 가 있을 때만 태그를 넣는다. 키 이벤트는 data/ai_channels.json conversion_events 와 같은 이름(click_tel · click_reservation · click_clinic_site · click_map). +const GA4: string = /^G-[A-Z0-9]{6,}$/.test(String((S as Record).ga4MeasurementId ?? '')) ? String((S as Record).ga4MeasurementId) : ''; const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null; --- @@ -39,6 +41,9 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array {ld && } + {GA4 &&