Compare commits
4 Commits
4e73798cce
...
5d3ce40f77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3ce40f77 | ||
|
|
b2caef9d02 | ||
|
|
501d6002c6 | ||
|
|
8776a15183 |
34
data/ai_channels.json
Normal file
34
data/ai_channels.json
Normal file
@ -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']" }
|
||||
]
|
||||
}
|
||||
57
docs/INFINITH_GA4_AI_Traffic_Integration.md
Normal file
57
docs/INFINITH_GA4_AI_Traffic_Integration.md
Normal file
@ -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 <ID> --days 90 --clinic <id> --out docs/reports/<id>/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에.
|
||||
104
docs/NEXT_SESSION_2026-09-09.md
Normal file
104
docs/NEXT_SESSION_2026-09-09.md
Normal file
@ -0,0 +1,104 @@
|
||||
# 다음 세션 핸드오버 · 2026-09-09
|
||||
|
||||
**2026-09-09 진행분은 §2 각 항목의 상태 표시를 본다.** §2-1~4·6 완료, 남은 것은 §2-5(GA4 실연결)·§2-7 과 2차 보정이다.
|
||||
|
||||
작성 2026-09-08 · 작성자 Claude (haewon 세션 핸드오버). 자동 빌드 전체 상태는 `docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md`(§1·§2·§3 갱신됨)를 본다. 이 문서는 09-08에 새로 시작한 일(감성 분석·GA4·가격)과 내일 첫 할 일만 다룬다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 세션 시작 시 붙여 넣을 프롬프트
|
||||
|
||||
```
|
||||
INFINITH 저장소. 자동 빌드 코드는 워크트리 ~/orca/workspaces/INFINITH/Productize (브랜치 feature/supporters-autobuild), 감성 분석 산출물은 메인 체크아웃 ~/Claude/Agentic Marketing/INFINITH 에 있다.
|
||||
먼저 docs/NEXT_SESSION_2026-09-09.md 를 읽고 §2 순서대로 진행한다. 자동 빌드 잔여 과제는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md §2.
|
||||
원칙: 근거 없는 사실은 생성하지 않는다. 사람 게이트는 자동화하지 않는다. 잴 수 없는 것은 숫자를 만들지 않고 '측정 불가'로 적는다. 존댓말, 엠대시 금지, 은유 금지. 결정이 필요한 것은 §3을 보고 haewon 에게 묻는다.
|
||||
```
|
||||
|
||||
## 1. 09-08에 끝낸 것
|
||||
|
||||
| 일 | 위치 | 상태 |
|
||||
|---|---|---|
|
||||
| 뉴스룸 워커 단계 + 뉴스 게이트 규칙 | Productize, 커밋 484601b·4e73798, 푸시·infinith-demo 배포 완료 | 뷰 회귀 1,105건(수작업 1,096), 원진 1,049건 반영·재배포 |
|
||||
| launchd 폴러 설치 | `~/Library/LaunchAgents/com.infinith.supporters-poll.plist`, 5분 간격, 로그 `~/supporters-builds/poll.log` | 큐가 비어 있어 실제 신청 1건 완주는 아직 못 봄 |
|
||||
| FrostAI 비교 + 가격 제안 v0.1 | 메인 체크아웃 `docs/INFINITH_vs_FrostAI_비교_및_가격제안_2026-09-08.html` | haewon 검토 대기 |
|
||||
| AI 답변 감성 판정 | 메인 체크아웃 `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.1.md`, `scripts/sentiment_qb.py`, 결과 `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md` | 뷰 240건 판정 완료(언급 84, 감성 53.6, 부정 15.5%, 규제 언급률 6.0%). **보정 라벨링 진행 중** |
|
||||
| GA4 AI 유입 연동 뼈대 | Productize `data/ai_channels.json`, `scripts/ga4_ai_traffic.py`, `supporters/src/layouts/Base.astro` 태그, `docs/INFINITH_GA4_AI_Traffic_Integration.md` | 합성 데이터로 검증. 실제 속성 연결은 GCP 계정 결정 후 |
|
||||
| 리포트 "측정 가능 범위" 절 | Productize `scripts/build_discovery_report.ts` Integrity 절, 원진 리포트 v2.1 HTML·PDF | 감성·GA4 리포트에도 같은 절 자동 포함 |
|
||||
|
||||
**미커밋 상태** (haewon 지시 없이 커밋하지 않았다)
|
||||
- Productize: `data/ai_channels.json`, `scripts/ga4_ai_traffic.py`, `scripts/build_discovery_report.ts`, `supporters/src/layouts/Base.astro`, 템플릿 site.json 2곳(`ga4MeasurementId`), `docs/INFINITH_GA4_AI_Traffic_Integration.md`, `docs/reports/wonjin/01_readiness_report/*v2.1*`, 이 문서.
|
||||
- 메인 체크아웃: `scripts/sentiment_qb.py`, `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.1.md`, `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment*.{md,xlsx}`, `scripts/out/qb_sentiment_*.jsonl`, `docs/INFINITH_vs_FrostAI_*.html`. 메인 체크아웃에는 09-08 이전부터 있던 무관한 수정(plan 컴포넌트 등)도 섞여 있으니 감성 파일만 골라 커밋한다.
|
||||
|
||||
## 2. 내일 순서
|
||||
|
||||
> 진행 상태 (2026-09-09 갱신): 1 완료 · 2 완료 · 3 완료 · 4 완료 · 5 미착수(§3-2 대기) · 6 완료(푸시 전) · 7 미착수
|
||||
|
||||
|
||||
1. **보정 라벨링 마무리** (haewon). 파일 `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx`, 시트 `라벨링` 30줄. D열 답변 전문을 읽고 F열(positive/neutral/negative/mixed/not_mentioned), 부정·혼합이면 G열 종류, 애매하면 H열 메모. 판정기 값은 숨김 시트에 있으니 보지 않는다.
|
||||
- 알려진 사례: **G-08(openai)** 은 영문 약칭 "View" 하나만 있음 → not_mentioned 로 볼지 haewon 판단. **G-04(openai)** 는 판정기 오판(뷰 언급 없음). **C-01(openai)** 은 "View Plastic Surgery" 영문 정식 명칭.
|
||||
2. **일치율 계산**: `python3 scripts/sentiment_qb.py --agreement docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx`. sentiment 일치 80% 이상이면 기준표 v0.1 확정. 미만이면 불일치 줄을 기준표 §2 규칙 예시로 추가하고 240건 재판정(`--out` 파일을 지우고 다시 실행, 약 $1).
|
||||
3. **기준표 확정 사항 반영**: 약한 언급(한글 상호 없이 영문 약칭만, 3건) 포함 여부, mixed 25점 유지 여부를 기준표 §6에 적고 리포트 재생성. 감성 결과를 `docs/reports/viewclinic/03_question_bank/` 엑셀·POC 부록에 넣을지 결정.
|
||||
4. **감성 판정을 월간 실측에 붙이기**: 질문 뱅크 러너 결과가 나오면 자동으로 `sentiment_qb.py` 를 이어 돌리는 한 줄(워커 별도 단계 또는 cron). 결과를 `supporter_builds.report.sentiment` 에 저장해 리포트 카드에 표시.
|
||||
5. **GA4 실연결**: §3-2 계정이 정해지면 서비스 계정 키 발급 → `.env` `GA4_SERVICE_ACCOUNT_JSON` → 서포터즈 사이트용 GA4 속성 생성 → 원진 `site.json` 에 `ga4MeasurementId` → 재빌드. 쿠키 배너 결정(§3-3) 전에는 미리보기에 ID를 넣지 않는다.
|
||||
6. **커밋**: 위 미커밋 목록을 두 체크아웃에서 각각 커밋(감성 파일은 메인 체크아웃에서 골라서). 푸시 후 `vercel --prod` 는 프론트 변경이 있을 때만.
|
||||
7. 시간이 남으면 v3 §2의 감사 재실행 단계(Phase 9), 원진 수동 실측(강남언니·바비톡·GBP·플레이스 NAP).
|
||||
8. (2026-09-09 추가) 질문 뱅크 러너 병원별 매개변수화. `run_question_bank_openai.py` 가 `question_bank_viewclinic.json` 과 출력 경로를 하드코딩해 뷰성형외과 전용이다. 월간 체인을 원진 등 다른 병원에 쓰려면 여기가 먼저다.
|
||||
|
||||
### 2026-09-09 결과 요약
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 보정 라벨링 | haewon 29/30 완료. G-08(openai)만 공란 |
|
||||
| 1차 일치율 | sentiment 22/29 = **75%**, 기준선 80% 미달. negative_type 은 G열 미기입으로 **측정 불가** |
|
||||
| 기준표 | v0.1 → **v0.3**(메인 체크아웃 `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.3.md`). 규칙 R1~R5 추가 |
|
||||
| 철회한 규칙 | R4-a·R6 시험 투입 후 철회. 일치율이 93%에서 89%로 떨어졌다. n=29 에서 과적합 |
|
||||
| 언급 탐지기 | 결정론적 `mention_kind()` 신설. URL 안의 "view", "리뷰"의 "뷰" 오탐 제거, 한글 상호 공백 허용. 언급 84 → 83, 약한 언급 3 → 1 |
|
||||
| 감성 점수 | 53.6 → **58.4** (약한 언급 1건 제외 시 57.9). mixed 10 → 0 |
|
||||
| 월간 체인 | `scripts/monthly_ai_measure.py` 신설(§2-4). 러너 → 감성 → 리포트 → `supporter_builds.report.sentiment` |
|
||||
| API 비용 | 총 $1.70 (v0.2 재판정 $0.79 + R4-a·R6 시험 $0.91). 최종본은 재호출 없이 후처리로 만들었다 |
|
||||
| 커밋 | 메인 체크아웃 `feature/ai-sentiment-rubric` 2eb3d5c · Productize 8776a15(GA4)·501d600(PRD v0.2) |
|
||||
|
||||
**아직 확정이 아니다.** 93%는 같은 표본으로 규칙을 고쳐 나온 값이다. 확정하려면 새 30건으로 2차 보정을 해야 하고, 1차 표본 30건은 제외한다. 리포트와 요약 JSON 에 `confirmed: false` 로 적어 두었다.
|
||||
|
||||
**메인 체크아웃이 브랜치에 있다.** `feature/ai-sentiment-rubric` 이며 main 이 아니다. main 으로 합치려면 그 체크아웃에서 `git checkout main && git merge feature/ai-sentiment-rubric` 을 한다. 두 체크아웃 모두 **푸시하지 않았다.**
|
||||
|
||||
## 3. haewon 결정 대기
|
||||
|
||||
1. 감성 기준표(2026-09-09 갱신): mixed 는 R2 적용 후 240건 중 0건이라 판정값에서 뺄지 정한다. 약한 언급은 오탐을 걷어내니 **1건**(G-08 openai)뿐이며 현재는 세고 표시해 두었다. 부정의 범위(규제 사실 서술을 negative 로)와 경쟁 노출 지표는 그대로 미결. 남은 불일치 2건(C-12 perplexity, A-03 openai)은 2차 보정에서 새 표본으로 본다.
|
||||
- 2차 보정을 언제 돌릴지도 결정 대상이다. 새 30건 추출 → haewon 라벨 → `--agreement`. 1차 표본 30건은 제외한다.
|
||||
2. GA4 서비스 계정을 만들 Google Cloud 계정(이전 재인증 이슈 계정 회피 여부).
|
||||
3. 서포터즈 사이트 쿠키 고지 배너 vs 쿠키 없는 대안(서버 로그·Vercel Web Analytics).
|
||||
4. 가격 제안 v0.1(문서 §5의 5개 항목: 무료 진단 범위, 최소 계약 3개월, 도메인 귀속, Frost 우위 3종 대응, 가격 공개 여부).
|
||||
5. 뉴스룸 "언론 언급" 종류 노출 여부(v3 §3-6).
|
||||
6. 자동 빌드 잔여 결정은 v3 §3.
|
||||
|
||||
## 4. 함정 (09-08 추가)
|
||||
|
||||
- 프레임워크 Python 3.13은 인증서 저장소가 비어 `CERTIFICATE_VERIFY_FAILED` 가 난다. 새 스크립트는 certifi 컨텍스트를 쓴다(`sentiment_qb.py` 참고).
|
||||
- 판정기의 `mentioned` 는 영문 약칭에 관대하다. 한글 상호 정규식으로 `weak_mention` 을 따로 표시하고 보정 때 결정한다.
|
||||
- `sentiment_qb.py --summary` 로 보정 엑셀을 다시 만들면 **haewon 라벨이 지워진다**. 라벨링 시작 후에는 `--calibration` 을 빼고 리포트만 다시 만든다. (2026-09-09: `--calibration` 없이 돌리면 §7 생성에서 죽던 버그를 고쳤다. 이제 그냥 빼고 돌리면 된다)
|
||||
- (2026-09-09) 감성 판정을 다시 돌릴 때는 `--out` 파일을 **옮기거나 지워야** 한다. 이어쓰기가 있어 그냥 실행하면 0건 처리하고 끝난다. `monthly_ai_measure.py --rejudge` 가 이 처리를 대신한다.
|
||||
- (2026-09-09) 병원 언급 탐지는 판정기(LLM)에 맡기지 않는다. URL 안의 상호와 "리뷰"의 "뷰" 를 언급으로 세면 모든 비율의 분모가 틀어진다. `mention_kind()` 를 먼저 통과시킨다.
|
||||
- 백그라운드 파이썬 로그는 버퍼링 때문에 끝날 때까지 비어 보인다. 진행은 JSONL 줄 수로 본다.
|
||||
- JS 정규식 `\W`·`\w`·`\b` 는 한글을 문자로 보지 않는다(v3 §5). 워드프레스 게시판 메타 날짜는 게시판 생성일이다.
|
||||
|
||||
## 5. 자주 쓰는 명령
|
||||
|
||||
```
|
||||
# 월간 실측 한 번에 (메인 체크아웃, 2026-09-09 신설)
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 # 러너부터 전부
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --skip-run # 기존 답변으로 감성만
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --rejudge # 기준표 고친 뒤 재판정
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --build-id <uuid> # Supabase 반영까지
|
||||
# 감성 판정 개별 (메인 체크아웃)
|
||||
python3 scripts/sentiment_qb.py --in scripts/out/qb_openai_results.jsonl --out scripts/out/qb_sentiment_openai.jsonl
|
||||
python3 scripts/sentiment_qb.py --summary scripts/out/qb_sentiment_openai.jsonl scripts/out/qb_sentiment_perplexity.jsonl --report docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md # 라벨링 중에는 --calibration 금지
|
||||
python3 scripts/sentiment_qb.py --agreement docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx
|
||||
# GA4 (Productize)
|
||||
python3 scripts/ga4_ai_traffic.py --mock --clinic demo --out /tmp/ga4demo
|
||||
python3 scripts/ga4_ai_traffic.py --property <ID> --days 90 --clinic <id> --out docs/reports/<id>/05_ai_traffic
|
||||
# 진단 리포트 (Productize)
|
||||
npx esbuild scripts/build_discovery_report.ts --bundle --platform=node --format=esm --outfile=/tmp/report.mjs && node /tmp/report.mjs --id <id> --out <html> --status ~/supporters-builds/<id>/status.json --site ~/supporters-builds/<id>/site --briefs ~/supporters-builds/<id>/briefs.json
|
||||
# 폴러 상태
|
||||
launchctl print gui/$(id -u)/com.infinith.supporters-poll | head -12 ; tail -20 ~/supporters-builds/poll.log
|
||||
```
|
||||
298
docs/prd/INFINITH_Global_Medical_Hub_PRD_v0.2.html
Normal file
298
docs/prd/INFINITH_Global_Medical_Hub_PRD_v0.2.html
Normal file
@ -0,0 +1,298 @@
|
||||
<!doctype html>
|
||||
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>INFINITH Global Medical Hub PRD v0.2</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||
<style>
|
||||
:root{--navy:#0A1128;--navy800:#1A2B5E;--near:#F4F6FB;--accent:#6C5CE7;--g1:#4F1DA1;--g2:#021341;--slate7:#334155;--slate6:#475569;--slate5:#64748B;--line:#E2E8F0;
|
||||
--crit-bg:#FFF0F0;--crit-tx:#7C3A4B;--crit-bd:#F5D5DC;--crit-dot:#D4889A;--warn-bg:#FFF6ED;--warn-tx:#7C5C3A;--warn-bd:#F5E0C5;--warn-dot:#D4A872;--good-bg:#F3F0FF;--good-tx:#4A3A7C;--good-bd:#D5CDF5;--good-dot:#9B8AD4;--info-bg:#EFF0FF;--info-tx:#3A3F7C;--info-bd:#C5CBF5;--info-dot:#7A84D4}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:Pretendard,Inter,-apple-system,sans-serif;color:var(--navy);background:#fff;line-height:1.65;font-size:15px;word-break:keep-all}
|
||||
.serif{font-family:'Playfair Display',serif}
|
||||
section{padding:96px 24px}.wrap{max-width:1160px;margin:0 auto}
|
||||
.dark{background:var(--navy);color:#fff;position:relative}.dark::before{content:"";position:absolute;inset:0;background:radial-gradient(ellipse at top right,rgba(108,92,231,.15),transparent 60%);pointer-events:none}.dark .wrap{position:relative}
|
||||
.light{background:#fff}.near{background:var(--near)}
|
||||
.eyebrow{font-family:Inter,sans-serif;font-size:12px;font-weight:600;letter-spacing:.24em;text-transform:uppercase;color:var(--accent)}.dark .eyebrow{color:#C4B5FD}
|
||||
h1{font-size:56px;line-height:1.05;margin:14px 0 10px;letter-spacing:-.02em}h2{font-size:38px;line-height:1.15;margin:10px 0 14px}h3{font-size:18px;font-weight:700;margin:0 0 8px}
|
||||
.dark h2{background:linear-gradient(90deg,#C4B5FD,#93C5FD);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.sub{font-size:17px;color:var(--slate6);max-width:820px}.dark .sub{color:#DDD6FE}
|
||||
p{margin:0 0 10px}.muted{color:var(--slate5);font-size:13.5px}.dark .muted{color:#C4B5FD}
|
||||
.grid{display:grid;gap:18px}.g2{grid-template-columns:repeat(2,1fr)}.g3{grid-template-columns:repeat(3,1fr)}.g4{grid-template-columns:repeat(4,1fr)}
|
||||
@media(max-width:900px){.g2,.g3,.g4{grid-template-columns:1fr}}
|
||||
.card{background:#fff;border:1px solid #F1F5F9;border-radius:18px;padding:22px 24px;box-shadow:3px 4px 12px rgba(0,0,0,.06);color:var(--navy)}.card:hover{box-shadow:4px 6px 16px rgba(0,0,0,.09)}
|
||||
.card p{color:var(--slate7);font-size:14.5px}
|
||||
.grad{background:linear-gradient(90deg,var(--g1),var(--g2));color:#fff;border:0}.grad p,.grad .muted{color:#DDD6FE}.grad h3{color:#fff}
|
||||
.cta{background:linear-gradient(to right,#fff3eb,#e4cfff,#f5f9ff);border:0}
|
||||
.badge{display:inline-block;font-family:Inter,sans-serif;font-size:11.5px;font-weight:600;border:1px solid;border-radius:999px;padding:3px 10px;line-height:1.3}
|
||||
.b-crit{background:var(--crit-bg);color:var(--crit-tx);border-color:var(--crit-bd)}.b-warn{background:var(--warn-bg);color:var(--warn-tx);border-color:var(--warn-bd)}.b-good{background:var(--good-bg);color:var(--good-tx);border-color:var(--good-bd)}.b-info{background:var(--info-bg);color:var(--info-tx);border-color:var(--info-bd)}
|
||||
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px;vertical-align:middle}
|
||||
table{width:100%;border-collapse:collapse;background:#fff;border-radius:14px;overflow:hidden;font-size:13.5px;box-shadow:3px 4px 12px rgba(0,0,0,.05)}th{background:var(--navy);color:#fff;text-align:left;padding:11px 13px;font-size:12.5px;font-weight:600}td{padding:11px 13px;border-bottom:1px solid #EEF2F7;vertical-align:top;color:var(--slate7)}tr:last-child td{border-bottom:0}td b{color:var(--navy)}
|
||||
.dark table{color:var(--navy)}
|
||||
.kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.kpi .k{background:#fff;border-radius:16px;padding:18px 20px;box-shadow:3px 4px 12px rgba(0,0,0,.06)}.kpi b{display:block;font-family:'Playfair Display',serif;font-size:34px;color:var(--accent);line-height:1}.kpi span{font-size:12.5px;color:var(--slate5)}
|
||||
@media(max-width:900px){.kpi{grid-template-columns:repeat(2,1fr)}}
|
||||
.flow{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;counter-reset:s}.flow .st{background:#fff;border-radius:14px;padding:14px;box-shadow:3px 4px 12px rgba(0,0,0,.06);color:var(--navy);font-size:13px}.flow .st::before{counter-increment:s;content:counter(s);display:inline-block;font-family:'Playfair Display',serif;font-weight:700;color:var(--accent);font-size:20px;margin-bottom:4px}
|
||||
@media(max-width:900px){.flow{grid-template-columns:repeat(2,1fr)}}
|
||||
ul{margin:6px 0 0 18px;padding:0}li{margin:4px 0;color:var(--slate7)}.dark li{color:#E9E5FF}
|
||||
.pill{display:inline-block;background:#F1F5F9;color:var(--slate6);border-radius:999px;padding:2px 9px;font-size:11.5px;font-family:Inter,sans-serif;font-weight:500;margin:0 4px 4px 0}
|
||||
.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--accent)}
|
||||
.toc a{color:var(--accent);text-decoration:none}.toc li{color:var(--slate6)}
|
||||
footer{padding:36px 24px;font-size:12.5px;color:var(--slate5);border-top:1px solid var(--line);text-align:center}
|
||||
@media print{@page{size:A4;margin:11mm}section{padding:26px 6px;page-break-inside:auto}.pb{page-break-before:always}h1{font-size:40px}h2{font-size:28px}.card,tr{page-break-inside:avoid}.dark,.grad,.badge,.kpi .k,.cta,th{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
|
||||
</style></head><body>
|
||||
|
||||
<!-- 0. Cover -->
|
||||
<section class="dark" style="padding-top:120px;padding-bottom:88px"><div class="wrap">
|
||||
<div class="eyebrow">INFINITH · Product Requirements Document · v0.2 Draft</div>
|
||||
<h1 class="serif">Global Medical Hub</h1>
|
||||
<div style="font-size:24px;font-weight:700;margin-bottom:6px">외국인 환자가 AI에 물었을 때 인용되는, AIO2O 운영 중립 의료관광 허브</div>
|
||||
<p class="sub">한국 성형외과·피부과의 시술·의료진·회복·비용 항목을 다국어 원문으로 공급하고, 상담·예약과 회복기 여정(숙박·식사·관광·미용)을 한 곳에서 연결하는 플랫폼. 병원이 아니라 AIO2O가 소유·운영한다. 지금 완성된 AI Discovery 진단과 서포터즈 자동 빌드 파이프라인이 공급 쪽 절반을 이미 자동화했다.</p>
|
||||
<div class="kpi" style="margin-top:34px">
|
||||
<div class="k"><b>3</b><span>제품 층 (정보·발견 / 상담·예약 / 여정 연결)</span></div>
|
||||
<div class="k"><b>2</b><span>검증된 병원 (뷰성형외과 18/18 · 원진 6/6, 사람 개입 0)</span></div>
|
||||
<div class="k"><b>$0.6</b><span>병원 1곳 온보딩 실측 비용 (30분)</span></div>
|
||||
<div class="k"><b>8~12주</b><span>1단계 글로벌 허브 MVP</span></div>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:22px">작성 2026-09-08 · 작성 Claude (haewon 세션) · 검토 haewon · 결정 CEO · 배경: 2026-09-08 CEO·뷰성형외과 미팅 (원장의 의료관광 포털 제안) · 법 해석은 자문이 아니며 계약·등록 전 변호사 확인 필요<br>v0.2 개정 2026-09-09 · 앵커의 병원 소개 역량 반영 (§04 앵커 계약 조항, §05 소개 구조 확인, §08 소개 규모·CEO 결정, §10 앵커 의존 리스크·미결 2건)</p>
|
||||
</div></section>
|
||||
|
||||
<!-- TOC -->
|
||||
<section class="light" style="padding:56px 24px"><div class="wrap toc">
|
||||
<div class="eyebrow">Contents</div>
|
||||
<ol style="columns:2;gap:40px;margin:10px 0 0 18px;font-size:14.5px">
|
||||
<li><a href="#s1">배경과 문제</a></li><li><a href="#s2">제품 정의와 범위</a></li><li><a href="#s3">사용자와 핵심 여정</a></li><li><a href="#s4">중립성과 IP: 운영 구조 비교</a></li><li><a href="#s5">AIO2O가 신청·등록·갖춰야 하는 것</a></li><li><a href="#s6">기술 스택과 기능: 완료 / 부분 / 신규</a></li><li><a href="#s7">아키텍처와 데이터 흐름</a></li><li><a href="#s8">로드맵</a></li><li><a href="#s9">KPI와 측정</a></li><li><a href="#s10">리스크와 미결</a></li><li><a href="#s11">부록: 근거 자료</a></li>
|
||||
</ol>
|
||||
</div></section>
|
||||
|
||||
<!-- 1. 배경 -->
|
||||
<section id="s1" class="near"><div class="wrap">
|
||||
<div class="eyebrow">01 · Why Now</div><h2 class="serif">Why Now</h2>
|
||||
<p class="sub">병원 홈페이지와 네이버 자산은 글로벌 AI 답변에 닿지 않는다. 그 공백을 원문 매체로 채우는 것이 INFINITH의 처방이고, 외국인 환자 시장은 인용할 원문이 가장 부족한 곳이다.</p>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><span class="badge b-crit">진단 근거</span><h3 style="margin-top:10px">병원은 읽히지만 인용되지 않는다</h3><p>뷰성형외과 AEO 42 / GEO 56, 원진성형외과 AEO 15 / GEO 45. 크롤러 접근은 열려 있으나 정의문·FAQ·표·의료진 스키마가 없다. 원진 영문 사이트는 hreflang조차 없다.</p></div>
|
||||
<div class="card"><span class="badge b-warn">시장 신호</span><h3 style="margin-top:10px">병원이 먼저 포털을 원한다</h3><p>2026-09-08 CEO 미팅에서 뷰성형외과 원장이 성형외과·피부과를 모은 글로벌 의료관광 포털을 제안했다. 반응은 좋았으나 "비슷한 제안이 많다"는 말도 들었다. 차별점은 실측 진단과 인용되는 원문, 그리고 자동 온보딩이다.</p></div>
|
||||
<div class="card"><span class="badge b-good">준비된 것</span><h3 style="margin-top:10px">공급 쪽은 이미 자동이다</h3><p>이름 하나로 채널 확인, 근거 수집, 팩트 시트, 의료진, 영상, 이미지(의료법 필터), 글 6편, 발행 게이트, 미리보기까지 30분에 $0.6. 두 병원에서 사람 개입 없이 완주했다.</p></div>
|
||||
</div>
|
||||
<div class="card cta" style="margin-top:18px"><h3>우리가 채울 공백</h3><p style="color:var(--navy)">외국인이 ChatGPT·Perplexity·Gemini에 "best rhinoplasty clinic in Seoul for foreigners"라고 물었을 때 인용할 다국어 원문(시술 조건 표·회복 일정·의료진·안전 체계·비용 항목)이 오픈웹에 사실상 없다. 기존 앱(강남언니 글로벌·바비톡·크리에이트립)은 정보를 앱 안에 가두고, 병원 영문 사이트는 슬로건과 이미지다. 네이버 자산은 robots.txt로 AI 크롤러를 막아 기여도 0이다(2026-08-26 실측).</p></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 2. 제품 정의 -->
|
||||
<section id="s2" class="dark"><div class="wrap">
|
||||
<div class="eyebrow">02 · Product Definition</div><h2 class="serif">What We Build</h2>
|
||||
<p class="sub">세 층으로 나눈다. 첫 층은 우리가 만들고, 둘째 층은 자격을 갖춰 만들며, 셋째 층은 만들지 않고 연결한다.</p>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><span class="badge b-good">1층 · 만든다</span><h3 style="margin-top:10px">정보·발견</h3><p>병원별 다국어 원문 페이지(시술 조건 표·회복 일정·의료진·안전·비용 항목·FAQ), 병원 공통 허브(시술별 비교 없는 목록·질문별 답), AI 답변엔진·검색 인용 구조(MedicalClinic·Physician·Article·FAQPage·hreflang). 지금 파이프라인 그대로.</p></div>
|
||||
<div class="card"><span class="badge b-warn">2층 · 자격 후 만든다</span><h3 style="margin-top:10px">상담·예약</h3><p>병원 선택 → 상담 신청 → 통역 포함 예약 확정 → 결제·정산. 대가를 받는 순간 외국인환자 유치업자 등록이 필요하다. 1단계에서는 상담 신청을 병원에 직접 넘기고 수수료를 받지 않는다.</p></div>
|
||||
<div class="card"><span class="badge b-info">3층 · 연결한다</span><h3 style="margin-top:10px">회복기 여정</h3><p>숙박·식사·관광·축제·미용·배달은 제휴 링크와 파트너 API로 연결한다. 직접 판매하면 여행업·통신판매업 등록이 따라온다. 배달은 국내 앱이 외부 API를 열지 않아 정보 안내 수준.</p></div>
|
||||
</div>
|
||||
<div class="grid g2" style="margin-top:18px">
|
||||
<div class="card"><h3>제품이 아닌 것</h3><ul><li>병원 홈페이지를 대체하는 사이트. 홈페이지는 그대로 두고 sameAs로 같은 엔티티에 묶는다.</li><li>후기·전후 사진·병원 비교·가격 할인으로 설득하는 앱. 의료법 56조 2항이 막는 것은 처음부터 없다.</li><li>슈퍼앱. 예약·숙박·배달 시스템을 처음부터 짓지 않는다.</li><li>특정 병원의 사이트. 뷰성형외과는 앵커 파트너이지 소유자가 아니다.</li></ul></div>
|
||||
<div class="card"><h3>성공의 정의 (1단계)</h3><ul><li>병원 4곳 이상이 다국어 원문으로 발행되고 각 병원 개설자 승인이 데이터로 켜진다.</li><li>외국인 질문 뱅크(영·일·중 각 60문항) 실측에서 허브 인용률이 0에서 유의미하게 오른다. 소유 신호(서버 로그 AI 크롤러 히트, GA4 AI 유입)로 잰다.</li><li>상담 신청이 병원으로 전달되고 병원이 응답한 건수를 센다.</li></ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 3. 사용자 -->
|
||||
<section id="s3" class="light"><div class="wrap">
|
||||
<div class="eyebrow">03 · Users & Journeys</div><h2 class="serif">Who It Serves</h2>
|
||||
<div class="grid g4" style="margin-top:22px">
|
||||
<div class="card"><h3>외국인 환자</h3><p>일본·중국어권·동남아·영어권. AI와 검색에 질문 → 허브 원문 → 병원 페이지 → 상담 신청 → 방문·회복. 원하는 것은 조건(시간·마취·회복·비용 항목)과 안전, 그리고 의사소통.</p></div>
|
||||
<div class="card"><h3>병원 담당자</h3><p>온보딩 폼(v2 §6 9항목), 글별 검토자·검토일 입력, 상담 신청 응답. 기술 지식 없음. 지금 만든 `/supporters/:id` 패널이 시작점.</p></div>
|
||||
<div class="card"><h3>원장·개설자</h3><p>의학 검토, 게시 승인. 의료광고 주체. 승인 기록(승인자·직함·일시·글 버전)이 법적 근거가 된다.</p></div>
|
||||
<div class="card"><h3>AIO2O 운영자</h3><p>큐·워커 운영, 게이트 실패 처리, 이미지 육안 확인, "확인 필요" 채널 판정, 정정 접수. 비개발자가 운영할 수 있어야 한다.</p></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:18px"><h3>핵심 여정 (외국인 환자)</h3>
|
||||
<div class="flow" style="margin-top:10px">
|
||||
<div class="st">AI·검색에 질문. 허브의 질문형 글이 인용된다</div><div class="st">시술 조건 표·회복 일정·비용 항목을 언어별로 읽는다</div><div class="st">병원 페이지에서 의료진·안전 체계·확인일을 본다</div><div class="st">상담 신청(언어·희망 시술·일정). 1단계는 병원 직결</div><div class="st">예약 확정·통역·결제 (2단계, 유치업 등록 후)</div><div class="st">회복기 숙박·식사·관광 제휴 링크와 주의사항 안내 (3단계)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 4. 중립성과 IP -->
|
||||
<section id="s4" class="near"><div class="wrap">
|
||||
<div class="eyebrow">04 · Neutrality & IP</div><h2 class="serif">Ownership Structure</h2>
|
||||
<p class="sub">포털의 생명은 중립성이다. 한 병원의 계열 플랫폼에는 두 번째 병원이 들어오지 않는다. 파이프라인 IP는 AIO2O 사업 자체이므로 어떤 구조에서도 밖에 두지 않는다.</p>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:150px">기준</th><th>뷰성형외과 자회사</th><th>뷰 · AIO2O 합작법인</th><th>AIO2O 단독 + 뷰 앵커 파트너 (추천)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>다른 병원 참여</b></td><td><span class="dot" style="background:var(--crit-dot)"></span>어렵다. 경쟁 병원 계열사에 의료진·비용 항목을 올릴 이유가 없다</td><td><span class="dot" style="background:var(--warn-dot)"></span>뷰 지분이 보이면 같은 경계. 지분이 작고 공개돼 있으면 완화</td><td><span class="dot" style="background:var(--good-dot)"></span>문제없다</td></tr>
|
||||
<tr><td><b>의료광고·고지</b></td><td>자회사 매체가 모병원을 노출하면 경제적 이해관계 고지가 페이지마다 붙고 추천·보증 심사지침 대상</td><td>같다. 뷰 관련 페이지에 지분 관계 고지</td><td>병원별 지원 관계 고지만</td></tr>
|
||||
<tr><td><b>유치업 등록</b></td><td>의료기관도 유치 등록은 가능하나 타 병원 환자 중개는 별도 법인이 깔끔</td><td>합작법인이 유치업자로 등록하면 자연스럽다</td><td>AIO2O 법인이 등록 (2단계 전)</td></tr>
|
||||
<tr><td><b>자본·앵커</b></td><td>병원이 자본을 댄다</td><td>자본·첫 병원·의료 자문을 같이 얻는다</td><td>자본은 AIO2O 몫. 앵커는 계약으로</td></tr>
|
||||
<tr><td><b>IP·속도</b></td><td>파이프라인이 병원 소유가 된다</td><td>IP를 JV에 넣으면 사업이 JV에 묶인다. 의사결정 둘</td><td>IP·속도 모두 AIO2O</td></tr>
|
||||
<tr><td><b>브랜드 리스크</b></td><td>뷰의 2026-07 공정위 시정명령(후기 뒷광고) 이력이 그대로 붙는다</td><td>같은 이력이 JV 브랜드에 묻는다</td><td>파트너 병원 중 하나의 이력</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="grid g2" style="margin-top:18px">
|
||||
<div class="card grad"><h3>추천 구조</h3><ul style="color:#E9E5FF"><li>플랫폼·파이프라인·게이트·데이터 모델 IP는 AIO2O 100%. 라이선스도 넘기지 않는다.</li><li>뷰성형외과는 앵커 파트너 계약: 첫 병원 무상 온보딩, 다국어 첫 발행, 병원 2~4곳 소개, 의료 자문. 대가는 소액 지분(10% 미만, 공개) 또는 소개 수수료.</li><li>예약 수수료를 받는 2단계에서 유치업 전용 법인이 필요하면 그 법인만 합작 검토. 자본금·배상보험을 나눠 지고 플랫폼 IP는 밖에 둔다.</li></ul></div>
|
||||
<div class="card"><h3>합작을 하게 된다면 계약 필수 조항</h3><ul><li>IP 귀속: 파이프라인·게이트·데이터 모델은 AIO2O 소유, JV는 사용권.</li><li>뷰 지분 상한과 중립성 조항: 노출 순서·비교 금지, 타 병원 정보 접근 제한.</li><li>의료광고 책임 분리: 각 병원 개설자가 광고 주체, JV는 매체·대행. 사전심의·유치 광고 규정 준수는 병원 책임.</li><li>공정위 이력에 대한 표명·보증. JV 이름에 병원명을 넣지 않는다.</li><li>데이터: 병원 자산 사용 허락 범위, 환자 데이터 처리 주체.</li></ul></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:18px;border-color:var(--info-bd)">
|
||||
<span class="badge b-info"><span class="dot" style="background:var(--info-dot)"></span>v0.2 추가 · 2026-09-09</span>
|
||||
<h3 style="margin-top:10px">앵커의 병원 소개 역량이 큰 경우</h3>
|
||||
<p><b>전달 사항(미확인).</b> 2026-09-09 haewon 전달. 뷰성형외과 원장이 다른 병원 네트워크·파트너십이 많아 병원을 유치할 수 있다고 밝혔다. 병원 명단·수·과목·지역·현재 관계 단계는 확인되지 않았다. 이 문서는 이를 사실이 아니라 확인 대상으로 둔다.</p>
|
||||
<p><b>구조 판단은 바뀌지 않는다.</b> 소개는 계약으로 확보할 수 있다. IP 귀속과 중립성 요건은 소개 규모와 무관하다. 위 비교표의 "다른 병원 참여" 행은 자회사·합작법인 구조에 대한 판단이며, 추천 구조는 이미 앵커가 병원을 소개하는 것을 전제로 한다(§07 입력 경로, §08 1단계). 소개 역량이 크다면 바뀌는 것은 구조가 아니라 <b>소개 목표 수치와 그 대가</b>다.</p>
|
||||
<div class="grid g2" style="margin-top:14px">
|
||||
<div>
|
||||
<h3>앵커 계약에 넣을 조항 (신규)</h3>
|
||||
<ul>
|
||||
<li>소개 목표를 수치로: 1단계 소개 N곳, 계약 체결 M곳. 미달·초과 시 대가 조정.</li>
|
||||
<li>대가는 성과 연동: 계약 체결 병원 수에 따른 지분 베스팅(상한 10% 미만·공개 유지) 또는 병원당 소개 수수료.</li>
|
||||
<li>소개받은 병원은 AIO2O와 직접 계약한다. 앵커를 거치는 계약 구조를 두지 않는다.</li>
|
||||
<li>노출 우대 없음: 소개 병원에도 노출 순서 규칙과 비교·순위 금지를 동일 적용한다.</li>
|
||||
<li>앵커 이탈 시 병원 계약 존속: 앵커 관계 종료가 병원 계약의 해지 사유가 아님을 명시한다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>다음 미팅 확인 질문</h3>
|
||||
<ul>
|
||||
<li>병원 수·이름·과목·지역, 그리고 현재 관계 단계(지인·구두·계약).</li>
|
||||
<li>관계의 성격: 공동 마케팅인지, 환자 리퍼럴인지, 학회·공동구매인지. 성격에 따라 §05 확인 항목이 달라진다.</li>
|
||||
<li>각 병원이 AIO2O와 직접 계약하는 데 동의하는지.</li>
|
||||
<li>대가로 원하는 것: 지분·수수료·노출 우대 중 무엇인지. 노출 우대를 원하면 중립성 원칙과 충돌하므로 재협의가 필요하다.</li>
|
||||
</ul>
|
||||
<p class="muted" style="margin-top:8px">답변 전까지 §08 1단계의 소개 병원 수는 최소선 2~4곳을 유지한다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 5. 신청·등록 -->
|
||||
<section id="s5" class="dark pb"><div class="wrap">
|
||||
<div class="eyebrow">05 · Registrations & Compliance</div><h2 class="serif">What AIO2O Must Obtain</h2>
|
||||
<p class="sub">단계별로 필요한 등록·신고·계약·계정을 전부 적었다. "확인"은 요건 수치나 적용 여부를 담당 기관 또는 변호사에게 확인해야 하는 항목이다.</p>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:56px">단계</th><th style="width:230px">항목</th><th>근거·요건</th><th style="width:170px">담당·비고</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>사업자 업종 추가</b></td><td>정보서비스업(포털·데이터), 광고대행업, 소프트웨어 개발. 의료관광 유치업은 등록 후 추가.</td><td>세무 대리인</td></tr>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>병원 간 상호 소개 구조 확인</b></td><td>의료법 27조 3항(영리 목적 환자 소개·알선·유인 금지). 앵커·파트너 병원이 서로 환자를 소개하는 기존 관계를 허브에 그대로 옮길 때 적용 여부. 앵커에게 지급하는 병원 소개 대가가 이 조항 또는 유치업 등록 전 수수료 제한에 걸리는지 함께 본다. 관계의 성격(공동 마케팅·리퍼럴·학회)에 따라 판단이 갈린다.</td><td>확인: 변호사. 앵커 소개 명단 확보 후</td></tr>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>병원 파트너 계약 (병원마다)</b></td><td>홈페이지·영상·이미지 자산 사용 허락 범위, 의료광고 주체는 개설자·AIO2O는 제작·운영 대행, 글별 의학 검토·게시 승인 의무와 주기, 지원 관계 고지 문구, 정정 절차, 환자 데이터 처리 역할. 뷰성형외과와의 앵커 조항(소개·자문·지분 또는 수수료)은 별도 부속.</td><td>변호사 검토. 표준 계약서 1종</td></tr>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>의료광고 규정 준수 체계</b></td><td>의료법 56조 2항(치료경험담 2호, 비교 4호, 수술 장면 6호, 부작용 누락 7호, 과장 8호, 기사·전문가 형태 10호, 인증·보증 14호)을 발행 게이트와 이미지 규칙으로 차단. 경제적 이해관계 표시는 추천·보증 심사지침대로 글 첫 부분. 외국어 페이지도 동일 적용.</td><td>완료(코드). 외국어 게이트 어휘 추가 필요</td></tr>
|
||||
<tr><td><span class="badge b-warn">1→2</span></td><td><b>의료광고 사전심의 (매체 기준)</b></td><td>의료법 57조. 자체 도메인 신규 매체는 전년도 일평균 이용자 10만 미만이라 의무 아님. 10만을 넘기면 대한의사협회 의료광고심의위원회 심의 대상. 네이버 블로그 재작성본은 게시물별 심의 대상이며 신청은 병원 명의.</td><td>이용자 수 모니터링. 확인: 외국어 매체 적용 여부</td></tr>
|
||||
<tr><td><span class="badge b-warn">1→2</span></td><td><b>외국인환자 유치 광고 규정</b></td><td>의료해외진출 및 외국인환자 유치 지원법. 유치업자·의료기관의 외국인환자 유치 광고는 국내 지정 장소(공항·항만·면세구역 등) 외 제한, 진료 비용 할인·과장 금지. 온라인 다국어 매체가 이 규정에서 어떻게 취급되는지 확인.</td><td>확인: 보건복지부·KHIDI</td></tr>
|
||||
<tr><td><span class="badge b-warn">2</span></td><td><b>외국인환자 유치업자 등록</b></td><td>같은 법 6조. 상담·예약을 중개하고 대가를 받으면 필수. 요건(확인): 법인, 자본금 1억 원 이상, 의료사고 배상책임보험 또는 공제(보장 한도 기준 있음), 사무실. 시·도에 등록, 3년마다 갱신, 유치 실적 연 1회 보고. 미등록 유치·수수료는 형사 처벌 대상.</td><td>AIO2O 법인. 2단계 착수 전 3개월</td></tr>
|
||||
<tr><td><span class="badge b-warn">2</span></td><td><b>통신판매업 신고 · 결제(PG)</b></td><td>예약금·상담비를 온라인으로 받으면 통신판매업 신고(구청). 해외 카드 결제 PG(Stripe·토스 해외결제) 계약, 외화 정산 계좌. 환불·취소 약관.</td><td>2단계</td></tr>
|
||||
<tr><td><span class="badge b-warn">2</span></td><td><b>개인정보·민감정보 처리</b></td><td>개인정보처리방침(한·영·일·중), 건강 정보는 민감정보라 별도 동의, 외국인 데이터의 국외 이전 동의(해외 결제·번역 API 사용 시), 병원과의 처리 위탁·제공 계약. GDPR·일본 개인정보보호법 적용 여부 확인(대상 국가별). 상담 신청 폼은 최소 수집.</td><td>변호사·개인정보 담당자 지정</td></tr>
|
||||
<tr><td><span class="badge b-info">3</span></td><td><b>여행업 등록 (직접 판매 시)</b></td><td>숙박·관광 상품을 직접 판매·알선하면 관광진흥법 여행업 등록. 제휴 링크(아고다·클룩·야놀자 어필리에이트)만 쓰면 불필요. 배달은 제휴 API 부재.</td><td>3단계에서 결정</td></tr>
|
||||
<tr><td><span class="badge b-info">1</span></td><td><b>상표·도메인</b></td><td>허브 브랜드 상표 출원(한국·주요 대상국), 국제 도메인, 언어별 서브패스(/en /ja /zh) 또는 서브도메인 결정(hreflang 설계에 영향).</td><td>브랜드 확정 후</td></tr>
|
||||
<tr><td><span class="badge b-info">1</span></td><td><b>API·플랫폼 계정</b></td><td>Google Cloud 프로젝트 쿼터(신청 중) → Places API(GBP 평점·리뷰 수·NAP 실측), YouTube Data API 쿼터 증량(기본 1만 유닛/일, 병원 10곳 이상 시 신청), OpenAI 조직 한도, Firecrawl·네이버 검색 API, 번역(DeepL 또는 GPT), Vercel Pro(팀·도메인), Supabase Pro, Trigger.dev(워커), Apify 미납 해소(인스타 수집).</td><td>AIO2O 계정 소유자 통일</td></tr>
|
||||
<tr><td><span class="badge b-info">1</span></td><td><b>보험·회계</b></td><td>매체 배상책임(명예훼손·저작권) 보험 검토, 병원 구독·수수료 세금계산서 체계, 외화 수익 회계.</td><td>확인</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 6. 기술 스택과 기능 -->
|
||||
<section id="s6" class="light pb"><div class="wrap">
|
||||
<div class="eyebrow">06 · Stack & Capabilities</div><h2 class="serif">Done, Partial, New</h2>
|
||||
<p class="sub">완료는 두 병원에서 검증된 것, 부분은 있으나 포털 규모로 바꿔야 하는 것, 신규는 없는 것. 근거 열은 저장소 파일이다.</p>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><h3>현재 스택</h3><p><span class="pill">React 19</span><span class="pill">Vite 6</span><span class="pill">Tailwind 4</span><span class="pill">React Router 7</span><span class="pill">Astro 7 (서포터즈)</span><span class="pill">Supabase (DB·Edge Functions)</span><span class="pill">Vercel</span><span class="pill">Node 22 워커</span><span class="pill">Python (채점기·이미지)</span><span class="pill">GPT-4.1</span><span class="pill">YouTube Data API</span><span class="pill">Firecrawl</span><span class="pill">네이버 검색 API</span></p></div>
|
||||
<div class="card"><h3>추가 스택 (1~2단계)</h3><p><span class="pill">번역: GPT-4.1 근거 프로토콜 재실행 + DeepL 검수</span><span class="pill">Trigger.dev (워커 상시)</span><span class="pill">Supabase Auth (병원·운영자 역할)</span><span class="pill">Stripe / 토스 해외결제</span><span class="pill">Google Places API</span><span class="pill">Resend·SES (알림 메일)</span><span class="pill">Plausible 또는 GA4 (AI 유입 채널그룹)</span><span class="pill">Vercel 로그 드레인 (AI 크롤러 계측)</span></p></div>
|
||||
<div class="card"><h3>원칙 (변하지 않는 것)</h3><ul><li>근거 없는 사실은 생성하지 않는다. "확인 대기".</li><li>사람 게이트(검토·승인·채널 확인·이미지 육안)는 자동화하지 않는다.</li><li>고객 화면에 운영자 설명 없음. 확정 디자인은 텍스트만.</li><li>부재는 증명한다(채널 탐색 다섯 갈래).</li></ul></div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:150px">영역</th><th>기능</th><th style="width:78px">상태</th><th style="width:260px">근거 파일 · 비고</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td rowspan="3"><b>진단 (AI Discovery)</b></td><td>기준표 v1.0 36항목 · v2.0 AEO/GEO 분리 채점, 미검증 분모 제외, 치명 게이트</td><td><span class="badge b-good">완료</span></td><td class="mono">src/data/aeoGeoRubric*.ts · src/lib/discoveryScore*.ts</td></tr>
|
||||
<tr><td>자동 채점기(auto 21~22항목) · 리포트 페이지 · 독립 HTML/PDF 리포트</td><td><span class="badge b-good">완료</span></td><td class="mono">scripts/audit_aeo_geo.py · /discovery/:id · scripts/build_discovery_report.ts</td></tr>
|
||||
<tr><td>외국인 질문 뱅크(영·일·중) · 다국어 답변엔진 실측 러너 · GBP·강남언니·플레이스 자동 실측(D1·D2·D4·D5)</td><td><span class="badge b-crit">신규</span></td><td>국문 120문항 러너 `scripts/run_question_bank_openai.py` 확장. Places API 키 필요</td></tr>
|
||||
<tr><td rowspan="4"><b>온보딩 파이프라인</b></td><td>채널 발견·엔티티 검증(5갈래, 근거 점수 3-state) · 근거 수집(223p) · 팩트 시트·의료진 초안</td><td><span class="badge b-good">완료</span></td><td class="mono">supporters/scripts/discover_channels.mjs · collect_evidence.mjs</td></tr>
|
||||
<tr><td>유튜브 집계(정보형 필터·쇼츠 판별) · 로고·파비콘 · 이미지(의료법 규칙·글자 이미지 검사)</td><td><span class="badge b-good">완료</span></td><td class="mono">collect_youtube.mjs · collect_images.mjs · img_process.py · docs/INFINITH_Supporters_Image_Rules.md</td></tr>
|
||||
<tr><td>첫 배치 기획(2층 구조) · GPT-4.1 글 생성 · 수치 대조 · 모델 근거 대조 · 수정 2회</td><td><span class="badge b-good">완료</span></td><td class="mono">default_briefs.mjs · generate_posts.mjs · gen/*</td></tr>
|
||||
<tr><td>뉴스룸 단계 · 감사 재실행(색인 허용 빌드 비교) · 유튜브 자막(IP 차단 대비 큐·프록시)</td><td><span class="badge b-warn">부분</span></td><td>뷰 수작업 스크립트 있음(`build_news.py`·`build_supporters_audit_xlsx.py`). 워커 단계로 미연결. 자막은 미구현</td></tr>
|
||||
<tr><td rowspan="2"><b>발행 게이트</b></td><td>error 11종 + warn 6종 + 비교 수치 · 승인 · 고지 위치 · 기자 화법 · 구조 지문, 픽스처 테스트 16/16</td><td><span class="badge b-good">완료</span></td><td class="mono">supporters/scripts/gate/rules.mjs · check.mjs</td></tr>
|
||||
<tr><td>외국어 금칙어·결론 단정·비교 표현 사전(영·일·중), 외국어 홈페이지 40자 중복 기준</td><td><span class="badge b-crit">신규</span></td><td>언어별 규칙 모듈. 원문 병원 페이지가 국문이면 번역 중복 검사 방식 결정 필요</td></tr>
|
||||
<tr><td rowspan="3"><b>서포터즈 사이트 (병원 페이지)</b></td><td>Astro 템플릿(INFINITH 토큰) · 팩트 시트 단일 원본 · 신뢰 층(작성자·검토·승인·근거 유형·갱신·정정) · MedicalClinic·Physician·Article·FAQPage · noindex→색인 전환</td><td><span class="badge b-good">완료</span></td><td class="mono">templates/supporters-astro · supporters/src/lib.ts</td></tr>
|
||||
<tr><td>다국어(hreflang·언어별 라우팅·언어별 팩트 시트 표기·통화·날짜) · 언어별 상담 신청 폼</td><td><span class="badge b-crit">신규</span></td><td>같은 팩트 시트에서 언어별 렌더. 수치·시점은 언어 간 동일해야 함</td></tr>
|
||||
<tr><td>병원 N개를 한 도메인에서: 병원 서브패스 또는 서브도메인, 공통 홈·시술별 허브·질문별 답 페이지·병원 목록(비교·순위 없음)</td><td><span class="badge b-crit">신규</span></td><td>현재는 병원당 Vercel 프로젝트. 허브 도메인 설계와 canonical·sitemap 분리 필요</td></tr>
|
||||
<tr><td rowspan="3"><b>승인·운영 UI</b></td><td>병원 확인 항목 폼(9항목) · 글별 검토·승인 기록 · 발견 채널 확인 · 미리보기 · 입력 반영 재빌드</td><td><span class="badge b-good">완료</span></td><td class="mono">src/components/discovery/ClinicInputsPanel.tsx · apply_inputs.mjs</td></tr>
|
||||
<tr><td>계정·역할(병원 담당자·원장·운영자) · 로그인 · 병원별 권한 · 승인 서명 기록(승인자·직함·일시·글 버전 해시)</td><td><span class="badge b-crit">신규</span></td><td>지금은 익명 입력. Supabase Auth + RLS 병원 스코프</td></tr>
|
||||
<tr><td>운영자 콘솔: 게이트 실패 목록, 이미지 육안 확인 체크, 확인 필요 채널 판정, 정정 접수, 비용·쿼터 대시보드</td><td><span class="badge b-warn">부분</span></td><td>status.json·report.md로 존재. 화면 없음</td></tr>
|
||||
<tr><td rowspan="2"><b>워커·인프라</b></td><td>워커 11단계 · 큐(supporter_builds) · 폴러 · 랜딩 URL 신청 → 큐 · Vercel 미리보기 배포</td><td><span class="badge b-good">완료</span></td><td class="mono">workers/supporters-build/run.mjs · poll.mjs</td></tr>
|
||||
<tr><td>상시 실행(Trigger.dev) · 입력 변경 시 자동 재빌드 트리거 · 실패 알림 · 병원 10곳 이상 동시 큐</td><td><span class="badge b-warn">부분</span></td><td>launchd plist 준비, 미설치. Trigger.dev 이관은 가입·키만 필요</td></tr>
|
||||
<tr><td rowspan="2"><b>상담·예약 (2단계)</b></td><td>상담 신청 폼(언어·시술·일정·연락처) → 병원 이메일·카카오 알림 · 응답 추적</td><td><span class="badge b-crit">신규</span></td><td>1단계는 병원 직결(수수료 없음)</td></tr>
|
||||
<tr><td>예약 확정·통역 배정·예약금 결제·취소 환불·정산 · 유치 실적 보고 데이터</td><td><span class="badge b-crit">신규</span></td><td>유치업 등록 후. Stripe·토스, 정산 테이블, 실적 보고 양식</td></tr>
|
||||
<tr><td><b>여정 연결 (3단계)</b></td><td>회복 일정 기반 숙박·식사·관광 추천 · 제휴 링크 트래킹 · 축제·행사 데이터 연동(한국관광공사 API)</td><td><span class="badge b-crit">신규</span></td><td>제휴 API·어필리에이트 계정. 직접 판매 없음</td></tr>
|
||||
<tr><td rowspan="2"><b>측정</b></td><td>서버 로그 AI 크롤러 히트 · GA4 AI 유입 채널그룹 · GSC AI Overviews · 기준선 스팟체크(질의 × 반복)</td><td><span class="badge b-crit">신규</span></td><td>E 카테고리 0/4. Vercel 로그 드레인 → Supabase, GA4 채널그룹 정의. 답변엔진 UI 자동 조회는 약관 위반이라 소유 신호만</td></tr>
|
||||
<tr><td>병원별 성과 리포트(인용·유입·상담 신청) 자동 생성</td><td><span class="badge b-warn">부분</span></td><td>리포트 빌더 있음. 측정 데이터가 없어 카드가 비어 있음</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 7. 아키텍처 -->
|
||||
<section id="s7" class="dark"><div class="wrap">
|
||||
<div class="eyebrow">07 · Architecture</div><h2 class="serif">Data Flow</h2>
|
||||
<p class="sub">공급(병원)은 자동 온보딩 파이프라인, 수요(환자)는 AI·검색 인용, 그 사이를 승인 데이터가 잇는다.</p>
|
||||
<div class="flow" style="margin-top:22px">
|
||||
<div class="st"><b>입력</b><br>병원 이름 또는 URL (랜딩 신청·운영자·앵커 소개) → 큐</div>
|
||||
<div class="st"><b>발견·수집</b><br>채널 엔티티 검증, 근거 223p, 유튜브, 이미지(법 필터), 팩트 시트</div>
|
||||
<div class="st"><b>생성·게이트</b><br>기획 → GPT-4.1 → 수치·근거 대조 → 발행 게이트 → 국문 원문</div>
|
||||
<div class="st"><b>다국어</b><br>같은 팩트 시트·근거로 언어별 재생성(번역 아님) → 언어별 게이트 → hreflang</div>
|
||||
<div class="st"><b>승인</b><br>병원 폼 입력 → 검토·승인 기록 → 색인 허용 빌드에 승인 글만</div>
|
||||
<div class="st"><b>허브 배포·측정</b><br>허브 도메인(병원별 경로) → AI·검색 인용 → 로그·GA4·상담 신청 계측</div>
|
||||
</div>
|
||||
<div class="grid g2" style="margin-top:18px">
|
||||
<div class="card"><h3>데이터 모델 (핵심 테이블)</h3><ul><li><b>clinics</b>: id, 이름(다국어), url, 팩트 시트(JSON, checkedAt/nextCheck), surfaces(확인된 채널), 파트너 계약 상태.</li><li><b>supporter_builds</b>(있음): 빌드 실행·단계·미리보기 URL·리포트.</li><li><b>supporter_inputs</b>(있음): 병원 입력, 글별 검토·승인.</li><li><b>posts</b>: 언어별 글, 근거 유형, 검토·승인, 갱신 기록. 지금은 md frontmatter, 허브 규모에서는 DB로.</li><li><b>inquiries</b>(신규): 상담 신청, 언어, 병원 전달·응답 시각.</li><li><b>bookings·payments</b>(2단계), <b>affiliate_clicks</b>(3단계), <b>ai_crawler_hits·ai_referrals</b>(측정).</li></ul></div>
|
||||
<div class="card"><h3>다국어 설계 원칙</h3><ul><li>번역이 아니라 근거 프로토콜을 언어별로 다시 태운다. 수치·시점·의료진은 팩트 시트 한 곳에서 나온다.</li><li>언어별 게이트: 금칙어·결론 단정·비교 표현 사전을 언어마다 둔다. 부작용 고지·지원 관계 고지는 언어별 문안 고정.</li><li>hreflang 상호 연결, canonical 언어별, sitemap 언어별. 병원 홈페이지 영문 사이트가 있으면 sameAs로 연결.</li><li>통화·단위: 비용 항목만 쓰고 금액은 쓰지 않으므로 환율 문제는 2단계 결제에서만.</li></ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 8. 로드맵 -->
|
||||
<section id="s8" class="near"><div class="wrap">
|
||||
<div class="eyebrow">08 · Roadmap</div><h2 class="serif">Three Phases</h2>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><span class="badge b-good">1단계 · 8~12주</span><h3 style="margin-top:10px">글로벌 허브 MVP</h3><ul><li>허브 브랜드·도메인·언어 라우팅 결정</li><li>다국어 생성(영·일·중) + 언어별 게이트</li><li>다병원 허브(공통 홈·시술 허브·병원 페이지·상담 신청 병원 직결)</li><li>뷰성형외과 + 앵커 소개 병원 온보딩(자동). 최소 2~4곳, 앵커 소개 명단 확인 후 재산정</li><li>외국인 질문 뱅크 실측(기준선)</li><li>측정 인프라: 로그 드레인·GA4 채널그룹</li><li>계정·역할·운영자 콘솔 최소판</li></ul><p class="muted">게이트: 병원 4곳 개설자 승인, 인용 기준선 확보</p></div>
|
||||
<div class="card"><span class="badge b-warn">2단계 · 3~6개월</span><h3 style="margin-top:10px">예약·수익화</h3><ul><li>유치업자 등록(법인·자본금·배상보험) 완료</li><li>예약 확정·통역·예약금 결제·환불·정산</li><li>병원 구독(온보딩·원문 공급·리포트) 요금제</li><li>뉴스룸·감사 재실행·자막 단계 워커 연결</li><li>Trigger.dev 상시 워커, 입력 변경 자동 재빌드</li><li>개인정보 국외 이전·민감정보 동의 체계</li></ul><p class="muted">게이트: 유치업 등록증, PG 계약, 상담→예약 전환 데이터</p></div>
|
||||
<div class="card"><span class="badge b-info">3단계 · 이후</span><h3 style="margin-top:10px">회복기 여정 연결</h3><ul><li>회복 일정 기반 숙박·식사·관광 추천(제휴)</li><li>축제·행사 데이터(관광공사 API)</li><li>미용 서비스 제휴, 배달은 안내</li><li>병원 10곳 이상, 피부과 확장</li><li>여행업 등록 여부 결정</li></ul><p class="muted">게이트: 제휴 계약, 3단계 수익 검증</p></div>
|
||||
</div>
|
||||
<div class="card cta" style="margin-top:18px"><h3>1단계 착수 전 CEO 결정 3가지</h3><p style="color:var(--navy)">(1) 뷰성형외과 앵커 계약 조건(무상 온보딩·소개·자문 대가로 소액 지분 또는 수수료). 소개 목표 수치, 성과 연동 여부, 소개 병원의 AIO2O 직접 계약 원칙, 노출 우대 배제를 함께 정한다(§04). (2) 유치업 등록을 AIO2O 법인으로 할지, 등록된 유치업자와 제휴할지. (3) 수익 순서: 병원 구독이 먼저인지 예약 수수료가 먼저인지. 이 셋이 정해지면 나머지는 지금 파이프라인 위에 붙는 개발이다.</p></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 9. KPI -->
|
||||
<section id="s9" class="light"><div class="wrap">
|
||||
<div class="eyebrow">09 · Metrics</div><h2 class="serif">How We Measure</h2>
|
||||
<p class="sub">답변엔진 UI 자동 조회는 약관 위반이라 주력 지표는 소유 신호다. 효과는 반복 측정과 신뢰구간으로만 보고한다.</p>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:170px">축</th><th>지표</th><th style="width:220px">소스</th><th style="width:150px">1단계 목표</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>공급</b></td><td>온보딩 병원 수, 언어별 발행 글 수, 개설자 승인 글 비율, 게이트 오류율, 편당 생성 비용</td><td>supporter_builds·inputs, 생성기 리포트</td><td>병원 4곳 · 승인 60% 이상</td></tr>
|
||||
<tr><td><b>인용(AEO/GEO)</b></td><td>외국인 질문 뱅크 인용률(언어별, 5회 반복), 병원 AEO/GEO 점수 변화, AI 크롤러 히트(IP 검증)</td><td>QB 러너, 채점기, 서버 로그</td><td>기준선 확보, 크롤러 히트 발생</td></tr>
|
||||
<tr><td><b>유입</b></td><td>AI 유입 세션(GA4 채널그룹), 검색 유입, 언어별 체류</td><td>GA4·GSC</td><td>월 AI 유입 세션 측정 시작</td></tr>
|
||||
<tr><td><b>전환</b></td><td>상담 신청 수, 병원 응답률·응답 시간, (2단계) 예약 확정·결제</td><td>inquiries·bookings</td><td>상담 신청 월 10건</td></tr>
|
||||
<tr><td><b>신뢰</b></td><td>정정 접수·처리 시간, 게이트 차단 건수, 확인 대기 항목 해소율</td><td>corrections, 게이트 로그</td><td>정정 72시간 내 처리</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 10. 리스크 -->
|
||||
<section id="s10" class="dark"><div class="wrap">
|
||||
<div class="eyebrow">10 · Risks & Open Questions</div><h2 class="serif">What Can Break</h2>
|
||||
<div class="grid g2" style="margin-top:22px">
|
||||
<div class="card"><h3>리스크와 대응</h3><ul><li><b>규제</b>: 유치업 미등록 상태의 수수료, 외국인 유치 광고 규정, 사전심의 매체 기준. 대응: 1단계 무수수료·병원 직결, 2단계 전 등록, 이용자 수 모니터링.</li><li><b>공급 정체</b>: 병원이 검토·승인을 안 한다. 대응: 미승인이어도 "확인 대기" 미리보기 발행, 검토 담당자·주기를 계약에.</li><li><b>중립성 의심</b>: 앵커 병원 편향. 대응: 지분 공개·고지, 노출 순서 규칙 공개, 비교·순위 없음.</li><li><b>생성 품질</b>: 근거 없는 수치·오역. 대응: 언어별 수치 대조·근거 대조, 사람 확인 플래그, 정정 페이지.</li><li><b>경쟁</b>: 크리에이트립·강남언니 글로벌이 AI 인용 구조를 갖추면 선점 기회가 사라진다. 대응: 속도. 온보딩 자동화가 유일한 시간 우위.</li><li><b>운영</b>: 비개발자 운영, 워커 상시 실행 미결정. 대응: Trigger.dev, 운영자 콘솔.</li><li><b>앵커 의존</b>: 공급이 앵커 개인의 관계에 붙는다. 소개 병원이 AIO2O와 직접 계약하지 않으면 앵커가 빠질 때 공급도 같이 빠진다. 대응: 병원별 직접 계약, 앵커 관계 종료가 해지 사유가 아님을 명시, 소개 경로와 계약 주체를 분리 기록.</li><li><b>브랜드</b>: 파트너 병원의 공정위·의료사고 이력. 대응: 계약 표명·보증, 뉴스룸 부정 기사 제외 규칙 유지, 허브 이름에 병원명 배제.</li></ul></div>
|
||||
<div class="card"><h3>미결 (결정 주체)</h3><ul><li>허브 브랜드·도메인·언어 라우팅 (CEO·haewon)</li><li>뷰 앵커 계약 조건, 합작 여부 (CEO)</li><li>앵커 소개 병원 명단·수·과목·지역·관계 단계 확인 (CEO)</li><li>소개 대가 구조: 성과 연동 지분 베스팅인지 병원당 수수료인지 (CEO)</li><li>유치업 등록 주체와 시점 (CEO·법무)</li><li>수익 모델 순서와 병원 구독 가격 (경영진)</li><li>대상 언어·국가 우선순위 (일본·중국어권·영어권) (CEO)</li><li>실존 서포터즈·편집 책임자 명의 (haewon)</li><li>워커 상시 실행 위치 (haewon)</li><li>기존 미결: 뷰 도메인, POC 8주 범위, 서포터즈 모집</li></ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 11. 부록 -->
|
||||
<section id="s11" class="near"><div class="wrap">
|
||||
<div class="eyebrow">11 · Appendix</div><h2 class="serif">References</h2>
|
||||
<div class="grid g2" style="margin-top:22px">
|
||||
<div class="card"><h3>프로젝트 산출물</h3><ul>
|
||||
<li>진단 리포트: <span class="mono">docs/reports/viewclinic/01_readiness_report</span> · <span class="mono">docs/reports/wonjin/01_readiness_report</span> (HTML·PDF)</li>
|
||||
<li>미리보기: supporters-viewclinic.vercel.app · supporters-wonjin.vercel.app (noindex)</li>
|
||||
<li>병원 확인 항목 패널: infinith-demo.vercel.app/supporters/wonjin</li>
|
||||
<li>핸드오버: <span class="mono">docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md</span>, 규칙: v2 §3·§4·§6·§12</li>
|
||||
<li>회고: <span class="mono">docs/INFINITH_AI_Discovery_Retrospective_2026-09-08.md</span></li>
|
||||
<li>이미지 규칙: <span class="mono">docs/INFINITH_Supporters_Image_Rules.md</span></li>
|
||||
<li>워커: <span class="mono">workers/supporters-build/README.md</span></li>
|
||||
</ul></div>
|
||||
<div class="card"><h3>법령·기준 (원문 확인 필요)</h3><ul>
|
||||
<li>의료법 제56조(의료광고의 금지 등) 제2항, 제57조(의료광고의 심의), 시행령 제23조·제24조</li>
|
||||
<li>의료 해외진출 및 외국인환자 유치 지원법 제6조(유치업자 등록), 제15조(의료광고 특례·제한), 시행규칙(등록 요건·보험)</li>
|
||||
<li>표시·광고의 공정화에 관한 법률, 추천·보증 등에 관한 표시·광고 심사지침(2024-12 개정)</li>
|
||||
<li>개인정보 보호법(민감정보·국외 이전), 정보통신망법</li>
|
||||
<li>전자상거래법(통신판매업 신고), 관광진흥법(여행업)</li>
|
||||
<li>공정거래위원회 2026-07-12 성형외과 후기 뒷광고 시정명령(뷰성형외과 포함 3곳)</li>
|
||||
</ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<footer>INFINITH · AI오투오 · o2oteam@o2o.kr · PRD v0.1 초안 2026-09-08 · 법 해석은 자문이 아니며 계약·등록 전 변호사 확인이 필요합니다.</footer>
|
||||
</body></html>
|
||||
@ -0,0 +1,155 @@
|
||||
<!doctype html>
|
||||
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>원진성형외과 AI Answer Readiness Report v2.0</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||
<style>
|
||||
:root{--ink:#0A1128;--navy:#021341;--violet:#4F1DA1;--accent:#6C5CE7;--light:#F7F8FC;--slate:#4A5268;--muted:#7A8399;--line:#E3E7F2}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:Pretendard,-apple-system,sans-serif;color:var(--ink);background:#fff;word-break:keep-all;line-height:1.62;font-size:14px}
|
||||
.serif{font-family:'Playfair Display',serif}
|
||||
.page{padding:48px 56px;max-width:1080px;margin:0 auto}
|
||||
.dark{background:var(--ink);color:#fff}.dark .sub,.dark .note{color:#B9C0D4}.light{background:var(--light)}
|
||||
.eyebrow{font-size:12px;font-weight:700;letter-spacing:.24em;color:var(--accent);text-transform:uppercase}.dark .eyebrow{color:#C4B5FD}
|
||||
h1{font-size:44px;line-height:1.1;margin:10px 0 6px;letter-spacing:-.02em}h2{font-size:30px;margin:8px 0 12px;line-height:1.2}h3{font-size:17px;margin:0 0 6px}
|
||||
.accent{background:linear-gradient(90deg,#C4B5FD,#93C5FD);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.lede{font-size:16px;color:var(--slate);max-width:760px}.dark .lede{color:#D6DBEA}
|
||||
.sub{font-size:12.5px;color:var(--muted)}.note{font-size:12.5px;color:var(--muted);margin-top:8px}
|
||||
.badge{display:inline-block;border:1px solid;border-radius:999px;padding:3px 12px;font-size:13px;font-weight:700}
|
||||
.pill{display:inline-block;border-radius:999px;padding:2px 9px;font-size:11.5px;font-weight:700}.pill.gray{background:#EEF0F6;color:#6B7280}
|
||||
.grid{display:grid;gap:16px}.g2{grid-template-columns:1fr 1fr}.g3{grid-template-columns:repeat(3,1fr)}
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:18px;padding:20px 22px;box-shadow:3px 4px 12px rgba(0,0,0,.05)}.dark .card{color:var(--ink)}
|
||||
.rings{display:flex;gap:28px;align-items:flex-start;flex-wrap:wrap}.ring{display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||
.tbl{width:100%;border-collapse:collapse;font-size:13px;background:#fff;border-radius:12px;overflow:hidden;border:1px solid var(--line)}.tbl th{background:#F1F3FA;text-align:left;padding:9px 11px;font-size:12px;color:var(--slate);border-bottom:1px solid var(--line)}.tbl td{padding:9px 11px;border-bottom:1px solid #EEF0F6;vertical-align:top}.tbl tr:last-child td{border-bottom:0}.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--accent);font-weight:700}
|
||||
.lv{display:inline-block;min-width:26px;text-align:center;border-radius:6px;padding:1px 6px;font-weight:700;font-size:12px}.lv3{background:#E9F7EF;color:#2E7D4F}.lv2{background:#EEF3FF;color:#2F4FBF}.lv1{background:#FFF4E5;color:#B26A00}.lv0{background:#FDECEF;color:#B84C5F}.lvu{background:#EEF0F6;color:#6B7280}
|
||||
.find{border-left:5px solid;padding-left:16px}.find .tag{font-size:11.5px;font-weight:700;letter-spacing:.08em}
|
||||
.act .pri{font-family:'Playfair Display',serif;font-size:22px;font-weight:700;color:var(--accent)}.act .eff{float:right}
|
||||
.kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.kpi .k{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);border-radius:14px;padding:14px 16px}.kpi b{font-family:'Playfair Display',serif;font-size:30px;color:#AF90FF;display:block;line-height:1}.kpi span{font-size:12.5px;color:#C7CDE0}
|
||||
a{color:var(--accent)}ul{margin:6px 0 0 18px;padding:0}li{margin:3px 0}
|
||||
footer{padding:28px 56px;font-size:12px;color:var(--muted);border-top:1px solid var(--line)}
|
||||
@media print{@page{size:A4;margin:12mm}body{font-size:12.5px}.page{padding:22px 10px;max-width:none}.pb{page-break-before:always}h1{font-size:34px}h2{font-size:24px}.dark{-webkit-print-color-adjust:exact;print-color-adjust:exact}.card,.tbl,.lv,.badge,.pill,.kpi .k{-webkit-print-color-adjust:exact;print-color-adjust:exact}tr,.card{page-break-inside:avoid}}
|
||||
</style></head><body>
|
||||
|
||||
<section class="dark"><div class="page" style="padding-top:64px;padding-bottom:56px">
|
||||
<div class="eyebrow">INFINITH AI Discovery · AEO / GEO Audit v2.0</div>
|
||||
<h1 class="serif"><span class="accent">AI Answer Readiness</span></h1>
|
||||
<div style="font-size:26px;font-weight:700;margin:4px 0 2px">원진성형외과</div>
|
||||
<div class="sub" style="color:#C4B5FD">성형외과 (강남역) · <a href="https://www.k-wonjin.co.kr" style="color:#C4B5FD">https://www.k-wonjin.co.kr</a></div>
|
||||
<p class="lede" style="margin-top:18px">AI 답변엔진(ChatGPT·Perplexity·Gemini·네이버 AI 브리핑)이 이 병원을 얼마나 읽고, 믿고, 인용할 수 있는지를 실측 신호로 채점한 리포트입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고, 확인하지 못한 항목은 점수에서 뺍니다.</p>
|
||||
<div style="display:flex;gap:32px;align-items:center;flex-wrap:wrap;margin-top:26px">
|
||||
<div class="card" style="display:flex;gap:26px;padding:22px 30px"><div class="ring"><svg viewBox="0 0 120 120" width="132" height="132"><circle cx="60" cy="60" r="52" fill="none" stroke="#E8ECF7" stroke-width="10"/><circle cx="60" cy="60" r="52" fill="none" stroke="#6C5CE7" stroke-width="10" stroke-linecap="round" stroke-dasharray="326.7" stroke-dashoffset="277.7" transform="rotate(-90 60 60)"/><text x="60" y="66" text-anchor="middle" font-family="Playfair Display, serif" font-weight="700" font-size="30" fill="#0A1128">15</text><text x="60" y="84" text-anchor="middle" font-size="10" fill="#7A8399">/ 100</text></svg><span class="badge" style="background:#FDECEF;color:#B84C5F;border-color:#F7C3CD">AEO 등급 D</span></div><div class="ring"><svg viewBox="0 0 120 120" width="132" height="132"><circle cx="60" cy="60" r="52" fill="none" stroke="#E8ECF7" stroke-width="10"/><circle cx="60" cy="60" r="52" fill="none" stroke="#6C5CE7" stroke-width="10" stroke-linecap="round" stroke-dasharray="326.7" stroke-dashoffset="179.7" transform="rotate(-90 60 60)"/><text x="60" y="66" text-anchor="middle" font-family="Playfair Display, serif" font-weight="700" font-size="30" fill="#0A1128">45</text><text x="60" y="84" text-anchor="middle" font-size="10" fill="#7A8399">/ 100</text></svg><span class="badge" style="background:#FFF4E5;color:#B26A00;border-color:#FFD9A8">GEO 등급 C</span></div></div>
|
||||
<div class="kpi" style="flex:1;min-width:420px">
|
||||
<div class="k"><b>25</b><span>실측 항목 / 36</span></div>
|
||||
<div class="k"><b>49</b><span>v1.0 종합 (C)</span></div>
|
||||
<div class="k"><b>5</b><span>핵심 발견</span></div>
|
||||
<div class="k"><b>6</b><span>실행 항목</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note" style="margin-top:22px">실측일 2026-09-08 · 리포트 작성 2026-09-08 · 미검증 11개 항목은 분모에서 제외했습니다. 조건은 마지막 장에 적었습니다.</p>
|
||||
</div></section>
|
||||
|
||||
<section class="light pb"><div class="page">
|
||||
<div class="eyebrow">Findings</div><h2>AI가 지금 원진성형외과를 어떻게 읽는지 정리했습니다</h2>
|
||||
<div class="grid g2" style="margin-top:14px">
|
||||
<div class="card find" style="border-left-color:#9B8AD4"><div class="tag" style="color:#9B8AD4">강점</div><h3>크롤러 접근은 열려 있습니다. 서버 렌더링이라 AI가 본문을 읽습니다</h3><p style="margin:0;color:var(--slate)">robots.txt가 AI 검색봇 12종을 모두 허용하고, IIS가 본문을 서버에서 렌더링해 JS 없이도 14,079자를 읽을 수 있습니다. TTFB 0.09초, 초기 HTML 153KB로 가볍습니다. 다만 www 유무에 따라 최종 URL이 갈라지고(정본 미수렴), sitemap이 robots.txt에 선언돼 있지 않습니다.</p></div><div class="card find" style="border-left-color:#D4889A"><div class="tag" style="color:#D4889A">핵심 과제</div><h3>연결은 됐지만 병원 자체가 스키마로 정의돼 있지 않습니다</h3><p style="margin:0;color:var(--slate)">Organization.sameAs 10개로 SNS·지도·버티컬을 잘 묶었고 주소 표기도 한 가지입니다. 그러나 MedicalClinic·Physician 스키마가 없어 주소·전화·진료시간·의료진 17명이 구조화돼 있지 않고, 전화번호는 6종이 섞여 있습니다. 영문 사이트(wonjinbeauty.com)는 hreflang으로 연결되지 않았습니다.</p></div><div class="card find" style="border-left-color:#D4889A"><div class="tag" style="color:#D4889A">핵심 과제</div><h3>인용할 문장이 부족합니다. H1도 표도 정의문도 없습니다</h3><p style="margin:0;color:var(--slate)">메인·시술 페이지에 H1이 없고 HTML 표가 없으며 병원 정의문도 없습니다. 눈매교정(CFS)·트임 페이지에는 수술 시간·마취·실밥·일상 복귀 수치가 텍스트로 있어 인용 구조의 출발점이 될 수 있지만, 쌍꺼풀·눈매교정 나머지 페이지는 수치가 없습니다. 최신 갱신 신호가 2025-02-18(sitemap)로 18개월 전입니다.</p></div><div class="card find" style="border-left-color:#D4A872"><div class="tag" style="color:#D4A872">보완</div><h3>유튜브는 강합니다. 736편·누적 3,113만 회</h3><p style="margin:0;color:var(--slate)">설명형 롱폼(눈성형 관리법·중년 눈성형 비교·지방흡입 QnA 등)이 있어 글의 근거 영상으로 쓸 수 있습니다. 다만 상위 조회 영상의 다수가 브랜드 필름·연예인·전후 형식이라 인용용 정보형 영상은 10편 안팎입니다. 이 채널이 원진의 가장 큰 오픈웹 자산입니다.</p></div><div class="card find" style="border-left-color:#D4A872"><div class="tag" style="color:#D4A872">보완</div><h3>이미지 alt 62% 누락, 의료진 경력은 텍스트지만 구조화는 안 됨</h3><p style="margin:0;color:var(--slate)">메인 이미지 45개 중 28개가 alt 없이 실려 AI가 이미지 내용을 읽지 못합니다. 의료진 17명의 경력은 텍스트로 있어(원장당 12~23줄) Physician 스키마만 붙이면 바로 엔티티가 됩니다. 강남언니·GBP·네이버 플레이스는 이번 자동 채점 범위 밖이라 수동 실측이 남아 있습니다.</p></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<section class="pb"><div class="page">
|
||||
<div class="eyebrow">AEO · Answer Readiness</div><h2>답변 준비도 8개 항목</h2>
|
||||
<p class="lede">질문을 고르고, 답을 먼저 쓰고, 한 단락으로 완결하고, 결정에 필요한 정보와 다음 행동까지 닿게 하는가.</p>
|
||||
|
||||
<table class="tbl"><thead><tr><th style="width:52px">항목</th><th>무엇을 보는가</th><th style="width:70px">점수</th><th style="width:72px">근거</th><th>파생된 실측 항목</th></tr></thead><tbody>
|
||||
<tr><td class="mono">AEO1</td><td><b>질문 수요 충족</b><div class="sub">사람들이 실제로 묻는 질문에 답하는 페이지가 있는가</div></td><td><b>0</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C2 L0</td></tr><tr><td class="mono">AEO2</td><td><b>답변의 직접성</b><div class="sub">페이지 첫 단락에 답이 먼저 나오는가</div></td><td><b>0</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C1 L0</td></tr><tr><td class="mono">AEO3</td><td><b>답변 단위의 자기완결성</b><div class="sub">한 단락만 떼어내도 누가·어디서·무엇을이 들어 있는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub">C8 미검증</td></tr><tr><td class="mono">AEO4</td><td><b>의사결정에 필요한 정보</b><div class="sub">가격대·시간·회복·조건처럼 결정에 쓰는 정보가 텍스트로 있는가</div></td><td><b>40</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C4 L2 · C5 L0</td></tr><tr><td class="mono">AEO5</td><td><b>정보 구조와 가독성</b><div class="sub">제목이 주제를 서술하고 계층이 논리적인가</div></td><td><b>0</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C3 L0</td></tr><tr><td class="mono">AEO6</td><td><b>다음 행동의 명확성</b><div class="sub">답을 읽은 사람이 무엇을 하면 되는지(예약·전화·방문) 바로 보이는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">AEO7</td><td><b>핵심 답변의 텍스트 추출</b><div class="sub">답이 이미지가 아니라 텍스트로 존재하고 서버가 렌더하는가</div></td><td><b>60</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">A3 L3 · F1 L0</td></tr><tr><td class="mono">AEO8</td><td><b>사이트 내 답변 발견</b><div class="sub">크롤러가 답이 있는 페이지를 찾아갈 수 있는가</div></td><td><b>47</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">A5 L1 · B6 L2</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<div class="eyebrow" style="margin-top:34px">GEO · Source Readiness</div><h2>출처 준비도 8개 항목</h2>
|
||||
<p class="lede">주장에 근거가 붙어 있고, 누가 책임지는지 보이고, 병원 사실이 표면마다 같고, 갱신·정정 기록이 있으며, 기계가 인용 식별자를 읽을 수 있는가.</p>
|
||||
|
||||
<table class="tbl"><thead><tr><th style="width:52px">항목</th><th>무엇을 보는가</th><th style="width:70px">점수</th><th style="width:72px">근거</th><th>파생된 실측 항목</th></tr></thead><tbody>
|
||||
<tr><td class="mono">GEO1</td><td><b>주장과 근거의 일치</b><div class="sub">수치·수상·효과 주장마다 근거(원문·출처·조건)가 붙어 있는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">GEO2</td><td><b>출처의 적합성과 추적성</b><div class="sub">인용된 URL이 정본으로 수렴하고 시간이 지나도 같은 곳을 가리키는가</div></td><td><b>33</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">A4 L1</td></tr><tr><td class="mono">GEO3</td><td><b>전문성과 책임 주체</b><div class="sub">누가 썼고 누가 의학적으로 책임지는지 텍스트와 스키마로 보이는가</div></td><td><b>40</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C7 L2 · B3 L0</td></tr><tr><td class="mono">GEO4</td><td><b>병원 엔티티와 사실의 정합성</b><div class="sub">이 병원이 누구인지 사이트 안과 밖(강남언니·GBP·플레이스)에서 같은 사실로 확정되는가</div></td><td><b>63</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">B2 L1 · B4 L3 · B5 L3 · D1 미검증 · D2 미검증 · D4 미검증 · D5 미검증 · D6 L0</td></tr><tr><td class="mono">GEO5</td><td><b>사실의 최신성과 정정</b><div class="sub">언제 확인한 사실인지 적혀 있고, 틀린 것을 고친 기록이 있는가</div></td><td><b>33</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C6 L1</td></tr><tr><td class="mono">GEO6</td><td><b>원천 대비 추가 가치</b><div class="sub">다른 곳에 있는 문장을 옮긴 것이 아니라 여기서만 얻는 정보(체크리스트·조건 표·실측)가 있는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">GEO7</td><td><b>발행 관계와 투명성</b><div class="sub">누가 운영하고 누구의 지원을 받는지, 광고와 정보가 구분되는지 밝히는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">GEO8</td><td><b>인용 식별자와 메타데이터 정합성</b><div class="sub">기계가 읽는 식별자(JSON-LD·메타)가 유효하고 화면 내용과 같은가</div></td><td><b>67</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">B1 L2</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
</div></section>
|
||||
|
||||
<section class="pb dark"><div class="page">
|
||||
<div class="eyebrow">Category Detail</div><h2>카테고리별 실측 36개 항목</h2>
|
||||
<div class="grid g3" style="margin-top:12px">
|
||||
<div class="card"><div class="sub">A · Access & Crawlability</div><h3 style="margin-top:2px">AI 크롤러 접근성</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">80</span><span class="sub"> / 100 · 실측 6/7</span></div></div><div class="card"><div class="sub">B · Entity & Structured Data</div><h3 style="margin-top:2px">엔티티 식별·구조화</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">50</span><span class="sub"> / 100 · 실측 7/7</span></div></div><div class="card"><div class="sub">C · Answer-Ready Content</div><h3 style="margin-top:2px">답변 인용 가능 콘텐츠</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">25</span><span class="sub"> / 100 · 실측 7/8</span></div></div><div class="card"><div class="sub">D · Off-site Surfaces</div><h3 style="margin-top:2px">AI 인용 표면 확보</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">53</span><span class="sub"> / 100 · 실측 2/6</span></div></div><div class="card"><div class="sub">E · Measurement</div><h3 style="margin-top:2px">AI 가시성 측정 인프라</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">0</span><span class="sub"> / 100 · 실측 0/4</span></div></div><div class="card"><div class="sub">F · Technical Hygiene</div><h3 style="margin-top:2px">기술 위생</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">42</span><span class="sub"> / 100 · 실측 3/4</span></div></div>
|
||||
</div>
|
||||
</div></section>
|
||||
<section><div class="page">
|
||||
<h3 style="margin-top:22px;font-size:18px">A · AI 크롤러 접근성 <span class="sub">AI 검색엔진이 사이트를 읽을 수 있는가. 크롤러 접근이 막히면 나머지 항목은 전부 무효다.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">A1</td><td><b>검색용 AI 봇 허용</b><div class="sub">배점 5</div></td><td><span class="lv lv3">L3</span><div class="sub">검색봇 전부 허용 + 200 응답</div></td><td class="sub" style="color:var(--slate)">12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200</td></tr><tr><td class="mono">A2</td><td><b>CDN/WAF AI 차단 없음</b><div class="sub">배점 3</div></td><td><span class="lv lv3">L3</span><div class="sub">CDN 차단 없음 확인</div></td><td class="sub" style="color:var(--slate)">Server: Microsoft-IIS/10.0 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음</td></tr><tr><td class="mono">A3</td><td><b>서버 렌더링 본문</b><div class="sub">배점 3</div></td><td><span class="lv lv3">L3</span><div class="sub">80% 이상 (SSR/정적)</div></td><td class="sub" style="color:var(--slate)">서버 렌더링. JS 미실행 HTML에서 본문 텍스트 14,079자 추출</td></tr><tr><td class="mono">A4</td><td><b>HTTPS·리다이렉트 정합</b><div class="sub">배점 2</div></td><td><span class="lv lv1">L1</span><div class="sub">변형 간 200 중복</div></td><td class="sub" style="color:var(--slate)">변형 간 최종 URL 불일치(중복 콘텐츠 위험): ['https://k-wonjin.co.kr', 'https://www.k-wonjin.co.kr']</td></tr><tr><td class="mono">A5</td><td><b>sitemap.xml 제공</b><div class="sub">배점 3</div></td><td><span class="lv lv1">L1</span><div class="sub">있으나 robots 미선언</div></td><td class="sub" style="color:var(--slate)">/sitemap.xml 200이나 robots.txt에 Sitemap 미선언. URL 132개</td></tr><tr><td class="mono">A6</td><td><b>Bing 색인 (ChatGPT 검색 경로)</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요 (또는 site: 검색 수동 확인)</td></tr><tr><td class="mono">A7</td><td><b>응답 속도·크롤 예산</b><div class="sub">배점 1</div></td><td><span class="lv lv3">L3</span><div class="sub">TTFB < 0.5s, HTML < 300KB</div></td><td class="sub" style="color:var(--slate)">TTFB 0.094s / 초기 HTML 153KB. 시술 페이지 145KB. 모두 기준 이내</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">B · 엔티티 식별·구조화 <span class="sub">AI가 "이 병원이 누구인지"를 기계적으로 확정할 수 있는가. NAP 일관성과 스키마 위생.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">B1</td><td><b>JSON-LD 문법 유효성</b><div class="sub">배점 3</div></td><td><span class="lv lv2">L2</span><div class="sub">전부 유효</div></td><td class="sub" style="color:var(--slate)">JSON-LD 4블록 전부 유효 (@graph 미사용)</td></tr><tr><td class="mono">B2</td><td><b>업종 엔티티 스키마 (MedicalClinic / LegalService / LocalBusiness)</b><div class="sub">배점 5</div></td><td><span class="lv lv1">L1</span><div class="sub">Organization만</div></td><td class="sub" style="color:var(--slate)">ItemList + Organization만 존재. MedicalClinic/LocalBusiness 없음 → 주소·전화·진료시간이 구조화되어 있지 않음</td></tr><tr><td class="mono">B3</td><td><b>전문가 스키마 (Physician / Attorney)</b><div class="sub">배점 3</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">Physician/Attorney 스키마 없음. 전문가 정보는 HTML 텍스트·이미지로만 존재</td></tr><tr><td class="mono">B4</td><td><b>NAP 사이트 내 일관성</b><div class="sub">배점 3</div></td><td><span class="lv lv3">L3</span><div class="sub">단일 표기</div></td><td class="sub" style="color:var(--slate)">주소 단일 표기: "서울시 서초구 강남대로 419 파고다타워 12". 전화 6종: 02-3477-3300, 023480-3573, 02392-0330</td></tr><tr><td class="mono">B5</td><td><b>sameAs 연결</b><div class="sub">배점 2</div></td><td><span class="lv lv3">L3</span><div class="sub">5개 이상 (SNS + 버티컬 + 지도)</div></td><td class="sub" style="color:var(--slate)">sameAs 10개 (SNS·버티컬·지도 연결)</td></tr><tr><td class="mono">B6</td><td><b>canonical·중복 제거</b><div class="sub">배점 2</div></td><td><span class="lv lv2">L2</span><div class="sub">있음</div></td><td class="sub" style="color:var(--slate)">rel=canonical self-referencing 존재</td></tr><tr><td class="mono">B7</td><td><b>다국어 hreflang</b><div class="sub">배점 2</div></td><td><span class="lv lv0">L0</span><div class="sub">다국어 페이지 있으나 미연결</div></td><td class="sub" style="color:var(--slate)">다국어 페이지 흔적(https://www.wonjinbeauty.com/en/) 있으나 hreflang 없음. 미연결</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">C · 답변 인용 가능 콘텐츠 <span class="sub">패시지 단위로 잘라내도 자기완결적인 사실 덩어리가 있는가. 정의문·FAQ·수치·표·의료진 근거.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">C1</td><td><b>정의문형 첫 문단</b><div class="sub">배점 4</div></td><td><span class="lv lv0">L0</span><div class="sub">없음 (슬로건·이미지만)</div></td><td class="sub" style="color:var(--slate)">홈 본문 1,999자에 "원진성형외과는 …에 있는 … 병원입니다" 형태의 정의문 없음. 소개 문구(25년 헤리티지·강남역)는 메인 비주얼 이미지 alt 에만 있고, 소개 페이지(/hospitalinfo/about) 첫 단락도 슬로건형</td></tr><tr><td class="mono">C2</td><td><b>FAQ 섹션</b><div class="sub">배점 4</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">FAQ·자주 묻는 질문·Q&A 섹션 탐지 안 됨 (메인·시술 페이지)</td></tr><tr><td class="mono">C3</td><td><b>제목 계층 의미성</b><div class="sub">배점 3</div></td><td><span class="lv lv0">L0</span><div class="sub">H1 없음 또는 5개 이상</div></td><td class="sub" style="color:var(--slate)">메인 H1 0개: 없음. 시술 페이지 H1 없음</td></tr><tr><td class="mono">C4</td><td><b>구체 수치 (가격·시간·회복)</b><div class="sub">배점 4</div></td><td><span class="lv lv2">L2</span><div class="sub">시술별 일부 수치</div></td><td class="sub" style="color:var(--slate)">눈매교정(CFS) 페이지에 수술 시간 2시간·단기 수면 또는 부분 마취·실밥 제거 7일·일상생활 3일 텍스트 존재. 트임(오픈아이) 40분·5~7일. 쌍꺼풀·눈매교정(글램아이·더블유착) 페이지는 수치 없음. 가격 표기 0회(비급여 안내 페이지는 별도)</td></tr><tr><td class="mono">C5</td><td><b>표·비교 구조</b><div class="sub">배점 2</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">HTML <table> 없음. 비교 정보는 이미지로 추정 (이미지 표 여부는 수동 확인)</td></tr><tr><td class="mono">C6</td><td><b>신선도 신호</b><div class="sub">배점 3</div></td><td><span class="lv lv1">L1</span><div class="sub">1년 이상</div></td><td class="sub" style="color:var(--slate)">최신 갱신 신호 2025-02-18 (sitemap lastmod 2025-02-18). 18개월 전. dateModified 마크업 없음</td></tr><tr><td class="mono">C7</td><td><b>전문가 E-E-A-T (의료진 / 변호사)</b><div class="sub">배점 3</div></td><td><span class="lv lv2">L2</span><div class="sub">자격·경력 텍스트</div></td><td class="sub" style="color:var(--slate)">의료진 상세 페이지 17명의 직함·전문의 여부·경력(원장당 12~23줄)이 HTML 텍스트로 존재. 학술 활동 페이지 3,760자. 단 Physician 스키마·저자 표기는 없어 구조화되지 않음</td></tr><tr><td class="mono">C8</td><td><b>패시지 자기완결성</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 핵심 페이지 단락 샘플 10개의 상호·시술명 포함 비율 수동 채점 필요</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">D · AI 인용 표면 확보 <span class="sub">실제로 AI가 인용하는 외부 표면(강남언니·GBP·오픈웹)에 존재하는가. 네이버 자산은 글로벌 AI에 기여도 0.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">D1</td><td><b>업종 버티컬 프로필 (병의원: 강남언니 / 법률: 로톡)</b><div class="sub">배점 6</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">강남언니 프로필은 공개 페이지 수동 실측 필요 (후기 수·의료진·이벤트·갱신일). 홈페이지 sameAs 에 버티컬 링크 포함 여부는 확인됨(10개 중)</td></tr><tr><td class="mono">D2</td><td><b>Google 비즈니스 프로필</b><div class="sub">배점 4</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] Google Maps 검색으로 GBP 소유·리뷰·NAP 수동 실측 필요</td></tr><tr><td class="mono">D3</td><td><b>오픈웹 언급 (티스토리·유튜브·언론)</b><div class="sub">배점 4</div></td><td><span class="lv lv2">L2</span><div class="sub">유튜브 + 언론/티스토리</div></td><td class="sub" style="color:var(--slate)">자체 YouTube 채널 @wjwonjin 영상 736편·구독 1.5만·누적 조회 3,113만(2026-09-08 API 집계). 인스타그램 3계정·페이스북·네이버 블로그(popokpop) 존재. 채널 sameAs 10개로 홈페이지와 연결됨. 단 홈페이지 내 외부 링크는 JS 렌더라 HTML에서 미노출</td></tr><tr><td class="mono">D4</td><td><b>표면 간 NAP 완전 일치</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 홈페이지·버티컬·GBP·플레이스 NAP 대조표 수동 작성 필요</td></tr><tr><td class="mono">D5</td><td><b>네이버 플레이스 (AI 브리핑 전용)</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[semi·사람 판정 필요] 사이트에 네이버 플레이스 링크 없음. 플레이스 존재·홈페이지 역연결·리뷰 수 수동 확인</td></tr><tr><td class="mono">D6</td><td><b>위키데이터/위키백과 엔티티</b><div class="sub">배점 1</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">Wikidata에서 "원진성형외과" 검색 결과 없음</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">E · AI 가시성 측정 인프라 <span class="sub">개선을 증명할 수 있는가. 소유 신호(크롤러 로그·GA4 AI 유입) 계측 여부.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">E1</td><td><b>AI 크롤러 로그 (IP 검증)</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 서버 로그 접근 필요 (AI 크롤러 히트 + IP 검증)</td></tr><tr><td class="mono">E2</td><td><b>GA4 AI 유입 채널</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] GA4 접근 필요 (AI 유입 커스텀 채널그룹 여부)</td></tr><tr><td class="mono">E3</td><td><b>GSC AI Overviews 노출</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] GSC 접근 필요 (AI Overviews 노출)</td></tr><tr><td class="mono">E4</td><td><b>기준선 스팟체크</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 기준선 스팟체크 미실시 (질의 10개 × 5회 반복은 별도 단계)</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">F · 기술 위생 <span class="sub">이미지 대체텍스트·CMS 보안·페이지 무게·모바일. 보조 지표.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">F1</td><td><b>이미지 대체텍스트</b><div class="sub">배점 2</div></td><td><span class="lv lv0">L0</span><div class="sub">50% 이상 누락</div></td><td class="sub" style="color:var(--slate)">메인 페이지 이미지 45개 중 28개(62%) alt 누락 또는 빈 값</td></tr><tr><td class="mono">F2</td><td><b>CMS 보안·최신성</b><div class="sub">배점 1</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">Server: Microsoft-IIS/10.0, ASP.NET 계열 자체 제작 추정(meta generator 없음). CMS 버전·보안 패치 여부 판정 불가</td></tr><tr><td class="mono">F3</td><td><b>페이지 무게</b><div class="sub">배점 1</div></td><td><span class="lv lv3">L3</span><div class="sub">< 300KB</div></td><td class="sub" style="color:var(--slate)">초기 HTML 153KB (300KB 미만)</td></tr><tr><td class="mono">F4</td><td><b>모바일 대응</b><div class="sub">배점 1</div></td><td><span class="lv lv2">L2</span><div class="sub">반응형</div></td><td class="sub" style="color:var(--slate)">viewport meta 존재, 반응형. CWV 미측정</td></tr>
|
||||
</tbody></table>
|
||||
</div></section>
|
||||
|
||||
<section class="pb light"><div class="page">
|
||||
<div class="eyebrow">Action Plan</div><h2>지금 고칠 것과 4주 안에 만들 것을 나눴습니다</h2>
|
||||
<h3 style="margin-top:20px">P0 · 지금 고칠 것 (1주 안)</h3><div class="grid g2"><div class="card act"><span class="pill gray eff">1일</span><div class="pri">P0</div><h3>정본 URL 수렴 + sitemap 선언</h3><p style="margin:0 0 8px;color:var(--slate)">k-wonjin.co.kr → www.k-wonjin.co.kr 로 301 통일, robots.txt 에 Sitemap: 선언, sitemap lastmod 갱신. 전화번호를 대표번호 1개로 통일.</p><div class="sub">연결 항목 <span class="mono">A4</span> <span class="mono">A5</span> <span class="mono">B4</span></div></div><div class="card act"><span class="pill gray eff">1주</span><div class="pri">P0</div><h3>MedicalClinic + Physician 스키마</h3><p style="margin:0 0 8px;color:var(--slate)">Organization 을 MedicalClinic 으로 승격(address·telephone·openingHoursSpecification·medicalSpecialty). 의료진 17명 Physician 스키마(이름·직함·전문의·프로필 URL). 영문 사이트와 hreflang 상호 연결.</p><div class="sub">연결 항목 <span class="mono">B2</span> <span class="mono">B3</span> <span class="mono">B7</span></div></div></div><h3 style="margin-top:20px">P1 · 4주 안에 만들 것</h3><div class="grid g2"><div class="card act"><span class="pill gray eff">2~4주</span><div class="pri">P1</div><h3>시술 페이지 인용 구조: H1·표·FAQ</h3><p style="margin:0 0 8px;color:var(--slate)">시술 페이지마다 H1 1개, 수술 시간·마취·실밥·일상 복귀를 HTML 표로, FAQ 5문항 + FAQPage 스키마. CFS·트임 페이지의 수치 형식을 다른 눈·코·윤곽 페이지로 확산. 메인에 병원 정의문 한 단락.</p><div class="sub">연결 항목 <span class="mono">C1</span> <span class="mono">C2</span> <span class="mono">C3</span> <span class="mono">C4</span> <span class="mono">C5</span></div></div><div class="card act"><span class="pill gray eff">1주</span><div class="pri">P1</div><h3>이미지 alt 일괄 정비 + 갱신 신호</h3><p style="margin:0 0 8px;color:var(--slate)">메인 28개 이미지 alt 작성(무엇을 찍은 사진인지 사실만). 페이지에 dateModified 마크업, sitemap lastmod 를 실제 수정일로.</p><div class="sub">연결 항목 <span class="mono">F1</span> <span class="mono">C6</span></div></div><div class="card act"><span class="pill gray eff">2~4주</span><div class="pri">P1</div><h3>서포터즈 사이트로 오픈웹 원문 공급</h3><p style="margin:0 0 8px;color:var(--slate)">자동 빌드 미리보기(supporters-wonjin.vercel.app)의 글 6편을 병원이 검토·승인하면 색인 허용 도메인으로 발행. 유튜브 설명 영상 10편을 글에 연결. 이후 질문 뱅크 기준으로 편집 캘린더 운영.</p><div class="sub">연결 항목 <span class="mono">C2</span> <span class="mono">C4</span> <span class="mono">C5</span> <span class="mono">D3</span></div></div></div><h3 style="margin-top:20px">P2 · 이어서 할 것</h3><div class="grid g2"><div class="card act"><span class="pill gray eff">지속</span><div class="pri">P2</div><h3>표면 실측과 측정 인프라</h3><p style="margin:0 0 8px;color:var(--slate)">강남언니·GBP·네이버 플레이스 NAP 대조표 작성, GA4 AI 유입 채널그룹, GSC AI Overviews 확인, 서버 로그의 AI 크롤러 히트 계측. Bing Webmaster 등록.</p><div class="sub">연결 항목 <span class="mono">D1</span> <span class="mono">D2</span> <span class="mono">D4</span> <span class="mono">D5</span> <span class="mono">E1</span> <span class="mono">E2</span> <span class="mono">E3</span> <span class="mono">A6</span></div></div></div>
|
||||
</div></section>
|
||||
|
||||
<section class="pb dark"><div class="page">
|
||||
<div class="eyebrow">Supporters Preview</div><h2>홈페이지 자료만으로 서포터즈 사이트 초안을 만들었습니다</h2>
|
||||
<p class="lede">병원 URL 하나로 근거 수집, 글 생성, 발행 게이트, 미리보기 배포까지 자동으로 돌렸습니다. 모든 글은 의학 검토 대기 상태이고 색인은 막혀 있습니다. 병원이 확인 항목을 입력하고 검토·승인하면 코드 수정 없이 발행됩니다.</p>
|
||||
<div class="kpi" style="margin-top:18px">
|
||||
<div class="k"><b>6</b><span>글 (게이트 통과)</span></div>
|
||||
<div class="k"><b>736</b><span>유튜브 영상 집계</span></div>
|
||||
<div class="k"><b>66</b><span>사용 가능 이미지 (전후·사례 제외)</span></div>
|
||||
<div class="k"><b>17</b><span>의료진 프로필</span></div>
|
||||
</div>
|
||||
<p class="note" style="margin-top:14px">미리보기 <a href="https://supporters-wonjin.vercel.app" style="color:#C4B5FD">https://supporters-wonjin.vercel.app</a> · 병원 확인 항목 입력 <a href="https://infinith-demo.vercel.app/supporters/wonjin" style="color:#C4B5FD">infinith-demo.vercel.app/supporters/wonjin</a></p>
|
||||
</div></section>
|
||||
<section><div class="page">
|
||||
<h3 style="font-size:18px">자동 생성한 글 6편</h3>
|
||||
<table class="tbl"><thead><tr><th style="width:54px">분류</th><th>제목</th><th style="width:70px">근거 영상</th><th style="width:86px">병원 확인 대기</th><th style="width:90px">상태</th></tr></thead><tbody>
|
||||
<tr><td class="mono">A</td><td><b>원진성형외과는 어떤 병원인가요? 진료 분야·의료진 구성·위치</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/what-is-clinic">https://supporters-wonjin.vercel.app/posts/what-is-clinic</a></div></td><td>0편</td><td>5건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">B</td><td><b>원진성형외과 방문 안내: 위치·진료시간·주차·예약은 어떻게 하나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/visit-guide">https://supporters-wonjin.vercel.app/posts/visit-guide</a></div></td><td>0편</td><td>3건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">D</td><td><b>원진성형외과 윤곽은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/procedure-facial">https://supporters-wonjin.vercel.app/posts/procedure-facial</a></div></td><td>0편</td><td>5건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">D</td><td><b>원진성형외과 눈성형은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/procedure-eye">https://supporters-wonjin.vercel.app/posts/procedure-eye</a></div></td><td>3편</td><td>5건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">D</td><td><b>원진성형외과 레이저안티에이징은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/procedure-skin">https://supporters-wonjin.vercel.app/posts/procedure-skin</a></div></td><td>0편</td><td>6건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">C</td><td><b>상담에서 꼭 물어봐야 할 질문은 무엇인가요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/consult-questions">https://supporters-wonjin.vercel.app/posts/consult-questions</a></div></td><td>3편</td><td>3건</td><td><span class="pill gray">의학 검토 대기</span></td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="font-size:18px;margin-top:26px">발견한 채널과 확인 결과</h3>
|
||||
<p class="sub" style="color:var(--slate)">홈페이지 역링크, 프로필 제목·설명, 등록부, 검색 결과를 근거로 병원 것인지 판정했습니다. "확인 필요"는 병원이 맞다고 알려 주시면 연결합니다.</p>
|
||||
<table class="tbl"><thead><tr><th style="width:110px">플랫폼</th><th>확인됨</th><th>확인 필요</th></tr></thead><tbody>
|
||||
<tr><td><b>유튜브</b></td><td><a href="https://www.youtube.com/channel/UCNBaQBCok5xIViCnrGdisoQ">WJ원진성형외과</a> <span class="sub">(구독 28,300)</span><br><a href="https://www.youtube.com/@wjwonjin">WJ원진성형외과</a> <span class="sub">(구독 14,700)</span></td><td class="sub"><a href="https://www.youtube.com/channel/UCNmTm4UubrscPczrAfGhUvQ" style="color:var(--muted)">Wonjin Plastic Surgery Korea</a><br><a href="https://www.youtube.com/channel/UCk2UbMID_NjYZe7-WBLQXRw" style="color:var(--muted)">유세윤의 광고100 X 원진성형외과</a><br><a href="https://www.youtube.com/channel/UCEC21_7WvRx8cWlje4sLVlw" style="color:var(--muted)">WONJINPS</a><br><a href="https://www.youtube.com/channel/UCJuM0rYynWd9xAXmOf-dl9A" style="color:var(--muted)">WONJIN_OFFICIAL - KOREA No.1 PLASTIC SURGERY</a><br><a href="https://www.youtube.com/channel/UChnJVIjdmUeZkA40cRFMzFA" style="color:var(--muted)">원트성형외과</a></td></tr><tr><td><b>인스타그램</b></td><td><a href="https://www.instagram.com/wonjin_official/">Instagram</a><br><a href="https://www.instagram.com/wonjin_ps/">Instagram</a><br><a href="https://www.instagram.com/wj_cosmetic/">Instagram</a></td><td class="sub"><a href="https://www.instagram.com/wj_plasticsurgery/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/hamdust_/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/bjwnjn/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjin_sunnywon/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjinire/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wxxnzin/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wj_1j_/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjin/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjinps/" style="color:var(--muted)">Instagram</a></td></tr><tr><td><b>페이스북</b></td><td><a href="https://www.facebook.com/KwonjinPS">KwonjinPS</a></td><td class="sub"></td></tr><tr><td><b>네이버 블로그</b></td><td><a href="https://blog.naver.com/popokpop">popokpop</a></td><td class="sub"><a href="https://blog.naver.com/naramalssami1" style="color:var(--muted)">naramalssami1</a><br><a href="https://blog.naver.com/qeqe0420" style="color:var(--muted)">qeqe0420</a><br><a href="https://blog.naver.com/vpepwxormtfo" style="color:var(--muted)">vpepwxormtfo</a></td></tr><tr><td><b>틱톡</b></td><td><a href="https://www.tiktok.com/@wonjin_official">wonjin_official</a></td><td class="sub"></td></tr><tr><td><b>카카오톡 채널</b></td><td><a href="https://pf.kakao.com/_bxflzE">원진성형외과의원</a></td><td class="sub"></td></tr><tr><td><b>강남언니</b></td><td><a href="https://www.gangnamunni.com/hospitals/2500">원진성형외과의원 | 강남언니 - 내 선택에 확신을</a></td><td class="sub"></td></tr><tr><td><b>네이버 플레이스</b></td><td><span class="sub">찾지 못함</span></td><td class="sub"></td></tr><tr><td><b>바비톡</b></td><td><a href="https://babitalk.com/hospitals/3143">원진성형외과의원 | 후기∙가격∙이벤트 정보 - 바비톡</a></td><td class="sub"></td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="font-size:18px;margin-top:26px">병원이 확인해 주실 것 9가지</h3>
|
||||
<p class="sub" style="color:var(--slate)">홈페이지 공개 자료에 없어 병원만 답할 수 있는 값입니다. 입력 전까지 사이트에는 "확인 대기"로 표시됩니다.</p>
|
||||
<div class="grid g3" style="margin-top:10px"><div class="card" style="padding:14px 16px"><span class="mono">1</span> <b>작성자 실명·직함·공개 프로필</b></div><div class="card" style="padding:14px 16px"><span class="mono">2</span> <b>글별 의학 검토 원장과 검토일</b></div><div class="card" style="padding:14px 16px"><span class="mono">3</span> <b>지원 관계 문구</b></div><div class="card" style="padding:14px 16px"><span class="mono">4</span> <b>견적 기본 포함 항목·결제 방식</b></div><div class="card" style="padding:14px 16px"><span class="mono">5</span> <b>회복 일정표의 병원 확정값</b></div><div class="card" style="padding:14px 16px"><span class="mono">6</span> <b>재수술 정책</b></div><div class="card" style="padding:14px 16px"><span class="mono">7</span> <b>페이지 간 표기 불일치의 정답</b></div><div class="card" style="padding:14px 16px"><span class="mono">8</span> <b>진료 분야별 담당 원장</b></div><div class="card" style="padding:14px 16px"><span class="mono">9</span> <b>정식 도메인·색인 허용 시점·홈페이지 링크 위치</b></div></div>
|
||||
<p class="note">싣지 않는 것: 전후 사진, 환자 경험담, 다른 병원과의 비교, 효과 보장, 가격 금액. 이미지는 의료법 기준 규칙에 따라 전후·사례·모델·수술 장면·인증 마크를 자동 제외했습니다.</p>
|
||||
</div></section>
|
||||
|
||||
<section class="pb light"><div class="page">
|
||||
<div class="eyebrow">Integrity</div><h2>근거 없는 것은 약속하지 않습니다</h2>
|
||||
<table class="tbl" style="margin-top:12px"><thead><tr><th style="width:30%">하지 않는 것</th><th>이유</th></tr></thead><tbody>
|
||||
<tr><td><b>llms.txt 제작</b></td><td style="color:var(--slate)">Google 공식(2026-06) 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없음. 위약이다.</td></tr><tr><td><b>"스키마 넣으면 AI가 인용합니다" 약속</b></td><td style="color:var(--slate)">인과 증거 없음. 스키마는 엔티티 식별을 돕는 위생 조치로만 설명한다.</td></tr><tr><td><b>단발 조회로 순위·노출 보고</b></td><td style="color:var(--slate)">LLM 답변 분산 10~34%. 반복 측정 + 신뢰구간으로만 보고한다.</td></tr><tr><td><b>답변엔진 UI 자동 조회</b></td><td style="color:var(--slate)">OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.</td></tr><tr><td><b>네이버 블로그 증량</b></td><td style="color:var(--slate)">blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙.</td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 조건</h3>
|
||||
<ul style="color:var(--slate)"><li>실측 대상: 메인 페이지 + /contents/eye/cfs 2개 URL, JS 미실행 HTML 기준</li><li>크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청</li><li>semi/manual 항목은 자동 수집 증거만 첨부하고 미검증 처리, 분모 제외 (사람 보정 후 확정)</li><li>답변엔진 언급률은 측정하지 않았다 (단발 조회는 무효, 반복 측정은 별도 기준선 단계에서 수행)</li></ul>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 가능 범위</h3>
|
||||
<p style="color:var(--slate);margin:0 0 8px">무엇을 어떤 방법으로 재고, 무엇은 잴 수 없는지 먼저 밝힙니다. 잴 수 없는 것은 숫자를 만들지 않고 비워 둡니다.</p>
|
||||
<table class="tbl"><thead><tr><th style="width:26%">재는 것</th><th style="width:22%">방법</th><th style="width:80px">가능</th><th>비고</th></tr></thead><tbody>
|
||||
<tr><td><b>AI 답변 준비도 · 출처 준비도</b></td><td>공개 페이지 실측(크롤러·렌더링·스키마·표면)</td><td><span class="pill gray">가능</span></td><td class="sub">오너 계정이 필요한 항목(GA4·GSC·서버 로그)은 미검증으로 표시</td></tr>
|
||||
<tr><td><b>답변엔진 언급률 · 감성</b></td><td>질문 뱅크를 공식 API로 반복 조회</td><td><span class="pill gray">가능</span></td><td class="sub">ChatGPT · Perplexity 기본, 단발 조회는 쓰지 않음. 소비자 화면 자동 조회는 약관 위반이라 하지 않음</td></tr>
|
||||
<tr><td><b>ChatGPT 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">가능</span></td><td class="sub">링크에 utm_source=chatgpt.com 을 붙여 보내므로 리퍼러가 비어도 잡힌다.</td></tr><tr><td><b>Perplexity 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">일부만</span></td><td class="sub">리퍼러를 자주 비운다. 잡히는 건수는 실제 유입의 일부다. 실측 언급률과 함께 읽는다.</td></tr><tr><td><b>Gemini 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">가능</span></td><td class="sub">gemini.google.com 리퍼러로 분리된다.</td></tr><tr><td><b>Copilot 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">일부만</span></td><td class="sub">Bing 검색과 리퍼러가 섞이는 경우가 있다.</td></tr><tr><td><b>Claude 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">가능</span></td><td class="sub">claude.ai 리퍼러.</td></tr><tr><td><b>Google AI Overview · AI Mode 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">불가</span></td><td class="sub">구글 자연검색(google / organic)과 같은 리퍼러라 GA4로는 구분되지 않는다. 타사 도구의 'AI Overview 유입'도 추정치다. 가시성은 답변엔진 실측으로만 잰다.</td></tr><tr><td><b>네이버 AI 브리핑 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">불가</span></td><td class="sub">네이버 검색 리퍼러(search.naver.com)와 구분되지 않는다. 네이버 서치어드바이저 유입 키워드로 간접 확인만 가능.</td></tr><tr><td><b>기타 AI 검색 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">일부만</span></td><td class="sub">리퍼러가 있을 때만 잡힌다.</td></tr>
|
||||
<tr><td><b>AI 크롤러 방문</b></td><td>서버 로그(UA · IP 대역)</td><td><span class="pill gray">조건부</span></td><td class="sub">서포터즈 사이트는 로그로 계측 가능. 병원 사이트는 로그 접근 권한이 있을 때만</td></tr>
|
||||
<tr><td><b>전화 · 오프라인 방문</b></td><td>-</td><td><span class="pill gray">불가</span></td><td class="sub">사이트의 전화 탭 · 예약 클릭까지만 잼</td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">채점 방법</h3>
|
||||
<p style="color:var(--slate);margin:0">기준표 v1.0 36개 실측 항목(A 접근성 · B 엔티티 · C 콘텐츠 · D 표면 · E 측정 · F 위생)을 0~3 레벨로 판정하고, v2.0은 그 항목을 답변 준비도(AEO) 8개와 출처 준비도(GEO) 8개로 묶어 각 100점으로 정규화합니다. 미검증 항목은 분모에서 빼고, 치명 게이트(크롤러 차단 등)에 걸리면 상한을 둡니다. 답변엔진 언급률은 단발 조회로는 재지 않습니다.</p>
|
||||
</div></section>
|
||||
|
||||
<footer>INFINITH · AI오투오 · 문의 o2oteam@o2o.kr · 이 리포트는 공개 페이지 실측에 근거하며, 오너 계정(GA4·GSC·서버 로그)이 필요한 항목은 미검증으로 두었습니다. 법률 자문이 아닙니다.</footer>
|
||||
</body></html>
|
||||
Binary file not shown.
@ -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 ? `<section class="pb dark"><div class="page">
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 조건</h3>
|
||||
<ul style="color:var(--slate)">${result.conditions.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 가능 범위</h3>
|
||||
<p style="color:var(--slate);margin:0 0 8px">무엇을 어떤 방법으로 재고, 무엇은 잴 수 없는지 먼저 밝힙니다. 잴 수 없는 것은 숫자를 만들지 않고 비워 둡니다.</p>
|
||||
<table class="tbl"><thead><tr><th style="width:26%">재는 것</th><th style="width:22%">방법</th><th style="width:80px">가능</th><th>비고</th></tr></thead><tbody>
|
||||
<tr><td><b>AI 답변 준비도 · 출처 준비도</b></td><td>공개 페이지 실측(크롤러·렌더링·스키마·표면)</td><td><span class="pill gray">가능</span></td><td class="sub">오너 계정이 필요한 항목(GA4·GSC·서버 로그)은 미검증으로 표시</td></tr>
|
||||
<tr><td><b>답변엔진 언급률 · 감성</b></td><td>질문 뱅크를 공식 API로 반복 조회</td><td><span class="pill gray">가능</span></td><td class="sub">ChatGPT · Perplexity 기본, 단발 조회는 쓰지 않음. 소비자 화면 자동 조회는 약관 위반이라 하지 않음</td></tr>
|
||||
${(AI_CHANNELS as any).channels.map((c: any) => `<tr><td><b>${esc(c.label)} 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">${c.measurable === 'yes' ? '가능' : c.measurable === 'partial' ? '일부만' : '불가'}</span></td><td class="sub">${esc(c.note)}</td></tr>`).join('')}
|
||||
<tr><td><b>AI 크롤러 방문</b></td><td>서버 로그(UA · IP 대역)</td><td><span class="pill gray">조건부</span></td><td class="sub">서포터즈 사이트는 로그로 계측 가능. 병원 사이트는 로그 접근 권한이 있을 때만</td></tr>
|
||||
<tr><td><b>전화 · 오프라인 방문</b></td><td>-</td><td><span class="pill gray">불가</span></td><td class="sub">사이트의 전화 탭 · 예약 클릭까지만 잼</td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">채점 방법</h3>
|
||||
<p style="color:var(--slate);margin:0">기준표 v1.0 ${total}개 실측 항목(A 접근성 · B 엔티티 · C 콘텐츠 · D 표면 · E 측정 · F 위생)을 0~3 레벨로 판정하고, v2.0은 그 항목을 답변 준비도(AEO) ${v2.aeo.items.length}개와 출처 준비도(GEO) ${v2.geo.items.length}개로 묶어 각 100점으로 정규화합니다. 미검증 항목은 분모에서 빼고, 치명 게이트(크롤러 차단 등)에 걸리면 상한을 둡니다. 답변엔진 언급률은 단발 조회로는 재지 않습니다.</p>
|
||||
</div></section>
|
||||
|
||||
206
scripts/fetch_medical_tourism.py
Normal file
206
scripts/fetch_medical_tourism.py
Normal file
@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""의료관광 연결 데이터 수집 (한국관광공사 TourAPI 다국어).
|
||||
|
||||
병원 좌표를 중심으로 숙박·음식점·관광지·문화시설·쇼핑을 모으고, 진행 중인 축제를
|
||||
거리와 함께 붙여 프론트가 쓸 JSON 하나로 낸다.
|
||||
|
||||
왜 사전 수집인가
|
||||
영문 서비스는 강남 일대 커버리지가 얇고(5km 음식점 18건, 국문은 338건) 관광지의
|
||||
79%가 의료기관이라 필터링이 많이 필요하다. 그 처리를 수집 시점에 끝내고 프론트는
|
||||
정제된 결과만 그린다. 미팅 데모 중 외부 API 상태에 화면이 종속되지도 않는다.
|
||||
|
||||
무엇을 만들지 않는가
|
||||
없는 값을 채우지 않는다. 평점·리뷰·영업시간은 TourAPI 가 주지 않으므로 비워 두고,
|
||||
Google Places 승인 후 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서
|
||||
판정하지 않는다.
|
||||
|
||||
python3 scripts/fetch_medical_tourism.py
|
||||
python3 scripts/fetch_medical_tourism.py --radius 5000 --out src/data/medicalTourism.json
|
||||
"""
|
||||
import argparse, io, json, math, os, ssl, sys, time, urllib.parse, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 영문 관광지(76)의 대부분은 관광지가 아니라 의료관광 등록업소다. 반경 5km 100건 중 92건이
|
||||
# 분류코드 A02020500 = "Medical Tourism Sites"(categoryCode2 로 확인)이며 병원과 유치 에이전시가
|
||||
# 여기 들어 있다. 제목 키워드로는 "하이안과", "닥파인더코리아" 같은 것을 놓치므로 코드로 거른다.
|
||||
CAT_MEDICAL_TOURISM = "A02020500"
|
||||
# 스파·찜질방은 수술 후 회복 여정에 맞으므로 관광이 아니라 별도 범주로 뺀다.
|
||||
CAT_WELLNESS = ("A02020300", "A02020400") # Hot Springs & Spa, Jjimjilbang
|
||||
|
||||
# 다국어 ContentTypeId. 국문과 코드가 다르다.
|
||||
# 반경은 목적에 따라 다르다. 회복기에 매일 가는 곳(숙박·식사·스파)은 가깝게, 관광은 넓게 본다.
|
||||
TYPES = {
|
||||
"76": ("attraction", "10000"),
|
||||
"78": ("culture", "10000"),
|
||||
"79": ("shopping", "10000"),
|
||||
"80": ("stay", "5000"),
|
||||
"82": ("restaurant", "5000"),
|
||||
}
|
||||
|
||||
|
||||
def ssl_ctx():
|
||||
try:
|
||||
import certifi; return ssl.create_default_context(cafile=certifi.where())
|
||||
except Exception:
|
||||
return ssl.create_default_context()
|
||||
|
||||
|
||||
def load_env(path):
|
||||
d = {}
|
||||
for line in io.open(path, encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1); d[k.strip()] = v.strip()
|
||||
return d
|
||||
|
||||
|
||||
def haversine_km(lat1, lng1, lat2, lng2):
|
||||
r = 6371.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def call(env, op, extra, rows=100, retries=3, page=1):
|
||||
base, svc, key = env["TOUR_API_BASE"], env["TOUR_API_SERVICE"], env["TOUR_API_KEY"]
|
||||
p = {"MobileOS": "ETC", "MobileApp": "INFINITH", "_type": "json",
|
||||
"numOfRows": str(rows), "pageNo": str(page), **extra}
|
||||
url = f"{base}/{svc}/{op}?serviceKey={key}&" + urllib.parse.urlencode(p)
|
||||
for i in range(retries):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30, context=ssl_ctx()) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
if body.lstrip().startswith("<"):
|
||||
raise RuntimeError(f"XML 응답(인증·한도 확인): {body[:160]}")
|
||||
j = json.loads(body)["response"]
|
||||
if j["header"].get("resultCode") != "0000":
|
||||
raise RuntimeError(f"{j['header'].get('resultCode')} {j['header'].get('resultMsg')}")
|
||||
b = j.get("body", {}); items = b.get("items")
|
||||
return b.get("totalCount", 0), ([] if not items or items == "" else items["item"])
|
||||
except Exception as e:
|
||||
if i == retries - 1: raise
|
||||
time.sleep(1.5 * (i + 1))
|
||||
|
||||
|
||||
def call_all(env, op, extra, max_pages=6, rows=100):
|
||||
"""전 페이지를 모은다. 관광지는 거리순 상위 100건이 거의 다 의료관광업소라 한 페이지로는 부족하다."""
|
||||
got, total = [], None
|
||||
for page in range(1, max_pages + 1):
|
||||
total, items = call(env, op, extra, rows=rows, page=page)
|
||||
got += items
|
||||
if not items or len(got) >= (total or 0): break
|
||||
time.sleep(0.2)
|
||||
return total, got
|
||||
|
||||
|
||||
def bucket(it):
|
||||
"""분류코드로 범주를 정한다. None 이면 버린다."""
|
||||
cat3 = (it.get("cat3") or "").strip()
|
||||
if cat3 == CAT_MEDICAL_TOURISM: return None
|
||||
if cat3 in CAT_WELLNESS: return "wellness"
|
||||
return "keep"
|
||||
|
||||
|
||||
def clean(it, lat, lng):
|
||||
"""TourAPI 원본에서 화면이 쓸 필드만 남긴다. mapx=경도, mapy=위도 (이름과 반대라 자주 틀린다)."""
|
||||
try:
|
||||
ilng, ilat = float(it.get("mapx") or 0), float(it.get("mapy") or 0)
|
||||
except ValueError:
|
||||
ilng = ilat = 0.0
|
||||
dist = it.get("dist")
|
||||
return {
|
||||
"id": it.get("contentid"),
|
||||
"title": (it.get("title") or "").strip(),
|
||||
"address": (it.get("addr1") or "").strip(),
|
||||
# TourAPI 는 이미지 URL 을 http 로 준다. https 사이트에서 혼합 콘텐츠로 차단되므로 올린다.
|
||||
# tong.visitkorea.or.kr 은 https 로도 같은 파일을 준다(실측 확인).
|
||||
"image": (lambda u: u.replace("http://", "https://", 1) if u else None)(
|
||||
it.get("firstimage") or it.get("firstimage2") or None),
|
||||
"tel": (it.get("tel") or "").strip() or None,
|
||||
"lat": ilat or None, "lng": ilng or None,
|
||||
"distanceM": round(float(dist)) if dist else (
|
||||
round(haversine_km(lat, lng, ilat, ilng) * 1000) if ilat and ilng else None),
|
||||
# TourAPI 가 주지 않는 값. Google Places 승인 후 채운다. 지금은 비워 둔다.
|
||||
"rating": None, "reviewCount": None, "openingHours": None,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다")
|
||||
ap.add_argument("--per-type", type=int, default=24, help="타입별 최대 건수")
|
||||
ap.add_argument("--out", default="src/data/medicalTourism.json")
|
||||
a = ap.parse_args()
|
||||
|
||||
env = load_env(os.path.join(ROOT, ".env"))
|
||||
for k in ("TOUR_API_KEY", "TOUR_API_BASE", "TOUR_API_SERVICE", "TOUR_API_ORIGIN_LAT", "TOUR_API_ORIGIN_LNG"):
|
||||
if not env.get(k): sys.exit(f".env 에 {k} 가 비어 있습니다")
|
||||
lat, lng = float(env["TOUR_API_ORIGIN_LAT"]), float(env["TOUR_API_ORIGIN_LNG"])
|
||||
|
||||
out = {"places": {}, "festivals": [], "meta": {}}
|
||||
filtered_log = {}
|
||||
|
||||
wellness = []
|
||||
for ct, (name, radius) in TYPES.items():
|
||||
total, rows = call_all(env, "locationBasedList2",
|
||||
{"mapX": str(lng), "mapY": str(lat), "radius": radius,
|
||||
"contentTypeId": ct, "arrange": "E"})
|
||||
kept, dropped = [], []
|
||||
for it in rows:
|
||||
b = bucket(it)
|
||||
if b is None:
|
||||
dropped.append((it.get("title") or "").strip()); continue
|
||||
(wellness if b == "wellness" else kept).append(clean(it, lat, lng))
|
||||
kept.sort(key=lambda x: x["distanceM"] if x["distanceM"] is not None else 10 ** 9)
|
||||
out["places"][name] = kept[:a.per_type]
|
||||
filtered_log[name] = {"apiTotal": total, "fetched": len(rows), "radiusM": int(radius),
|
||||
"droppedMedicalTourism": len(dropped), "kept": len(kept),
|
||||
"shown": len(out["places"][name]), "droppedSample": dropped[:5]}
|
||||
print(f" {name:<11} r={radius:>5}m · API {total:>4} · 받음 {len(rows):>3} · 의료관광업소 제외 {len(dropped):>3} · 담음 {len(out['places'][name])}")
|
||||
wellness.sort(key=lambda x: x["distanceM"] if x["distanceM"] is not None else 10 ** 9)
|
||||
out["places"]["wellness"] = wellness[:a.per_type]
|
||||
filtered_log["wellness"] = {"note": "관광지(76)에서 스파·찜질방 분류만 분리", "shown": len(out["places"]["wellness"])}
|
||||
print(f" {'wellness':<11} 스파·찜질방 분리 · 담음 {len(out['places']['wellness'])}")
|
||||
|
||||
# 영문 축제는 areacode 필드가 비어 있어 지역 필터를 쓸 수 없다. 좌표로 거리를 계산해 거른다.
|
||||
today = time.strftime("%Y%m%d")
|
||||
total, rows = call(env, "searchFestival2", {"eventStartDate": today, "arrange": "A"}, rows=300)
|
||||
near, nocoord = [], 0
|
||||
for it in rows:
|
||||
c = clean(it, lat, lng)
|
||||
if c["lat"] is None: nocoord += 1; continue
|
||||
km = haversine_km(lat, lng, c["lat"], c["lng"])
|
||||
if km > a.festival_km: continue
|
||||
c.update(startDate=it.get("eventstartdate"), endDate=it.get("eventenddate"),
|
||||
distanceKm=round(km, 1))
|
||||
near.append(c)
|
||||
near.sort(key=lambda x: x["startDate"] or "")
|
||||
out["festivals"] = near[:a.per_type]
|
||||
print(f" festival API {total:>4}건 · 받음 {len(rows):>3} · 좌표없음 {nocoord} · {a.festival_km:.0f}km 이내 {len(near)}")
|
||||
|
||||
out["meta"] = {
|
||||
"source": "한국관광공사 TourAPI " + env["TOUR_API_SERVICE"],
|
||||
"fetchedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"origin": {"lat": lat, "lng": lng, "label": "뷰성형외과의원 (봉은사로 107)",
|
||||
"coordSource": "Naver 지역검색 실측 2026-09-09"},
|
||||
"radiusByType": {n: int(r) for n, r in TYPES.values()}, "festivalRadiusKm": a.festival_km,
|
||||
"counts": filtered_log,
|
||||
# 화면이 한계를 그대로 표시할 수 있게 데이터에 적어 둔다. 없는 값을 채우지 않는다.
|
||||
"limits": [
|
||||
"영문 서비스는 강남 일대 숙박·음식점 커버리지가 얇다(반경 5km 숙박 4건·음식점 18건, 국문은 43건·338건).",
|
||||
"관광지 항목의 대부분(반경 5km 100건 중 92건)이 분류코드 A02020500 \"Medical Tourism Sites\" 였고 제외했다.",
|
||||
"평점·리뷰·영업시간은 TourAPI 가 제공하지 않는다. Google Places 승인 후 채운다.",
|
||||
"영문 축제는 areacode 필드가 비어 있어 좌표 거리로 걸렀다.",
|
||||
"수술 전후 식이 적합성은 병원이 입력한 가이드로만 표시한다. 이 파일에는 없다.",
|
||||
],
|
||||
}
|
||||
p = os.path.join(ROOT, a.out)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
io.open(p, "w", encoding="utf-8").write(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
print(f"\n→ {a.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
149
scripts/ga4_ai_traffic.py
Normal file
149
scripts/ga4_ai_traffic.py
Normal file
@ -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 <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}")
|
||||
@ -40,6 +40,7 @@ const FIELDS: Array<{ key: string; title: string; hint: string; inputs: Array<{
|
||||
{ key: 'sponsorship', title: '3. 지원 관계 문구', hint: '모든 글 첫 부분에 그대로 실립니다. 예: 이 글은 OO의원의 지원(광고비)을 받아 OO 서포터즈가 작성했습니다.', inputs: [{ name: 'notice', label: '고지 문장', type: 'textarea' }] },
|
||||
{ key: 'quote_items', title: '4. 견적 기본 포함 항목과 결제 방식', hint: '금액은 받지 않습니다. 항목과 방식만 적어 주세요.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'recovery', title: '5. 회복 일정표의 병원 확정값', hint: '출근 기준, 걷기 시작 시점 등 공개 자료에 없는 값.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'diet_guide', title: '5b. 수술 전후 식이 가이드', hint: '의료관광 연결의 음식점 추천에 그대로 실립니다. 개별 식당의 적합 여부는 저희가 판정하지 않고, 여기 적힌 분류만 표시합니다.', inputs: [{ name: 'recommended', label: '권장 음식 분류 (회복 단계별)', type: 'textarea' }, { name: 'avoid', label: '피해야 할 음식 분류', type: 'textarea' }, { name: 'stageDays', label: '각 단계가 수술 후 며칠인지' }] },
|
||||
{ key: 'revision_policy', title: '6. 재수술 정책', hint: '다른 병원 수술의 상담 가능 여부, 부위별 대기 기간.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'discrepancy', title: '7. 페이지 간 표기 불일치의 정답', hint: '예: 코 실밥 제거일이 코성형 페이지 7일, 애프터케어 3일로 다릅니다.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'specialty_doctors', title: '8. 진료 분야별 담당 원장', hint: '아래 의료진 목록의 ID 를 적습니다. 글별 검토자 후보가 됩니다.', inputs: [{ name: 'eye', label: '눈' }, { name: 'nose', label: '코' }, { name: 'contour', label: '윤곽' }, { name: 'lifting', label: '리프팅' }, { name: 'breast', label: '가슴' }, { name: 'anesthesia', label: '마취' }] },
|
||||
|
||||
449
src/components/discovery/MedicalTourismPanel.tsx
Normal file
449
src/components/discovery/MedicalTourismPanel.tsx
Normal file
@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 의료관광 연결 패널 (/discovery/:id)
|
||||
*
|
||||
* 외국인 환자가 수술 전후에 쓸 주변 정보를 한 곳에 모은다.
|
||||
* 숙박 · 음식점 · 웰니스(스파) · 관광 · 쇼핑 · 축제, 그리고 이동 반경 기준 회복 동선.
|
||||
*
|
||||
* 데이터는 `scripts/fetch_medical_tourism.py` 가 한국관광공사 TourAPI 다국어(영문)에서
|
||||
* 미리 수집한 `src/data/medicalTourism.json` 이다. 사전 수집인 이유는 영문 커버리지가
|
||||
* 얇아 필터링이 많이 필요하고, 시연 중 외부 API 상태에 화면이 종속되지 않게 하기 위해서다.
|
||||
*
|
||||
* 만들지 않는 것
|
||||
* - 평점·리뷰·영업시간: TourAPI 미제공이므로 "미연동"으로 표시하고 비워 둔다.
|
||||
* - 수술 전후 식이 적합성: 병원이 supporter_inputs('diet_guide')로 입력한 값만 쓴다.
|
||||
* 입력 전에는 "병원 확인 대기"로 둔다. 개별 식당의 적합 여부를 추론하지 않는다.
|
||||
* - 회복 단계가 며칠째인지: 의학 판단이라 병원 입력('recovery')을 따른다.
|
||||
* 화면의 단계는 이동 반경으로만 정의한다.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import data from '../../data/medicalTourism.json';
|
||||
import type {
|
||||
MedicalTourismData,
|
||||
PlaceCategory,
|
||||
RecoveryStage,
|
||||
TourPlace,
|
||||
} from '../../types/medicalTourism';
|
||||
import {
|
||||
BedFilled,
|
||||
ForkFilled,
|
||||
SpaFilled,
|
||||
MapPinFilled,
|
||||
TicketFilled,
|
||||
BagFilled,
|
||||
TheaterFilled,
|
||||
} from '../icons/FilledIcons';
|
||||
|
||||
const TOURISM = data as unknown as MedicalTourismData;
|
||||
|
||||
const CATEGORY_META: Record<
|
||||
PlaceCategory,
|
||||
{ label: string; en: string; Icon: typeof BedFilled }
|
||||
> = {
|
||||
stay: { label: '숙박', en: 'Stay', Icon: BedFilled },
|
||||
restaurant: { label: '음식점', en: 'Dining', Icon: ForkFilled },
|
||||
wellness: { label: '웰니스', en: 'Wellness', Icon: SpaFilled },
|
||||
attraction: { label: '관광', en: 'Attractions', Icon: MapPinFilled },
|
||||
culture: { label: '문화', en: 'Culture', Icon: TheaterFilled },
|
||||
shopping: { label: '쇼핑', en: 'Shopping', Icon: BagFilled },
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: PlaceCategory[] = [
|
||||
'stay',
|
||||
'restaurant',
|
||||
'wellness',
|
||||
'attraction',
|
||||
'culture',
|
||||
'shopping',
|
||||
];
|
||||
|
||||
/**
|
||||
* 회복 동선. 며칠째인지는 병원이 정한다(§ClinicInputsPanel 'recovery').
|
||||
* 여기서는 이동 반경만 정의하므로 의학적 판단을 담지 않는다.
|
||||
*/
|
||||
const STAGES: RecoveryStage[] = [
|
||||
{
|
||||
id: 'near',
|
||||
label: '병원 도보권',
|
||||
maxDistanceM: 1000,
|
||||
categories: ['stay', 'restaurant'],
|
||||
note: '내원이 잦은 기간에 머무는 범위입니다.',
|
||||
},
|
||||
{
|
||||
id: 'short',
|
||||
label: '근거리 이동',
|
||||
maxDistanceM: 3000,
|
||||
categories: ['restaurant', 'wellness'],
|
||||
note: '짧은 외출이 가능해지는 범위입니다.',
|
||||
},
|
||||
{
|
||||
id: 'city',
|
||||
label: '시내 이동',
|
||||
maxDistanceM: 10000,
|
||||
categories: ['attraction', 'culture', 'shopping'],
|
||||
note: '관광 일정을 넣을 수 있는 범위입니다.',
|
||||
},
|
||||
];
|
||||
|
||||
function fmtDistance(m: number | null) {
|
||||
if (m === null) return '거리 미상';
|
||||
return m < 1000 ? `${m}m` : `${(m / 1000).toFixed(1)}km`;
|
||||
}
|
||||
|
||||
function fmtDate(d: string | null) {
|
||||
if (!d || d.length !== 8) return '';
|
||||
return `${d.slice(0, 4)}.${d.slice(4, 6)}.${d.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
export function MedicalTourismPanel({ clinicId }: { clinicId: string }) {
|
||||
const [tab, setTab] = useState<PlaceCategory | 'festival' | 'route'>('route');
|
||||
const [clinicInput, setClinicInput] = useState<{
|
||||
recovery?: string;
|
||||
diet?: Record<string, string>;
|
||||
}>({});
|
||||
|
||||
// 병원이 입력한 회복·식이 값을 읽는다. 같은 clinic_id 의 최신 행이 유효값이다.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
const { data: rows } = await supabase
|
||||
.from('supporter_inputs')
|
||||
.select('key,value,created_at')
|
||||
.eq('clinic_id', clinicId)
|
||||
.in('key', ['recovery', 'diet_guide'])
|
||||
.order('created_at', { ascending: true });
|
||||
if (!alive || !rows) return;
|
||||
const latest = new Map<string, Record<string, string>>();
|
||||
for (const r of rows) latest.set(r.key, r.value as Record<string, string>);
|
||||
setClinicInput({
|
||||
recovery: latest.get('recovery')?.answer,
|
||||
diet: latest.get('diet_guide'),
|
||||
});
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [clinicId]);
|
||||
|
||||
// 단계는 누적이 아니라 구간이다. 누적으로 하면 가까운 곳이 모든 단계에 중복으로 뜬다.
|
||||
const stageBuckets = useMemo(
|
||||
() =>
|
||||
STAGES.map((s, i) => {
|
||||
const minM = i === 0 ? 0 : STAGES[i - 1].maxDistanceM;
|
||||
return {
|
||||
stage: s,
|
||||
minM,
|
||||
items: s.categories
|
||||
.flatMap((c) =>
|
||||
(TOURISM.places[c] ?? []).map((p) => ({ ...p, category: c })),
|
||||
)
|
||||
.filter(
|
||||
(p) =>
|
||||
p.distanceM !== null &&
|
||||
p.distanceM > minM &&
|
||||
p.distanceM <= s.maxDistanceM,
|
||||
)
|
||||
.sort((a, b) => (a.distanceM ?? 0) - (b.distanceM ?? 0))
|
||||
.slice(0, 6),
|
||||
};
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const totalPlaces =
|
||||
CATEGORY_ORDER.reduce((n, c) => n + (TOURISM.places[c]?.length ?? 0), 0) +
|
||||
TOURISM.festivals.length;
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
id="medical-tourism"
|
||||
title="Medical Tourism Connect"
|
||||
subtitle={`외국인 환자의 수술 전후 동선을 병원 반경 기준으로 연결합니다. 한국관광공사 TourAPI 영문 데이터 ${totalPlaces}건`}
|
||||
dark
|
||||
>
|
||||
{/* 출처와 기준점 */}
|
||||
<div className="mb-8 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/10 p-5">
|
||||
<div className="grid gap-4 md:grid-cols-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-purple-300 mb-1">기준 좌표</div>
|
||||
<div className="text-white/90">{TOURISM.meta.origin.label}</div>
|
||||
<div className="text-purple-200 text-xs mt-0.5">
|
||||
{TOURISM.meta.origin.lat}, {TOURISM.meta.origin.lng} · {TOURISM.meta.origin.coordSource}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-purple-300 mb-1">데이터 출처</div>
|
||||
<div className="text-white/90">{TOURISM.meta.source}</div>
|
||||
<div className="text-purple-200 text-xs mt-0.5">
|
||||
수집 {TOURISM.meta.fetchedAt.replace('T', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-purple-300 mb-1">수집 반경</div>
|
||||
<div className="text-white/90">
|
||||
숙박·식사 5km · 관광·문화·쇼핑 10km
|
||||
</div>
|
||||
<div className="text-purple-200 text-xs mt-0.5">
|
||||
축제 {TOURISM.meta.festivalRadiusKm}km 이내
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex flex-wrap gap-2 mb-8" data-no-print>
|
||||
<TabButton active={tab === 'route'} onClick={() => setTab('route')}>
|
||||
회복 동선
|
||||
</TabButton>
|
||||
{CATEGORY_ORDER.map((c) => (
|
||||
<TabButton key={c} active={tab === c} onClick={() => setTab(c)}>
|
||||
{CATEGORY_META[c].label} {TOURISM.places[c]?.length ?? 0}
|
||||
</TabButton>
|
||||
))}
|
||||
<TabButton active={tab === 'festival'} onClick={() => setTab('festival')}>
|
||||
축제 {TOURISM.festivals.length}
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{tab === 'route' && (
|
||||
<div className="space-y-5">
|
||||
<ClinicGate
|
||||
title="회복 단계별 일수"
|
||||
filled={clinicInput.recovery}
|
||||
waiting="수술별로 각 단계가 며칠인지는 병원이 정합니다. 아래 '병원 확인 항목'의 회복 일정 칸에 입력하면 이 동선에 날짜가 붙습니다."
|
||||
/>
|
||||
{stageBuckets.map(({ stage, minM, items }, i) => (
|
||||
<motion.div
|
||||
key={stage.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3 mb-1 flex-wrap">
|
||||
<h3 className="text-lg font-bold text-[#0A1128]">{stage.label}</h3>
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
병원 반경 {minM === 0 ? '' : `${fmtDistance(minM)} ~ `}
|
||||
{fmtDistance(stage.maxDistanceM)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-4">{stage.note}</p>
|
||||
{items.length === 0 ? (
|
||||
<EmptyNote>이 반경에 담긴 영문 데이터가 없습니다.</EmptyNote>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
<PlaceCard key={`${p.category}-${p.id}`} place={p} category={p.category} compact />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab !== 'route' && tab !== 'festival' && (
|
||||
<>
|
||||
{tab === 'restaurant' && (
|
||||
<div className="mb-5">
|
||||
<ClinicGate
|
||||
title="수술 전후 식이 가이드"
|
||||
filled={clinicInput.diet?.recommended}
|
||||
waiting="어떤 음식이 회복에 맞는지는 의학 판단이라 병원이 정합니다. 병원이 권장·회피 분류를 입력하면 이 목록에 표시됩니다. 개별 식당의 적합 여부를 저희가 판정하지 않습니다."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PlaceGrid items={TOURISM.places[tab] ?? []} category={tab} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'festival' && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{TOURISM.festivals.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow overflow-hidden"
|
||||
>
|
||||
<div className="h-36 bg-slate-100 flex items-center justify-center overflow-hidden">
|
||||
{f.image ? (
|
||||
<img src={f.image} alt="" className="w-full h-36 object-cover" loading="lazy" />
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">사진 없음</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[#6C5CE7]">
|
||||
<TicketFilled size={16} />
|
||||
<span className="text-xs font-semibold">{f.distanceKm}km</span>
|
||||
</div>
|
||||
<h4 className="text-base font-bold text-[#0A1128] mb-1.5">{f.title}</h4>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
{fmtDate(f.startDate)} ~ {fmtDate(f.endDate)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">{f.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 한계를 숨기지 않는다 */}
|
||||
<div className="mt-10 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs font-semibold tracking-wider text-purple-300 mb-3">
|
||||
측정 가능 범위
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{TOURISM.meta.limits.map((l) => (
|
||||
<li key={l} className="text-sm text-white/80 flex gap-2">
|
||||
<span className="text-purple-300 shrink-0">·</span>
|
||||
<span>{l}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
key?: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white'
|
||||
: 'bg-white/10 text-white/80 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceGrid({ items, category }: { items: TourPlace[]; category: PlaceCategory }) {
|
||||
if (items.length === 0) return <EmptyNote>영문 데이터가 없습니다.</EmptyNote>;
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
<PlaceCard key={p.id} place={p} category={category} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceCard({
|
||||
place,
|
||||
category,
|
||||
compact = false,
|
||||
}: {
|
||||
key?: string;
|
||||
place: TourPlace;
|
||||
category: PlaceCategory;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { Icon, label } = CATEGORY_META[category];
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-2xl overflow-hidden transition-shadow h-full flex flex-col ${
|
||||
compact
|
||||
? 'border border-slate-100 shadow-sm hover:shadow-[3px_4px_12px_rgba(0,0,0,0.06)]'
|
||||
: 'shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
|
||||
}`}
|
||||
>
|
||||
{!compact && (
|
||||
// 이미지가 없는 항목이 섞여 그리드가 어긋나지 않도록 자리를 항상 차지하게 둔다.
|
||||
<div className="h-36 bg-slate-100 flex items-center justify-center overflow-hidden">
|
||||
{place.image ? (
|
||||
<img src={place.image} alt="" className="w-full h-36 object-cover" loading="lazy" />
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">사진 없음</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex-1 ${compact ? 'p-4' : 'p-5'}`}>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[#6C5CE7]">
|
||||
<Icon size={16} />
|
||||
<span className="text-xs font-semibold">{label}</span>
|
||||
<span className="text-xs text-slate-400">· {fmtDistance(place.distanceM)}</span>
|
||||
</div>
|
||||
<h4 className={`font-bold text-[#0A1128] mb-1.5 ${compact ? 'text-sm' : 'text-base'}`}>
|
||||
{place.title}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 mb-2">{place.address}</p>
|
||||
{/* TourAPI 미제공 항목. 채우지 않고 미연동으로 둔다. */}
|
||||
<div className="text-xs text-slate-400">
|
||||
{place.rating === null ? '평점·영업시간 미연동' : `평점 ${place.rating}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 병원이 채워야 하는 값의 자리. 값이 없으면 비운 채로 무엇이 필요한지 밝힌다. */
|
||||
function ClinicGate({
|
||||
title,
|
||||
filled,
|
||||
waiting,
|
||||
}: {
|
||||
title: string;
|
||||
filled?: string;
|
||||
waiting: string;
|
||||
}) {
|
||||
const has = Boolean(filled && filled.trim());
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-5"
|
||||
style={
|
||||
has
|
||||
? { background: '#F3F0FF', borderColor: '#D5CDF5' }
|
||||
: { background: '#FFF6ED', borderColor: '#F5E0C5' }
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full"
|
||||
style={{ background: has ? '#9B8AD4' : '#D4A872' }}
|
||||
/>
|
||||
<span className="text-sm font-bold" style={{ color: has ? '#4A3A7C' : '#7C5C3A' }}>
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||
style={
|
||||
has
|
||||
? { background: '#fff', color: '#4A3A7C' }
|
||||
: { background: '#fff', color: '#7C5C3A' }
|
||||
}
|
||||
>
|
||||
{has ? '병원 입력됨' : '병원 확인 대기'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: has ? '#4A3A7C' : '#7C5C3A' }}>
|
||||
{has ? filled : waiting}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyNote({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white/10 border border-white/10 px-4 py-3 text-sm text-white/70">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -324,3 +324,76 @@ export function PrismFilled({ size = 20, className = '' }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 의료관광 연결 (숙박·식사·웰니스·관광·축제) ── */
|
||||
|
||||
export function BedFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="2" y="10" width="20" height="8" rx="2" fill="currentColor" opacity="0.25" />
|
||||
<rect x="4" y="6" width="16" height="5" rx="2" fill="currentColor" opacity="0.5" />
|
||||
<circle cx="7.5" cy="9" r="2" fill="currentColor" />
|
||||
<rect x="2" y="17" width="3" height="3" rx="1" fill="currentColor" />
|
||||
<rect x="19" y="17" width="3" height="3" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForkFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8 4v6a2 2 0 0 0 2 2v8h1.5v-8a2 2 0 0 0 2-2V4H12v5h-1V4H9.5v5h-1V4H8z" fill="currentColor" />
|
||||
<path d="M17 4c-1.4 1-2 2.8-2 4.5 0 1.4.6 2.4 1.5 2.9V20H18V4h-1z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpaFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M12 5c2.2 1.8 3.4 4 3.4 6.2 0 1.3-.4 2.4-1.1 3.2C13.4 13.2 12.7 11.4 12 9c-.7 2.4-1.4 4.2-2.3 5.4-.7-.8-1.1-1.9-1.1-3.2C8.6 9 9.8 6.8 12 5z" fill="currentColor" />
|
||||
<ellipse cx="12" cy="17.5" rx="5.5" ry="1.6" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapPinFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M12 2c-3.9 0-7 3.1-7 7 0 5.2 7 13 7 13s7-7.8 7-13c0-3.9-3.1-7-7-7z" fill="currentColor" opacity="0.25" />
|
||||
<circle cx="12" cy="9" r="3.2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2.5a2.5 2.5 0 0 0 0 5V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2.5a2.5 2.5 0 0 0 0-5V7z" fill="currentColor" opacity="0.25" />
|
||||
<rect x="11.2" y="7" width="1.6" height="3" rx="0.8" fill="currentColor" />
|
||||
<rect x="11.2" y="12" width="1.6" height="5" rx="0.8" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BagFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M4 8h16l-1.2 12H5.2L4 8z" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8.5 9V7a3.5 3.5 0 1 1 7 0v2h-2V7a1.5 1.5 0 1 0-3 0v2h-2z" fill="currentColor" />
|
||||
<rect x="4" y="7" width="16" height="2.4" rx="1.2" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TheaterFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="3" y="4" width="18" height="14" rx="3" fill="currentColor" opacity="0.25" />
|
||||
<path d="M7 9.5h10v2H7zM7 13h6v2H7z" fill="currentColor" />
|
||||
<rect x="8" y="18" width="8" height="2" rx="1" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
1797
src/data/medicalTourism.json
Normal file
1797
src/data/medicalTourism.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@ import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
|
||||
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
|
||||
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
|
||||
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
|
||||
import { MedicalTourismPanel } from '../components/discovery/MedicalTourismPanel';
|
||||
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
|
||||
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
|
||||
import type {
|
||||
@ -358,6 +359,9 @@ export default function DiscoveryReportPage() {
|
||||
{/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */}
|
||||
<ClinicInputsPanel clinicId={result.id} />
|
||||
|
||||
{/* ── 5c. 의료관광 연결 (dark) ── */}
|
||||
<MedicalTourismPanel clinicId={result.id} />
|
||||
|
||||
{/* ── 6. 채점 기준표 (light) ── */}
|
||||
<SectionWrapper
|
||||
id="rubric"
|
||||
|
||||
88
src/types/medicalTourism.ts
Normal file
88
src/types/medicalTourism.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 의료관광 연결 기능 타입.
|
||||
*
|
||||
* 데이터 출처는 한국관광공사 TourAPI 다국어(영문) 이며 `scripts/fetch_medical_tourism.py`
|
||||
* 가 수집·필터링해 `src/data/medicalTourism.json` 으로 낸다.
|
||||
*
|
||||
* 원칙: 없는 값을 만들지 않는다.
|
||||
* - rating/reviewCount/openingHours 는 TourAPI 가 주지 않는다. Google Places 승인 후 채운다.
|
||||
* 그때까지 null 이며 화면은 빈 값을 그대로 "미연동"으로 보여준다.
|
||||
* - 수술 전후 식이 적합성은 병원이 입력한 가이드로만 표시한다. 여기서 추론하지 않는다.
|
||||
*/
|
||||
|
||||
/** 병원 반경 안에서 찾은 장소 한 곳. */
|
||||
export interface TourPlace {
|
||||
id: string;
|
||||
title: string;
|
||||
address: string;
|
||||
image: string | null;
|
||||
tel: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
/** 병원으로부터의 거리(m). TourAPI 가 준 값을 그대로 쓰고, 없으면 좌표로 계산한다. */
|
||||
distanceM: number | null;
|
||||
/** 아래 셋은 TourAPI 미제공. Google Places 승인 전까지 null 이다. */
|
||||
rating: number | null;
|
||||
reviewCount: number | null;
|
||||
openingHours: string | null;
|
||||
}
|
||||
|
||||
/** 축제·행사. 영문 서비스는 areacode 가 비어 있어 좌표 거리로 걸렀다. */
|
||||
export interface TourFestival extends TourPlace {
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
distanceKm: number;
|
||||
}
|
||||
|
||||
export type PlaceCategory =
|
||||
| 'stay'
|
||||
| 'restaurant'
|
||||
| 'wellness'
|
||||
| 'attraction'
|
||||
| 'culture'
|
||||
| 'shopping';
|
||||
|
||||
export interface MedicalTourismData {
|
||||
places: Record<PlaceCategory, TourPlace[]>;
|
||||
festivals: TourFestival[];
|
||||
meta: {
|
||||
source: string;
|
||||
fetchedAt: string;
|
||||
origin: { lat: number; lng: number; label: string; coordSource: string };
|
||||
radiusByType: Record<string, number>;
|
||||
festivalRadiusKm: number;
|
||||
counts: Record<string, Record<string, unknown>>;
|
||||
/** 화면에 그대로 표시한다. 한계를 숨기지 않는다. */
|
||||
limits: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 회복 단계. 며칠째인지는 의학 판단이므로 여기서 정하지 않는다.
|
||||
* 이 단계는 "병원에서 얼마나 멀리 움직이는가"라는 이동 반경 기준이며,
|
||||
* 각 단계가 수술 후 며칠에 해당하는지는 병원이 supporter_inputs 로 입력한다.
|
||||
*/
|
||||
export interface RecoveryStage {
|
||||
id: string;
|
||||
label: string;
|
||||
/** 이 단계에서 권하는 이동 반경(m). 거리로만 정의하며 의학적 판단을 담지 않는다. */
|
||||
maxDistanceM: number;
|
||||
/** 이 단계에 배치할 장소 범주. */
|
||||
categories: PlaceCategory[];
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** 병원이 입력하는 값. 없으면 화면은 "병원 확인 대기"로 둔다. */
|
||||
export interface ClinicRecoveryInput {
|
||||
/** supporter_inputs.key = 'recovery' */
|
||||
recoveryNote?: string;
|
||||
/** supporter_inputs.key = 'diet_guide' */
|
||||
dietGuide?: {
|
||||
/** 회복 단계별 권장 음식 분류. 병원이 적은 그대로 쓴다. */
|
||||
recommended?: string;
|
||||
/** 회복 단계별 피해야 할 음식 분류. */
|
||||
avoid?: string;
|
||||
/** 단계가 며칠씩인지. */
|
||||
stageDays?: string;
|
||||
};
|
||||
}
|
||||
@ -21,5 +21,7 @@
|
||||
"visitReads": [],
|
||||
"newsNote": "",
|
||||
"newsOutletsExample": "",
|
||||
"sponsorNotice": ""
|
||||
"sponsorNotice": "",
|
||||
"ga4MeasurementId": "",
|
||||
"_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다."
|
||||
}
|
||||
|
||||
@ -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<string, any>).ga4MeasurementId ?? '')) ? String((S as Record<string, any>).ga4MeasurementId) : '';
|
||||
const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null;
|
||||
---
|
||||
<!doctype html>
|
||||
@ -39,6 +41,9 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
{ld && <script type="application/ld+json" set:html={ld} />}
|
||||
{GA4 && <script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${GA4}`}></script>}
|
||||
{GA4 && <script is:inline set:html={`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${GA4}',{anonymize_ip:true});
|
||||
document.addEventListener('click',function(e){var a=e.target.closest&&e.target.closest('a');if(!a)return;var h=a.getAttribute('href')||'';var ev=h.indexOf('tel:')===0?'click_tel':a.dataset.ga==='reservation'||a.classList.contains('cta')?'click_reservation':a.dataset.ga==='clinic-site'?'click_clinic_site':a.dataset.ga==='map'?'click_map':null;if(ev)gtag('event',ev,{link_url:h,page_path:location.pathname});},true);`} />}
|
||||
</head>
|
||||
<body>
|
||||
{!INDEXABLE && <div class="sample-banner">샘플 사이트입니다. 정식 공개 전입니다.</div>}
|
||||
|
||||
@ -21,5 +21,7 @@
|
||||
"visitReads": [],
|
||||
"newsNote": "",
|
||||
"newsOutletsExample": "",
|
||||
"sponsorNotice": ""
|
||||
"sponsorNotice": "",
|
||||
"ga4MeasurementId": "",
|
||||
"_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다."
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user