feat: 뷰성형외과 서포터즈 샘플 사이트 v3 + 설계 v0.1 + 공식 사이트 대비 AEO 감사

- supporters/: Astro 사이트 소스 (pages·components·posts·factSheet/authors/videos 데이터, 뉴스룸 916건 빌더, 발행 게이트 check.mjs, vercel.json)
- supporters/docs/: v1~v3 스크린샷 기록
- docs/INFINITH_Viewclinic_Supporters_Site_Design_v0.1.md: 서포터즈 사이트 설계 문서
- docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD.md: 다음 세션 자동 빌드 프롬프트
- docs/reports/Viewclinic_Supporters_vs_Official_AEO_Audit.xlsx + scripts/build_supporters_audit_xlsx.py: 서포터즈 vs 공식 사이트 AEO/GEO 감사 비교

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-07 08:40:10 +09:00
parent 1dd1384fbf
commit 03ed725481
184 changed files with 18068 additions and 0 deletions

View File

@ -0,0 +1,243 @@
# 뷰성형외과 서포터즈 웹 설계 제안 v0.1
**작성:** 2026-09-04 · INFINITH AI Discovery 후속 · **상태:** 초안(haewon 검토 전)
**전제 진단:** /discovery/viewclinic 50점/C (A98 · B25 · C28 · D68 · E0 · F33%), QB 120문항 논브랜드 언급률 GPT 11% · PPLX 3%
---
## 0. 한 줄 요약
병원 홈페이지·유튜브·인스타 자산을 **복제하지 않고 "환자 질문에 대한 답"으로 변환해 발행하는 별도 매체**를 만듭니다. 기업은행 티스토리와 같은 개념이되, 우리가 통제하는 도메인에 두어 AI 크롤러 방문과 유입을 직접 계측합니다. 편집 캘린더는 이미 만들어 둔 QB 120문항입니다.
---
## 1. 오늘 확정한 것과 미결 사항
| 항목 | 결정 | 설계에 미치는 영향 |
|---|---|---|
| 사이트 주소 | **미결.** 별도 도메인 vs 병원 서브도메인 2안(§3). 남의 플랫폼은 제외 확정 | 중복 판정 거리, 초기 신뢰도, 계측 가능 여부 |
| 사용 가능 자산 | 유튜브 전사+임베드 / 홈페이지·인스타 이미지 캡처 / 환자 후기·경험담 | 전후사진은 제외. 후기는 §7 규칙으로 제한 |
| 작성자 표기 | 실명 서포터즈 + 원장 감수 | Person 스키마 + reviewedBy Physician 구조 |
| 목표 표면 | AI 답변엔진과 네이버 동등 | 허브(자체 도메인 원문) + 네이버 블로그 재작성 2트랙 |
---
## 2. 목적과 성공 지표
이 사이트는 브랜딩 매체가 아니라 **AI 인용 표면 + 계측 장치**입니다. 성과는 기존 QB 120문항을 같은 방식으로 재측정해 증명합니다.
| 지표 | 현재 | 8주 목표 | 측정 방법 |
|---|---|---|---|
| 논브랜드 84문항 뷰 언급률 (GPT) | 11% | 20% 이상 | `scripts/run_question_bank_openai.py` 재실행 |
| 논브랜드 84문항 뷰 언급률 (PPLX) | 3% | 10% 이상 | 동일 러너, sonar |
| 뷰 인용 시 출처 도메인 | blog.viewclinic.com(정지 블로그)·강남언니 | 서포터즈 도메인 등장 | JSONL 인용 URL 집계 |
| AI 크롤러 방문 | 계측 불가(E 0%) | GPTBot·OAI-SearchBot·PerplexityBot·ClaudeBot·Google-Extended 일별 히트 | 서버 로그 UA + IP 대역 검증 |
| AI 유입 세션 | 계측 불가 | 주간 집계 | GA4 AI 채널그룹(chatgpt.com·perplexity.ai·gemini 리퍼러) |
| 네이버 유입 | 없음 | 서치어드바이저 노출·클릭 | 서치어드바이저 + 블로그 통계 |
언급률은 신뢰구간과 함께만 보고합니다. "스키마를 넣으면 인용된다"고 약속하지 않습니다(INFINITH 정직성 규약 유지).
---
## 3. 사이트 주소 2안 비교표
남의 플랫폼(티스토리 등)은 robots·스키마·서버로그를 통제할 수 없어 AI 크롤러 계측이 불가능하므로 후보에서 제외했습니다(2026-09-04 haewon 결정).
| 기준 | A. 별도 도메인 (예: viewsupporters.kr) | B. 병원 서브도메인 (supporters.viewclinic.com) |
|---|---|---|
| 검색엔진이 보는 정체 | 병원을 다루는 제3의 사이트 | 병원과 같은 사이트 |
| 중복·클론 판정 거리 | 가장 멂 | 가까움. 내용 유사 시 canonical 통합으로 한쪽만 노출 |
| AI가 보는 "언급 도메인 수" 효과 | +1 도메인 | 없음(같은 도메인) |
| 초기 검색 신뢰도 | 0에서 시작 | 홈페이지 권위 상속 |
| robots·스키마·서버로그 통제 | 전부 가능 | 전부 가능(병원 DNS 협조 필요) |
| AI 크롤러 접근 | 우리가 전면 허용 | 우리가 전면 허용 |
| 의료광고 판정 관점 | 운영 주체·비용 부담에 따라 판단 | 병원 광고로 명확 |
| 홈페이지 문제의 전염 | 없음. 독립 채점 | WordPress 5.5.3 등 본 사이트 감점 요인이 같은 도메인 평가에 섞임 |
| 개설 속도 | 1~2주 | 1~2주 + 병원 IT 협의(DNS 위임) |
| 비용 | 도메인 + Vercel(월 2만원 내외) | 동일. 도메인 비용 없음 |
| INFINITH 권고 | **1순위** | 2순위 |
**권고 근거:** 이 프로젝트가 증명할 것은 "AI가 뷰성형외과를 인용하는가"이고, AI는 자사 사이트보다 다른 도메인의 언급을 더 신뢰합니다. 별도 도메인은 중복 위험도 구조적으로 제거합니다. 초기 권위 0 문제는 병원 홈페이지·유튜브·강남언니·GBP에서 서포터즈 사이트로 sameAs와 링크를 걸어 보완합니다.
**B안이 맞는 경우:** 병원이 "공식 채널"로 운영하길 원하고 의료광고 심의를 어차피 받기로 했다면, 권위 상속 이점이 있는 B안이 낫습니다. 이 경우 §4.2 규칙 1·2(문장 재사용 금지, 정보 유형 분리)를 더 엄격히 적용해야 홈페이지와의 canonical 충돌을 피할 수 있습니다.
---
## 4. 중복·클론 판정을 피하는 원칙
### 4.1 검색엔진별로 위험의 성격이 다릅니다
| 엔진 | 실제 동작 | 우리에게 생기는 위험 |
|---|---|---|
| 구글 | 중복 자체에 페널티는 없음. 같은 내용은 canonical로 하나만 남기고 통합 | (1) 스크랩 사이트로 분류돼 색인 제외 (2) 홈페이지와 서포터즈가 같은 키워드로 경쟁해 서로 순위를 깎음 |
| 네이버 | 유사문서 판독 시스템이 뒤에 발행된 유사 글을 검색에서 제외. 출처 신뢰(C-Rank) 반영 | 홈페이지·네이버 블로그·서포터즈 중 원본 판정을 못 받은 쪽이 사라짐 |
| AI 답변엔진 | 크롤링 가능한 텍스트를 근거로 인용. 여러 도메인이 일관되게 말하면 신뢰 상승 | 위험보다 기회. 단, 사실이 도메인마다 다르면(NAP 혼재) 오답 |
### 4.2 운영 규칙 6가지
1. **원문 재사용률 상한.** 홈페이지 문장을 그대로 옮기지 않습니다. 사실(의료진 수, 개원 연도, 진료시간)은 가져오되 문장은 새로 씁니다. 발행 전 원문 대비 연속 일치 문자열을 검사합니다(§9 파이프라인의 자동 게이트).
2. **매체 정체성을 다르게 둡니다.** 홈페이지는 "병원이 병원을 소개"하고, 서포터즈는 "환자 관점의 질문과 답"입니다. 같은 시술을 다뤄도 홈페이지에 없는 정보 유형(준비 체크리스트, 회복 일정표, 비교표, FAQ)을 중심에 둡니다.
3. **유튜브는 매체 변환이라 중복이 아닙니다.** 영상 발화를 글로 재구성하고 원본 영상을 임베드합니다. 영상 설명란 텍스트는 복사하지 않습니다.
4. **이미지 캡처는 재문맥화합니다.** 원본 alt·캡션을 쓰지 않고 글 문맥에 맞는 alt와 캡션을 새로 씁니다. 출처(뷰성형외과 인스타그램 @계정, 게시일)를 캡션에 표기합니다.
5. **발행 순서로 원본을 확정합니다.** 허브(자체 도메인)에 먼저 발행하고, 네이버 블로그는 24시간 이후 요약 재작성본을 올리며 허브 링크를 답니다. 네이버 글은 허브 글의 60% 이하 분량으로 다른 문장을 씁니다.
6. **사실은 한 곳에서 관리합니다.** 주소·전화·진료시간·의료진 수는 팩트 시트 하나에서 불러와 홈페이지 표기와 한 글자까지 맞춥니다(진단 B4 NAP 혼재 문제를 서포터즈에서 재생산하지 않습니다).
---
## 5. 콘텐츠 엔진: QB 120문항이 편집 캘린더입니다
### 5.1 카테고리별 글 포맷
| QB 카테고리 | 문항 수 | 현재 ✕/△ | 글 포맷 | 주 원천 |
|---|---|---|---|---|
| A 브랜드 직접 | 12 | 대부분 △·✕ | "뷰성형외과는 어떤 곳인가" 정의문 시리즈(연혁·규모·의료진·방송 이력) | 홈페이지 사실 + 유튜브 채널 소개 영상 |
| B 위치·운영 | 10 | 절반 ✕ | 방문 가이드(주차·층별 안내·예약 경로·공휴일) | 인스타 시설 사진 + 홈페이지 |
| C 지역 추천·비교 | 15 | 거의 ✕ | 시술별 "선택 기준" 가이드 + 병원 특성 비교표 | 유튜브 원장 설명 영상 |
| D 시술별 정보탐색 | 58 | 대부분 ✕ | 시술 FAQ 페이지(정의·적응증·시간·회복·주의) 1시술 1페이지 | 유튜브 시술 설명 영상 전사 |
| E 신뢰·안전 | 9 | ✕ | 마취과 상주·CCTV·응급 체계 등 안전 설명 | 홈페이지 시설 + 강남언니 병원정보 |
| F 가격·이벤트 | 8 | ✕ | 가격 구성 요소 설명(범위 표기는 병원 승인 후) | 홈페이지 이벤트 페이지 |
| G 후기·평판 | 8 | △ | 강남언니·GBP 집계 수치 요약 + 링크. 경험담 인용 없음(§7) | 강남언니 hospitals/189, GBP |
### 5.2 세 가지 원천의 변환 파이프라인
```
[유튜브 1,064편] → 자막/전사 → 질문 단위로 분할 → Q&A 글 초안 → 영상 타임스탬프 임베드
[홈페이지·인스타 캡처] → 사용 허락 목록 → 캡처 → 새 alt·캡션 → 글의 삽화로 배치
[홈페이지 사실] → 팩트 시트(JSON 1개) → 모든 글의 병원 정보 블록에 주입
```
QB 120문항 하나가 글 하나의 H1이 됩니다. D 카테고리 58문항은 시술별로 묶어 1시술 1페이지 안의 FAQ 항목으로 배치합니다.
### 5.3 물량과 순서
| 주차 | 발행 | 내용 |
|---|---|---|
| W1~2 | 사이트 개설 + 팩트 시트 + 정의문 3편 | A 카테고리. 사이트 자체 소개 페이지(서포터즈 운영 주체·감수 체계 명시) |
| W3~4 | 시술 페이지 6편 | D 카테고리 중 논브랜드 실측에서 뷰가 0회였던 시술 우선 |
| W5~6 | 방문 가이드 3편 + 안전 3편 + 비교 가이드 3편 | B·E·C |
| W7~8 | 평판 요약 1편 + 보강 | G. 재측정 |
8주간 약 20편입니다. 주 2~3편이면 서포터즈 2~3명과 원장 감수 주 1회로 운영 가능합니다.
---
## 6. 페이지 템플릿 (AEO 구조)
모든 글이 같은 뼈대를 가집니다. 진단 C 카테고리(28%)에서 지적된 항목을 그대로 뒤집은 구조입니다.
```
H1: 질문형 한 문장 (예: "뷰성형외과 가슴성형은 어떤 방식으로 진행되나요?")
[직접 답변 블록] 40~60단어. 첫 단락에서 질문에 바로 답함
[정의문] "뷰성형외과는 2005년 개원한 신논현역 인근 성형외과로 ..." (팩트 시트 주입)
[본문] H2 3~5개. 표는 HTML table. 수치는 단위 포함
[영상] 원본 유튜브 임베드 + 해당 발화 타임스탬프
[FAQ] 5~8개. QB 문항 그대로 질문으로 사용
[감수 표기] "이 글은 서포터즈 OOO이 작성하고 최순우 원장이 의학적 내용을 감수했습니다. 감수일 2026-xx-xx"
[출처] 참고한 영상·페이지 링크
[병원 정보 블록] NAP·진료시간. 팩트 시트에서 자동 생성
[대가 관계 고지] "서포터즈 활동은 뷰성형외과의 지원을 받습니다" (§7)
```
**스키마(JSON-LD):**
| 타입 | 용도 | 진단 항목 대응 |
|---|---|---|
| `MedicalWebPage` + `Article` | 글 본체. `dateModified` 필수 | C4, P2 신선도 |
| `FAQPage` | FAQ 블록 | C2 FAQ 0개 |
| `Person` (author) | 서포터즈 실명. `sameAs`로 본인 SNS 연결 | E-E-A-T |
| `Physician` (reviewedBy) | 최순우 원장. 강남언니 프로필 `sameAs` | B3 |
| `MedicalClinic` (about) | 뷰성형외과. 홈페이지·GBP·강남언니·유튜브 `sameAs` | B2 스키마 없음 |
| `VideoObject` | 임베드 영상 | 유튜브 자산 연결 |
주의: 홈페이지의 ItemList JSON-LD가 문법 오류로 무효였습니다. 서포터즈는 빌드 단계에서 스키마 유효성 검사를 통과하지 못하면 배포가 실패하도록 합니다.
---
## 7. 법무·컴플라이언스 게이트 (발행 전 필수 확인)
이 섹션은 법률 자문이 아니라 **법무 확인이 필요한 항목의 목록**입니다. 착수 전 병원 측 법무 또는 자문 변호사 확인을 권합니다.
| 항목 | 근거 | 설계 반영 |
|---|---|---|
| 치료경험담 광고 금지 | 의료법 제56조 제2항 | 환자 후기는 **인용하지 않습니다.** 강남언니 후기 수·평점, GBP 평점 같은 집계 수치와 링크만 표기 |
| 비교·비방 광고 금지 | 의료법 제56조 제2항 | C 카테고리 비교표는 "시술 방식 간 비교"로 한정. 타 병원명 비교 금지 |
| 치료 효과 보장·과장 금지 | 의료법 제56조 제2항 | 직접 답변 블록에 "개인차" 문구 표준화. 원장 감수 게이트 |
| 전후사진 | 의료법 시행령 요건 | 이번 범위에서 **제외** |
| 사전심의 대상 여부 | 의료법 제57조. 매체 이용자 규모 기준 | 신규 사이트는 초기 기준 미달 가능성이 높으나 확인 필요. 네이버 블로그 트랙은 별도 확인 |
| 경제적 대가 관계 표시 | 공정위 추천·보증 심사지침 | 서포터즈 글 하단에 지원 관계 고지 문구 고정 |
| 저작권 | 병원 콘텐츠 사용 | 유튜브·홈페이지·인스타 자산 사용 허락서 1장(범위·기간·매체 명시) |
| 초상권 | 의료진·직원 사진 | 캡처 대상 사진의 인물 동의 확인 목록 |
| 서포터즈 개인정보 | 실명·사진 공개 | 서포터즈 동의서 |
---
## 8. 네이버 트랙 (동등 목표 반영)
네이버 블로그·플레이스·카페는 robots.txt로 AI 크롤러를 차단합니다. 그래서 네이버용 자산은 따로 두되, 허브의 파생물로만 운영합니다.
| 단계 | 내용 |
|---|---|
| 원본 확정 | 허브에 먼저 발행. 서치어드바이저에 허브 도메인 등록·사이트맵 제출 |
| 재작성 | 24시간 후 네이버 블로그에 요약본(허브 분량의 60% 이하, 다른 문장) + 허브 링크 |
| 유사문서 회피 | 이미지도 크롭·순서를 바꾸고, 표는 네이버 에디터 표로 다시 작성 |
| 플레이스 연동 | 네이버 플레이스 소식에 허브 글 링크 게시(병원 계정 필요) |
| 측정 | 서치어드바이저 노출·클릭, 블로그 유입 경로 |
네이버 블로그를 병원 명의로 할지 서포터즈 명의로 할지는 §7 사전심의 확인 결과에 따라 정합니다.
---
## 9. 기술 아키텍처
기존 INFINITH 인프라(Supabase·Vercel)를 그대로 씁니다.
```
[콘텐츠 DB: Supabase]
supporter_posts (초안/검수/발행 상태, QB 문항 ID, 원천 영상 ID)
fact_sheet (NAP·의료진·진료시간 단일 원본)
media_assets (캡처 이미지, 출처, 허락 여부, alt)
[생성 파이프라인: Edge Function]
1. 유튜브 자막 수집 (YouTube Data API captions / 없으면 Whisper)
2. QB 문항 매칭 → Q&A 초안 생성 (기존 Perplexity/Claude 프롬프트 체계 재사용)
3. 중복 검사 게이트: 홈페이지 원문 대비 연속 일치 문자열, 금칙어(효과 보장·경험담 표현)
4. 서포터즈 편집 → 원장 감수(상태 전이) → 발행
[사이트: Astro 정적 빌드 → Vercel]
robots.txt 전면 허용(GPTBot·OAI-SearchBot·PerplexityBot·ClaudeBot·Google-Extended 명시)
sitemap.xml 자동, JSON-LD 빌드 시 검증, dateModified 자동
[계측]
Vercel 로그 드레인 → Supabase crawler_hits (UA + 공개 IP 대역 검증)
GA4 AI 채널그룹 + GSC + Bing 웹마스터 + 네이버 서치어드바이저
QB 120 재측정 러너(기존 스크립트)로 4주·8주 스팟체크
```
정적 빌드를 고르는 이유는 세 가지입니다. HTML 무게가 가벼워 크롤러가 전문을 읽고, WordPress 같은 취약 버전 문제가 없고, 스키마 검증을 빌드 단계에 걸 수 있습니다.
---
## 10. 리스크와 대응
| 리스크 | 대응 |
|---|---|
| 병원이 시술 정보 문장을 홈페이지와 똑같이 쓰길 원함 | 4.2 규칙 1을 계약서에 명시. 팩트만 공유하고 문장은 서포터즈가 씀 |
| 원장 감수 병목 | 주 1회 감수 슬롯 고정. 감수 없이 발행 불가하되 사실 확인형 글(방문 가이드)은 실무자 감수로 대체 |
| 8주 안에 언급률이 안 움직임 | 색인·크롤러 방문·인용 URL 등 선행 지표를 먼저 보고. 언급률은 신뢰구간으로 보고 |
| 후기 사용 요구 | §7 규칙 고정. 집계 수치와 링크로만 |
| 네이버 유사문서로 허브가 밀림 | 발행 순서 규칙(허브 우선)과 재작성 분량 상한 |
---
## 11. haewon 결정 필요 항목
1. 사이트 주소 2안 중 선택 (§3)
2. 서포터즈 인원과 모집 방식(병원 내부 직원인지, 외부 모집인지)
3. 법무 확인 주체(병원 자문 변호사인지, INFINITH가 별도 자문을 붙일지)
4. 이 사이트를 POC 8주 범위 안(W3~6 콘텐츠 구조 항목)에 넣을지, 별도 계약으로 뗄지
5. 가격 정보(F 카테고리) 노출 범위: 구성 요소 설명만 vs 범위 금액 표기
## 12. 다음 산출물
- 이 문서 승인 후: 도메인 2안 비교와 콘텐츠 계획을 POC 덱 부록 또는 별도 제안 덱(infinith-deck 규격)으로 제작
- 기술 착수 시: `fact_sheet` 스키마, 페이지 템플릿 1개, 중복 검사 게이트 스크립트를 먼저 만들어 정의문 1편으로 파일럿

View File

@ -0,0 +1,159 @@
# 다음 세션 프롬프트 · "병원 URL 하나로 서포터즈 사이트까지 자동 빌드"
작성 2026-09-04 · 작성자 Claude (haewon 세션 핸드오버) · 상태: 착수 전
---
## 0. 세션 시작 시 그대로 붙여 넣을 프롬프트
```
INFINITH 저장소(브랜치 feature/discovery-url-cta)에서 "AI Discovery 자동 빌드" 기능을 개발한다.
목표: /discovery 랜딩의 URL 입력 CTA에 병원 홈페이지 URL을 넣으면, 진단 리포트(/discovery/:id)까지 만들던 기존 파이프라인을 확장해
서포터즈 사이트(supporters/ Astro 프로젝트와 같은 구조)까지 자동 생성·미리보기 배포하는 것이다.
먼저 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD.md 를 읽고, 그 문서의 §5 단계 순서대로 진행한다.
핵심 제약: 뷰성형외과 수작업 빌드에서 확정된 원칙(전후사진·후기 인용 금지, 홈페이지 문장 복사 금지, 감수 예정 표기, noindex 기본, 발행 게이트 통과 필수)을 코드로 강제한다.
결정이 필요한 것은 문서 §7을 보고 haewon에게 묻는다. 존댓말, 엠대시 금지.
```
---
## 1. 지금까지 만든 것 (위치)
| 자산 | 위치 | 상태 |
|---|---|---|
| AI Discovery 랜딩 (뷰성형외과 제안용) | `src/pages/DiscoveryLandingPage.tsx` + `src/components/discovery/` 9종 (Hero·WhyNow·AbmrLoop·HowItWorks·SampleReport·HonestyStrip·Roadmap·DiscoveryCta·DiscoveryLeadModal) | 프로덕션 https://infinith-demo.vercel.app/discovery (main). 단일 HTML 시안 `docs/reports/ai_discovery_landing.html` |
| URL 입력 CTA | `DiscoveryLeadModal.tsx` → Supabase `discovery_leads` insert (URL·연락처 필수, 상호 선택). 실패 시 o2oteam@o2o.kr mailto 폴백 | 브랜치 `feature/discovery-url-cta`, 프로덕션 미배포. 마이그레이션 `supabase/migrations/20260831_discovery_leads.sql` **미적용** (supabase login 후 db push 필요) |
| 진단 리포트 페이지 | `src/pages/DiscoveryReportPage.tsx`, 결과 레지스트리 `src/data/discoveryResults.ts`, 뷰성형외과 결과 `src/data/discovery_viewclinic.ts` (50/C) | 새 병원 = 결과 파일 + 레지스트리 1줄 |
| 기준표 | `src/data/aeoGeoRubric.ts` (36항목, A20/B20/C25/D20/E10/F5), 문서 `docs/AEO_GEO_RUBRIC.md`, 채점 `src/lib/discoveryScore.ts` | v1.0. 미검증은 분모 제외 |
| 자동 채점기 | `scripts/audit_aeo_geo.py <main_url> <service_url> --id --name --skip-ts --json` (auto 21~22항목) | stdlib+curl. 노드 순회가 역순(stack.pop)이라 sameAs는 Organization 타입 노드가 먼저 잡힘 → 서포터즈 스키마는 `@type: ['MedicalClinic','Organization']` |
| 기존 백엔드 파이프라인 | `supabase/functions/` discover-channels → collect-channel-data → generate-report → generate-content-plan, scrape-website, enrich-channels, `_shared/` (extractSocialLinks·verifyHandles·dataQuality·retry) | 실제 연동: YouTube Data API, Apify, Naver Search API, Firecrawl, Perplexity. 문서 `docs/AI_DISCOVERY_BUILD2_HANDOVER.md`, `docs/PIPELINE_IMPROVEMENT_PLAN.md` |
| 질문 뱅크 | `docs/reports/Viewclinic_AI_Discovery_Question_Bank.md` (120문항, 7카테고리), 러너 `scripts/run_question_bank_openai.py`, 통합 `scripts/merge_qb_results.py`, 엑셀 `scripts/export_qb_excel.py` | 뷰성형외과 2엔진 실측 완료 |
| **서포터즈 샘플 사이트 (수작업 v3)** | `supporters/` (Astro 7 정적, 별도 Vercel 프로젝트 `view-supporters-sample`, https://view-supporters-sample.vercel.app, noindex) | 이 세션의 산출물. 자동화의 정답지(golden sample) |
| 감사 비교 엑셀 | `docs/reports/Viewclinic_Supporters_vs_Official_AEO_Audit.xlsx`, 빌더 `scripts/build_supporters_audit_xlsx.py` | 공식 50/C · 샘플 78/B · 색인 허용 86/A. 시트 4 "E 측정 인프라 연결 가이드" |
| 설계 문서 | `docs/INFINITH_Viewclinic_Supporters_Site_Design_v0.1.md` | 중복 회피 6규칙, 법무 게이트, 네이버 2트랙 |
| 기업은행 블로그 감사 | `docs/reports/IBK_Blog_AEO_GEO_Audit_2026-09-04.md` | 54/C, Perplexity 3/3 인용. 글 구조(세 줄 요약·질문형 H3·표·FAQ)의 근거 |
### supporters/ 구조 (자동 생성기가 만들어야 할 결과물)
```
supporters/
astro.config.mjs site = SITE_URL env, build.format 'file', trailingSlash never
vercel.json cleanUrls, X-Robots-Tag noindex, build env PUBLIC_INDEXABLE=false
scripts/check.mjs 발행 게이트: JSON-LD 파싱·Article/FAQPage 존재·img alt·H1=1·금칙어·홈페이지 문장 40자 연속 일치
scripts/home_text.txt 홈페이지 본문(공백 제거) — 중복 검사 기준
scripts/build_news.py 뉴스룸 데이터 빌더 (게시판 크롤 + 네이버 뉴스 API → src/data/news.json, 부정 기사 제외 키워드)
src/data/factSheet.json 병원 사실 단일 원본 (NAP·진료시간·층·진료과·안전·이력·표면 집계)
src/data/authors.json 서포터즈(가명·샘플) + 감수 원장(실제 약력·사진·sameAs)
src/data/videos.json 채널 전체 조회수 집계 → 롱폼 Top10(정보형, 쇼츠 판별은 /shorts/{id} 200 응답)·정보형 쇼츠+서포터즈 답·전체
src/data/news.json 1,137건 (보도자료/기고·인터뷰/언급, 주제 13종)
src/content/posts/*.md 글 8편. frontmatter: title(질문형)·category·qbIds·summary[3]·author·reviewer·reviewStatus·videos[]·gallery[]·faq[]·sources[]·thumbnail
src/lib.ts clinicSchema/personSchema/physicianSchema, INDEXABLE 플래그
src/layouts/Base.astro 헤더(병원 로고+서포터즈)·이전 페이지 버튼(홈 제외)·플로팅 위로·푸터·JSON-LD·canonical(index.html 제거)
src/components/ Summary(세 줄 요약, 연하늘 #F2F6FF 단색)·VideoLite(썸네일+클릭 iframe)·Gallery·RelatedVideos·Faq·ReviewBox·Sources·Disclosure·FactBlock·PostCard
src/pages/ index(라이트 파스텔 히어로·다크 글래스 KPI·Doctors Explain·시설 갤러리)·posts/[id]·posts/index·videos·newsroom·clinic·about·authors/[id]·robots.txt.ts·sitemap.xml.ts
public/img/{clinic,doctors,safety,exam,breast,care}/ 홈페이지에서 내려받은 92장 + hero-collage.jpg + view-logo.png
```
---
## 2. 이 세션에서 확정된 결정 (자동화가 지켜야 할 규칙)
1. 디자인은 INFINITH 토큰(`infinith-frontend-design` 스킬): 네이비 #0A1128, 퍼플 #6C5CE7, 그라디언트 #4F1DA1→#021341, Playfair(영문 제목·숫자)+Pretendard, 다크/라이트 리듬, 다크 위 흰 카드, 대각선 그림자, 파스텔 상태색. **텍스트 많은 블록은 그라디언트 금지, 연하늘 단색.**
2. 글 구조 = 기업은행 블로그 검증 구조: 세 줄 요약 → 본문(질문형 H2, HTML 표) → 원본 영상 → 갤러리 → 이어서 볼 영상 → FAQ → 작성·감수 박스 → 참고 자료 → 병원 정보 블록 → 대가 고지.
3. 넣지 않는 것: 전후 사진, 환자 후기·경험담 인용(**공정위 2026-07-12 뒷광고 제재 병원**), 타 병원 비교, 효과 보장, 가격(경영진 결정 전), 후기·리뷰·토크·변신 성격 영상, 비교 광고 소지 이미지(마취과 상주 43% vs 23.1%).
4. 리뷰 페이지는 만들지 않는다(haewon 결정 2026-09-04). Google Places 키 불필요.
5. 영상: 롱폼 Top10 기본 탭, 정보형만. 쇼츠는 정보형만 + 서포터즈 한 문단 답. 채널 전체 순위는 참고 탭.
6. 뉴스룸: 병원 배포 기사 링크 트리. 제3자 규제·사건 보도 제외(키워드 목록은 build_news.py NEG). 본문 인용 없음.
7. 서포터즈 실명·원장 감수는 확정 전 → 가명·"감수 예정" 표기, reviewedBy 스키마는 감수 완료 글에만.
8. 기본 noindex + robots Disallow. 발행은 `PUBLIC_INDEXABLE=true` 환경변수 하나로 전환(robots 전면 허용 + AI 봇 명시 + sitemap 선언).
9. 병원 자산 사용은 파트너십 전제(허락 받은 것으로 간주). 단 이미지는 재문맥화(새 alt·캡션·출처·캡처일).
10. 경영진 미결: 도메인(별도 vs 서브도메인), 서포터즈 모집, 법무 확인 주체, POC 8주 범위 포함 여부, 가격 노출.
---
## 3. 목표 기능의 파이프라인 (제안)
```
[입력] 병원 홈페이지 URL (+ 상호 선택) ← /discovery 랜딩 CTA (DiscoveryLeadModal)
Phase 0 discover-channels (기존) → 유튜브·인스타·페북·블로그·강남언니·GBP 핸들
Phase 1 audit_aeo_geo (기존 py → Edge Function 이식 또는 워커) → discovery_<id> 결과 (50/C 등)
Phase 2 자산 수집 (신규 collect-supporter-assets)
├ 홈페이지: Firecrawl 페이지 텍스트(병원소개·의료진·오시는길·안전·시술 메인) + 이미지 URL 목록(필터: logo/icon/btn/popup/전후/before-after 제외) + 로고
├ 유튜브: uploads 전량 videos.list(statistics·contentDetails·status) → 조회수 순위, 쇼츠 판별(/shorts/{id} 200), 자막(youtube_transcript_api, IP 차단 대비 프록시/캐시/큐)
├ 강남언니: 병원 페이지 공개 집계(평점·후기수·의료진수·진료시간)
├ 뉴스: 홈페이지 언론보도 게시판(있으면) + 네이버 뉴스 API 6질의 → 분류·부정 제외
└ 의료진: 홈페이지 의료진 페이지 → 이름·직함·약력·사진 → authors.json physicians
Phase 3 팩트 시트 생성 (factSheet.json) ← 사실 추출 + 표기 단일화(주소 1형태, 전화, 진료시간 출처 표기)
Phase 4 질문 선정 ← QB 템플릿(카테고리 7종)을 업종·시술로 인스턴스화 → 첫 배치 8~10문항 (A1·B1·D4·E1·G1 비율)
Phase 5 글 생성 (Claude) ← 문항 + 관련 영상 자막 + 홈페이지 사실 → frontmatter 규격 md
프롬프트 규칙: 세 줄 요약, 질문형 H2, 표 1개 이상, FAQ 5~8, 출처 accessed 날짜, 금칙어, 개인차 문장, 홈페이지 문장 복사 금지
Phase 6 사이트 조립 ← supporters/ 템플릿 복제 + 데이터 주입 + 이미지 다운로드·리사이즈 + 로고
Phase 7 발행 게이트 (check.mjs) 통과 못 하면 중단 → 실패 항목을 리포트에 표시
Phase 8 미리보기 배포 ← Vercel 프로젝트 `supporters-<clinicId>` 생성·배포(noindex) → URL을 discovery 리포트와 discovery_leads 행에 기록
Phase 9 감사 재실행 (색인 허용 빌드 로컬) → 공식 vs 서포터즈 비교표 (build_supporters_audit_xlsx.py 로직 재사용) → 리포트 페이지에 표시
[출력] /discovery/<id> 에 "서포터즈 미리보기" 카드 + 비교 점수 + 남은 과제
```
권장 실행 형태: 오래 걸리는 Phase 2·5·8은 Edge Function 시간 제한(150초)을 넘기므로, **Supabase 큐(테이블 + cron) + 별도 워커(Node, Vercel 서버리스 아님)** 또는 GitHub Actions 워커로 돌리고 진행 상태를 `supporter_builds` 테이블에 기록해 랜딩이 폴링한다.
---
## 4. 재사용할 코드 (이식 대상)
| 기능 | 지금 있는 곳 | 이식 방향 |
|---|---|---|
| 채널 발견·검증 | `supabase/functions/discover-channels`, `_shared/extractSocialLinks.ts`, `verifyHandles.ts` | 그대로 사용 |
| 자동 채점 | `scripts/audit_aeo_geo.py` | Node/TS 이식(`supabase/functions/discover-aeo-geo`) 또는 워커에서 python 호출 |
| 유튜브 통계·쇼츠 판별 | 이 세션 스크래치 스니펫 (uploads.json → videos.list 50개 배치 → /shorts/{id} 200 판별) | 워커 모듈 `collect-youtube-ranking.ts` |
| 뉴스 수집·분류 | `supporters/scripts/build_news.py` (OUTLET 맵, NEG, TOPICS, REL/Q1/Q2 정규식, 게시판 파서) | 워커 모듈 |
| 이미지 필터·리사이즈·컨택트시트 | 이 세션 스니펫 (PIL, 1600px, 제외 정규식 `icon|logo|btn|popup|floating|banner|before|after`) | 워커 모듈 |
| 발행 게이트 | `supporters/scripts/check.mjs` | 그대로. home_text.txt는 Phase 2 텍스트로 생성 |
| 비교 엑셀 | `scripts/build_supporters_audit_xlsx.py` | 점수 비교 로직만 TS로 옮겨 리포트 카드에 표시 |
| 글 템플릿·스키마 | `supporters/src/pages/posts/[id].astro`, `src/lib.ts` | 템플릿 저장소로 분리(`templates/supporters/`) |
---
## 5. 작업 순서 (권장)
1. **템플릿화**: `supporters/`에서 뷰성형외과 고유값을 전부 `factSheet.json`·`authors.json`·`videos.json`·`news.json`·`posts/`·`public/img/`로 몰아넣고, 나머지를 `templates/supporters-astro/`로 복제. 빈 데이터로도 빌드되게 empty-state 처리.
2. **워커 뼈대**: `workers/supporters-build/` (Node 22). 입력 `{clinicId, url}` → 단계별 함수, 각 단계 결과를 `supporter_builds` 테이블(단계·상태·로그·산출 경로)에 기록.
3. **Phase 2 수집기** 3종(홈페이지·유튜브·뉴스)을 뷰성형외과로 회귀 테스트: 결과가 수작업 v3와 같은 팩트 시트·Top10·뉴스 건수(±5%)가 나오면 통과.
4. **Phase 5 글 생성 프롬프트**: 뷰성형외과 글 8편을 정답지로 두고, 같은 문항·자막으로 생성한 결과가 게이트를 통과하고 세 줄 요약·표·FAQ 규격을 지키는지 확인. 금칙어·경험담 표현은 게이트에서 막는다.
5. **Phase 8 배포기**: Vercel API로 프로젝트 생성·환경변수·배포. 도메인은 `supporters-<id>.vercel.app`.
6. **랜딩 연결**: DiscoveryLeadModal 제출 → `supporter_builds` 행 생성 → 진행 상태 UI(단계 9개) → 완료 시 리포트 페이지에 미리보기 카드. `discovery_leads` 마이그레이션 먼저 적용.
7. **두 번째 병원으로 검증**: `data/plastic surgery clinics url.json`에서 한 곳 골라 끝까지 돌린다. 수작업 개입 없이 게이트 통과가 목표.
---
## 6. 이번 세션에서 밟은 함정 (반드시 회피)
- youtube_transcript_api는 25편 후 `IpBlocked`. 자막은 큐 + 지연 + 프록시 또는 YouTube Data API captions(OAuth 필요) 대안 설계. 자막 없는 영상은 제목·설명만으로 글을 쓰지 말고 "근거 부족"으로 건너뛴다.
- Apify 계정 미납(`Too many outstanding invoices`) → 인스타 수집 불가. 인스타는 선택 단계로.
- YouTube search API `order=viewCount`는 3건만 반환 → uploads 전량 + videos.list로 집계.
- 쇼츠는 3분까지 가능 → 길이 60초 기준만으로 판별 금지, `/shorts/{id}` 200 응답으로 판별.
- Firecrawl 결과에 팝업·상담폼 텍스트가 섞임 → NOISE 필터 필요. 홈페이지 게시판 썸네일 alt가 비어 있으면 문서 페이지에서 제목 재수집.
- 채점기 canonical 검사: Astro `Astro.url.pathname``/index.html`로 잡힘 → 정규화 필수. 주소는 지번 병기 시 2종으로 잡힘 → 팩트 블록은 도로명 1형태만.
- zsh에서 `[id].astro` 경로는 따옴표 필수(glob).
- Vercel `build.format: 'file'``cleanUrls: true` 없으면 /posts/x 404.
- `npm run build | grep`은 파이프라인 때문에 게이트 실패를 삼킨다 → `if npm run build > log; then deploy; fi`.
- Google Places 키 없음. 필요 없음(리뷰 페이지 폐기).
- `.env`의 GOOGLE_PLACES_API_KEY는 비어 있음. YOUTUBE·NAVER·FIRECRAWL·PERPLEXITY·OPENAI 키는 있음.
---
## 7. haewon에게 물어볼 것 (착수 전)
1. 워커 실행 위치: Supabase 큐 + Node 워커(서버 필요) vs GitHub Actions vs Vercel 백그라운드 함수 중 어디로 할지.
2. 첫 배치 글 수(8편 유지?)와 생성 모델(Claude API, 회사 키 유무).
3. 자동 생성 글의 감수 워크플로: 병원에 초안 링크를 보내는 이메일/노션 연동을 넣을지.
4. 두 번째 검증 병원 선정(`data/plastic surgery clinics url.json`).
5. 미리보기 배포를 Vercel 프로젝트 자동 생성으로 할지, 하나의 프로젝트에 `/<id>/` 경로로 묶을지.
---
## 8. 완료 기준
- 랜딩에서 URL 제출 → 사람 개입 없이 9단계 완주 → 미리보기 URL이 리포트에 표시된다.
- 뷰성형외과 URL로 돌렸을 때 팩트 시트·Top10·뉴스룸이 수작업 v3와 일치(±5%)하고, 게이트 0오류, 색인 허용 빌드 감사 80점 이상.
- 두 번째 병원에서도 게이트 0오류로 완주.
- 금칙 사항(전후·후기 인용·비교·효과 보장)이 게이트에서 실제로 차단되는 테스트 케이스가 있다.

View File

@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""공식 홈페이지 vs 서포터즈 사이트 AEO/GEO 비교 엑셀.
사용: python3 scripts/build_supporters_audit_xlsx.py <scratch/audit dir> <out.xlsx>
입력: official.json(공식 결과+항목명), supporters_prod.json(샘플 noindex 실측), supporters_idx.json(색인 허용 빌드 실측), overrides.json(수동 판정)"""
import json,sys,os
from openpyxl import Workbook
from openpyxl.styles import Font,PatternFill,Alignment,Border,Side
from openpyxl.utils import get_column_letter
A=sys.argv[1]; OUT=sys.argv[2]
off=json.load(open(f"{A}/official.json")); names=off["names"]
CATS={"A":("AI 크롤러 접근성",20),"B":("엔티티 식별·구조화",20),"C":("답변 인용 가능 콘텐츠",25),"D":("AI 인용 표면 확보",20),"E":("AI 가시성 측정 인프라",10),"F":("기술 위생",5)}
def load(fn):
d=json.load(open(f"{A}/{fn}")); res=d["results"] if isinstance(d,dict) and "results" in d else d
return {r["criterionId"]:r for r in res}
prod=load("supporters_prod.json"); idx=load("supporters_idx.json")
ov=json.load(open(f"{A}/overrides.json")) if os.path.exists(f"{A}/overrides.json") else {}
official={r["id"]:r for r in off["official"]}
def lvl(r): return r["level"] if r else "unverified"
# 색인 허용 열 = 로컬 색인 빌드 실측 + 로컬에서 판정 불가한 항목(A4·B6·A7)은 프로덕션 값 + 수동 판정 override
final={}
for cid in names:
r=idx.get(cid); src="로컬 색인 허용 빌드 실측"
if cid in ("A4","B6","A7"): r=prod.get(cid); src="프로덕션 실측 (로컬에서 판정 불가)"
ev=r["evidence"] if r else ""; L=lvl(r)
if cid in ov: L=ov[cid]["level"]; ev=ov[cid]["evidence"]; src="수동 판정 (서포터즈 실측)"
final[cid]={"level":L,"evidence":ev,"src":src}
W={c:names[c][1] for c in names}
def score(levels):
cat={}
for c in CATS:
e=p=n=0
for cid in names:
if cid[0]!=c: continue
L=levels.get(cid,"unverified")
if L=="unverified": continue
n+=1; p+=W[cid]; e+=W[cid]*L/3
cat[c]=(round(e,1),p,(round(e/p*100) if p else 0),n)
E=sum(v[0] for v in cat.values()); P=sum(v[1] for v in cat.values()); N=sum(v[3] for v in cat.values())
s=round(E/P*100) if P else 0; g="A" if s>=81 else "B" if s>=61 else "C" if s>=41 else "D"
return cat,round(E,1),P,s,g,N
lv_off={c:official[c]["level"] for c in names}; lv_prod={c:lvl(prod.get(c)) for c in names}; lv_fin={c:final[c]["level"] for c in names}
for c in ov:
if lv_prod.get(c)=="unverified": lv_prod[c]=ov[c]["level"]
S_off=score(lv_off); S_prod=score(lv_prod); S_fin=score(lv_fin)
NAVY="0A1128"; VIOLET="4F1DA1"; LIGHT="F4F6FB"; GOOD="F3F0FF"; WARN="FFF6ED"; CRIT="FFF0F0"
hf=Font(name="Pretendard",bold=True,color="FFFFFF",size=14); bf=Font(name="Pretendard",size=14); bb=Font(name="Pretendard",size=14,bold=True)
fill=lambda h:PatternFill("solid",fgColor=h); thin=Side(style="thin",color="E2E8F0"); bd=Border(left=thin,right=thin,top=thin,bottom=thin)
wrap=Alignment(wrap_text=True,vertical="top"); center=Alignment(horizontal="center",vertical="center",wrap_text=True)
wb=Workbook()
# ── 1. 요약
ws=wb.active; ws.title="요약"
ws["A1"]="뷰성형외과 공식 홈페이지 vs 뷰 서포터즈 사이트 · AEO/GEO 기준표 v1.0 비교"; ws["A1"].font=Font(name="Pretendard",bold=True,size=18,color=NAVY)
ws["A2"]="실측일 2026-09-04 · 도구 scripts/audit_aeo_geo.py(자동 21항목) + 수동 판정 · 미검증 항목은 0점이 아니라 분모 제외 · 등급 D≤40 / C 41~60 / B 61~80 / A 81+"; ws["A2"].font=Font(name="Pretendard",size=14,color="64748B")
hdr=["구분","공식 홈페이지 (viewclinic.com)","서포터즈 샘플 (현재·noindex)","서포터즈 색인 허용 빌드","비고"]
for i,h in enumerate(hdr,1):
c=ws.cell(row=4,column=i,value=h); c.font=hf; c.fill=fill(NAVY); c.alignment=center; c.border=bd
rows=[("총점 (100점 환산)",S_off[3],S_prod[3],S_fin[3],"검증 항목 가중 합 ÷ 검증 항목 가중치 합"),
("등급",S_off[4],S_prod[4],S_fin[4],""),
("획득 / 가능 가중치",f"{S_off[1]} / {S_off[2]}",f"{S_prod[1]} / {S_prod[2]}",f"{S_fin[1]} / {S_fin[2]}",""),
("검증 항목 수 (36 중)",S_off[5],S_prod[5],S_fin[5],"D·E 대부분은 오너 계정·수동 실측 필요")]
r=5
for row in rows:
for i,v in enumerate(row,1):
c=ws.cell(row=r,column=i,value=v); c.font=bb if i==1 else bf; c.border=bd; c.alignment=center if i>1 else wrap
r+=1
r+=1
for i,h in enumerate(["카테고리 (가중치)","공식 %","샘플 noindex %","색인 허용 %","차이 (색인 허용 공식)"],1):
c=ws.cell(row=r,column=i,value=h); c.font=hf; c.fill=fill(VIOLET); c.alignment=center; c.border=bd
r+=1
for cat,(nm,w) in CATS.items():
a=S_off[0][cat]; b=S_prod[0][cat]; f=S_fin[0][cat]
vals=[f"{cat}. {nm} ({w})",f"{a[2]}% ({a[3]}/{sum(1 for x in names if x[0]==cat)} 검증)",f"{b[2]}% ({b[3]} 검증)",f"{f[2]}% ({f[3]} 검증)",f"{f[2]-a[2]:+d}p"]
for i,v in enumerate(vals,1):
c=ws.cell(row=r,column=i,value=v); c.font=bf; c.border=bd; c.alignment=center if i>1 else wrap
if i==5: c.fill=fill(GOOD if f[2]-a[2]>0 else CRIT if f[2]-a[2]<0 else LIGHT)
r+=1
r+=1
notes=["읽는 법",
"· 샘플 noindex 열: 지금 배포된 그대로. robots.txt가 전면 차단이라 A1이 0점, sitemap 미선언으로 A5 1점. 의도된 상태입니다(발행 전).",
"· 색인 허용 빌드 열: PUBLIC_INDEXABLE=true로 빌드한 같은 코드. 발행 시 robots 전면 허용 + AI 봇 명시 + sitemap 선언. A4·B6·A7은 로컬에서 판정할 수 없어 프로덕션 실측값을 씁니다.",
"· D(표면)·E(측정)는 사이트가 아니라 병원 엔티티·오너 계정에 걸린 항목입니다. 서포터즈 사이트가 직접 바꿀 수 있는 항목은 A·B·C·F이며, E는 서포터즈 도메인의 서버 로그·GA4를 붙이면 채워집니다.",
"· 공식 홈페이지 수치는 2026-08-31 재채점 기준(50점/C)이며 discovery_viewclinic.ts와 같습니다.",
"· 점수는 '인용된다'는 약속이 아닙니다. 인용될 수 없는 이유를 제거했는지의 체크리스트입니다(INFINITH 정직성 규약)."]
for n in notes:
c=ws.cell(row=r,column=1,value=n); c.font=bb if n=="읽는 법" else bf; c.alignment=wrap; ws.merge_cells(start_row=r,start_column=1,end_row=r,end_column=5); ws.row_dimensions[r].height=44 if n!="읽는 법" else 24; r+=1
for i,w in enumerate([44,36,36,36,56],1): ws.column_dimensions[get_column_letter(i)].width=w
# ── 2. 항목별 비교
ws2=wb.create_sheet("항목별 비교 (36항목)")
hdr=["ID","카테고리","항목","가중치","공식 홈페이지 레벨","공식 근거","서포터즈 샘플 레벨 (noindex)","샘플 근거","서포터즈 색인 허용 레벨","색인 허용 근거","판정 출처","차이 (색인 허용 공식)"]
for i,h in enumerate(hdr,1):
c=ws2.cell(row=1,column=i,value=h); c.font=hf; c.fill=fill(NAVY); c.alignment=center; c.border=bd
r=2
def L2(v): return "미검증" if v=="unverified" else v
for cid in names:
o=official[cid]; p=prod.get(cid); f=final[cid]
pL=lv_prod[cid]; pE=(p["evidence"] if p else "")
if cid in ov and (not p or lvl(p)=="unverified"): pE=ov[cid]["evidence"]
diff="" if (o["level"]=="unverified" or f["level"]=="unverified") else f["level"]-o["level"]
vals=[cid,f"{cid[0]}. {CATS[cid[0]][0]}",names[cid][0],W[cid],L2(o["level"]),o["evidence"],L2(pL),pE,L2(f["level"]),f["evidence"],f["src"],(f"{diff:+d}" if diff!="" else "")]
for i,v in enumerate(vals,1):
c=ws2.cell(row=r,column=i,value=v); c.font=bf; c.border=bd; c.alignment=wrap if i in (3,6,8,10,11) else center
if i in (5,7,9) and v!="미검증": c.fill=fill(GOOD if v==3 else LIGHT if v==2 else WARN if v==1 else CRIT)
if i==12 and diff!="": c.fill=fill(GOOD if diff>0 else CRIT if diff<0 else LIGHT)
r+=1
for i,w in enumerate([8,24,38,9,11,60,12,60,12,60,24,12],1): ws2.column_dimensions[get_column_letter(i)].width=w
ws2.freeze_panes="D2"
# ── 3. 남은 과제
ws3=wb.create_sheet("서포터즈 남은 과제")
todo=[("A4","www 변형 미수렴","vercel.app 임시 도메인은 www 변형이 없음. 정식 도메인(별도 도메인 또는 서브도메인) 확정 후 http/www 4변형 301 통일","도메인 결정 후"),
("B4","주소 표기 2종","팩트 시트 단일 원본으로 통일 완료(2026-09-04 수정). 다음 실측에서 3점 예상","완료"),
("B5","sameAs 인식 1개","MedicalClinic 노드에 sameAs 5개가 있으나 채점기가 Organization 타입 노드를 먼저 읽음. 스키마 노드 순서·타입 보강","코드 수정"),
("B7","hreflang 없음","단일 언어 매체라 해당 없음. 영문판을 만들면 연결","해당 없음"),
("C4","구체 수치","시술 페이지에 절개 길이·수술 시간은 있으나 가격·회복 일수 표 없음. 가격 노출 범위는 경영진 결정 대기","결정 대기"),
("C5","표 1개","시술 글마다 비교표 1~2개로 확대","콘텐츠"),
("D1~D5","표면 항목","병원 엔티티 항목. 서포터즈 사이트가 아니라 병원 계정(강남언니·GBP·플레이스)에서 개선","병원"),
("E1~E4","측정 인프라","서포터즈 도메인에 Vercel 로그 드레인 + GA4 AI 채널그룹 + GSC·Bing 등록. 발행과 동시에 설치","발행 시"),
("F2","CMS 판정 불가","정적 빌드라 CMS·버전 노출 없음. 채점기 기준으로는 미검증이지만 실질 위험은 없음","해당 없음"),
("전제","noindex 해제","법무 확인·도메인 결정·서포터즈 실명·원장 감수 완료 후 PUBLIC_INDEXABLE=true로 발행","경영진")]
for i,h in enumerate(["항목","현재","조치","상태"],1):
c=ws3.cell(row=1,column=i,value=h); c.font=hf; c.fill=fill(VIOLET); c.alignment=center; c.border=bd
for r,row in enumerate(todo,2):
for i,v in enumerate(row,1):
c=ws3.cell(row=r,column=i,value=v); c.font=bf; c.border=bd; c.alignment=wrap
for i,w in enumerate([12,28,100,16],1): ws3.column_dimensions[get_column_letter(i)].width=w
# ── 4. E 측정 연결 가이드
ws4=wb.create_sheet("E 측정 인프라 연결 가이드")
ws4["A1"]="E. AI 가시성 측정 인프라 (가중치 10) · 무엇을 어디서 연결해야 채점되는가"; ws4["A1"].font=Font(name="Pretendard",bold=True,size=18,color=NAVY)
ws4["A2"]="원칙: 답변엔진 UI를 자동 조회하지 않는다(OpenAI·Perplexity 약관). 측정은 우리가 소유한 신호(서버 로그·GA4·GSC)와 정식 API 반복 측정으로만 한다. 현재 공식 홈페이지·서포터즈 모두 E 4항목이 미검증(분모 제외) 상태다."; ws4["A2"].font=Font(name="Pretendard",size=14,color="64748B"); ws4["A2"].alignment=wrap; ws4.merge_cells("A2:I2"); ws4.row_dimensions[2].height=48
hdr=["항목 (가중치)","레벨 정의 0 → 3","필요한 데이터","공식 홈페이지 (viewclinic.com, nginx 직접 호스팅)","서포터즈 사이트 (Vercel 정적)","연결 방법 (단계)","접근 권한 · 담당","주기 · 산출물","현재 → 목표"]
for i,h in enumerate(hdr,1):
c=ws4.cell(row=4,column=i,value=h); c.font=hf; c.fill=fill(NAVY); c.alignment=center; c.border=bd
E_ROWS=[
("E1 AI 크롤러 로그 · IP 검증 (3)",
"0 로그 접근 불가 → 1 로그 있음·미분석 → 2 UA 기준 집계 → 3 IP 검증 히트 집계",
"웹서버 접근 로그(요청 시각·URL·User-Agent·원격 IP·상태코드). AI 봇 UA: GPTBot, OAI-SearchBot, ChatGPT-User, PerplexityBot, ClaudeBot, Claude-SearchBot, Google-Extended, Applebot-Extended, Bingbot, Amazonbot, CCBot, Bytespider",
"nginx access.log. 병원 호스팅 업체(또는 내부 IT)에서 로그 파일 또는 로그 전송(syslog/S3) 설정 필요. Cloudflare 미사용이라 AI Crawl Control 대시보드 없음",
"Vercel 프로젝트 → Settings → Log Drains(Pro 플랜 이상) 또는 Observability → Logs. 드레인 대상: Supabase Edge Function(수신 엔드포인트) → crawler_hits 테이블. 무료 플랜이면 Vercel 대시보드 로그 수동 내보내기로 시작",
"1) 로그 수집 경로 확보 2) UA로 1차 필터 3) 원격 IP를 공개 IP 대역과 대조해 위조 제거: OpenAI gptbot.json·searchbot.json, Google googlebot.json·special-crawlers.json, Perplexity perplexitybot.json, Bing bingbot.json, Anthropic 공개 IP 문서, 나머지는 역방향 DNS 4) 봇별·URL별 일별 히트 집계 5) 대시보드(/discovery 리포트 E 카드)에 표시. 참조 구현: ADO2 009. AEO-GEO/06_skill-snapshot/scripts/bot_ip_verify.py",
"공식: 병원 호스팅 관리자 계정 (병원 IT 또는 대행사). 서포터즈: INFINITH Vercel 팀(o2odev) + Supabase 서비스 키",
"일별 자동 집계, 주간 리포트. 산출물: crawler_hits 테이블, 봇별 방문 추이 차트, 첫 방문일(TTFC)",
"공식 미검증 → 2(UA 집계)까지 로그만 받으면 즉시, 3은 IP 검증 스크립트 연결 후. 서포터즈 미검증 → 발행과 동시에 3"),
("E2 GA4 AI 유입 채널 (3)",
"0 GA4 없음 → 1 GA4 있음·AI 채널 없음 → 2 기본 AI Assistant 채널 → 3 커스텀 채널(Perplexity 포함)",
"GA4 속성의 세션 소스/매체·리퍼러. 리퍼러 도메인: chatgpt.com, chat.openai.com, perplexity.ai, gemini.google.com, bard.google.com, copilot.microsoft.com, bing.com/chat, claude.ai, you.com, phind.com, 그리고 UTM(utm_source=chatgpt 등)",
"홈페이지에 GA4 태그가 있는지부터 확인(2026-08 실측 시 오너 접근 불가로 미검증). 있으면 관리 → 데이터 표시 → 채널 그룹에서 커스텀 채널 추가. 없으면 GTM으로 GA4 설치 후 동일",
"Astro 레이아웃에 GA4(gtag.js) 삽입 + 같은 커스텀 채널 그룹. 정적 사이트라 서버사이드 태깅 불필요. 서포터즈 GA4 속성은 병원 GA4 계정 안에 별도 속성으로 두어 병원이 소유",
"1) GA4 속성 생성 또는 접근 확보 2) 관리 → 채널 그룹 → 새 채널 그룹 생성 3) 채널 'AI Assistant' 규칙: 세션 소스가 정규식 (chatgpt|openai|perplexity|gemini|bard|copilot|bing\\.com/chat|claude|you\\.com|phind) 와 일치 4) 채널 우선순위를 Referral 위로 5) 탐색 보고서에 AI 채널 세션·랜딩 페이지·전환 추가 6) 서포터즈→병원 상담 예약 링크에 utm_source=viewsupporters 부착해 귀속",
"공식: 병원 GA4 관리자(편집자 권한). 서포터즈: INFINITH가 속성 생성 후 병원 계정에 관리자 권한 부여",
"실시간 수집, 월간 리포트. 산출물: AI 채널 세션 수·전환 수(하한값), 랜딩 페이지 순위. 주의: AI 유입의 35~70%가 Direct로 유실되므로 하한값으로만 보고",
"공식 미검증 → 3 (GA4 접근 확보 후 30분 작업). 서포터즈 미검증 → 발행 시 3"),
("E3 GSC AI Overviews 노출 (2)",
"0 GSC 미등록 → 1 등록만 → 2 데이터 확인 → 3 월간 추적",
"Google Search Console 실적 보고서의 검색 유형/검색 결과 모양 필터(AI 개요·AI 모드 노출·클릭). Bing Webmaster Tools(A6)와 함께 등록",
"search.google.com/search-console에 viewclinic.com 도메인 속성 등록. DNS TXT 인증은 도메인 관리자(병원)가 필요. 이미 등록돼 있을 수 있으니 오너 확인 먼저",
"서포터즈 도메인 확정 후 도메인 속성 등록(vercel.app 임시 도메인은 등록 불가 아님이나 발행 도메인으로 다시 등록해야 하므로 도메인 확정 후 진행). Vercel DNS면 TXT 추가 1분",
"1) GSC 도메인 속성 추가 → DNS TXT 인증 2) sitemap.xml 제출 3) 실적 보고서에서 '검색 결과 모양' 또는 '검색 유형' 필터로 AI 개요 노출 확인 4) 월 1회 스냅샷 저장(노출·클릭·평균 게재순위) 5) Bing 웹마스터 도구에 GSC 가져오기로 동시 등록(A6 해소) 6) IndexNow 키 설정",
"공식: 도메인 DNS 관리자(병원 또는 가비아 등 등록 대행). 서포터즈: INFINITH(Vercel DNS)",
"월 1회 수동 스냅샷. 산출물: AI 개요 노출 페이지 목록, 월별 추이표",
"공식 미검증 → 2는 등록 직후, 3은 3개월 누적 후. 서포터즈 미검증 → 도메인 확정 후 등록"),
("E4 기준선 스팟체크 (2)",
"0 없음 → 1 단발 조회만 → 2 반복 측정 1회 → 3 분기 반복 측정",
"질문 세트(QB 120문항 또는 핵심 10문항) × 엔진(OpenAI Responses API+web_search 서울 고정, Perplexity sonar) × 반복 5~7회 → 언급 여부·인용 URL·순위. 결과는 언급률 + 신뢰구간으로만 보고",
"이미 1회 실시(2026-09-01, 120문항 × 2엔진). 현재 레벨 2에 해당하나 리포트 원본이 INFINITH 측에 있어 병원 기준 미검증 처리. 분기 반복으로 3",
"동일 문항으로 발행 4주·8주 후 재측정. 서포터즈 도메인이 인용 URL에 등장하는지가 핵심 지표",
"1) scripts/run_question_bank_openai.py 로 OpenAI·Perplexity 반복 실행(JSONL 재개 가능) 2) scripts/merge_qb_results.py 로 통합표 3) scripts/export_qb_excel.py 로 엑셀 4) 브랜드/논브랜드 언급률·95% 신뢰구간 산출 5) 인용 도메인 빈도표에 viewsupporters 도메인 추가 6) 분기마다 반복해 추이 저장",
"INFINITH (OPENAI_API_KEY·PERPLEXITY_API_KEY 회사 계정). 병원 접근 불필요",
"분기 1회(발행 초기에는 4주·8주). 산출물: Viewclinic_QB_Results_*.xlsx, 언급률 추이, 인용 URL 목록",
"공식 미검증 → 2 즉시 인정 가능(리포트 첨부), 3은 다음 분기 재측정 후. 서포터즈 동일"),
]
for r,row in enumerate(E_ROWS,5):
for i,v in enumerate(row,1):
c=ws4.cell(row=r,column=i,value=v); c.font=bb if i==1 else bf; c.border=bd; c.alignment=wrap
ws4.row_dimensions[r].height=330
for i,w in enumerate([26,34,48,50,50,70,36,40,36],1): ws4.column_dimensions[get_column_letter(i)].width=w
ws4.freeze_panes="B5"
r=len(E_ROWS)+6
extra=[("공통 전제","서버 로그·GA4·GSC는 '오너가 소유한 신호'다. 병원이 계정 권한을 주지 않으면 공식 홈페이지 E는 계속 미검증이다. 서포터즈 사이트는 INFINITH가 인프라를 소유하므로 발행과 동시에 E 4항목을 모두 채울 수 있고, 이것이 서포터즈 사이트가 측정 장치 역할을 한다는 뜻이다."),
("IP 대역 출처","OpenAI: openai.com/gptbot.json, openai.com/searchbot.json, openai.com/chatgpt-user.json · Google: developers.google.com/search/apis/ipranges/googlebot.json, special-crawlers.json, user-triggered-fetchers.json · Perplexity: perplexity.com/perplexitybot.json · Bing: bing.com/toolbox/bingbot.json · Anthropic: 공식 문서의 공개 IP 목록 · 나머지 봇: 역방향 DNS + 정방향 재확인"),
("데이터 보관","crawler_hits(봇·IP·URL·시각·검증여부), ga4_ai_sessions(일·채널·랜딩·전환), gsc_ai_snapshot(월·페이지·노출·클릭), qb_runs(분기·문항·엔진·언급·인용URL). 모두 Supabase에 두고 /discovery/:id 리포트 E 카드가 읽는다."),
("보고 규칙","단발 조회 금지. 언급률은 신뢰구간과 함께. 크롤러 히트는 IP 검증분만. GA4 AI 유입은 하한값 표기. 스키마·콘텐츠 변경과 지표 변화의 인과를 단정하지 않는다.")]
for k,v in extra:
c=ws4.cell(row=r,column=1,value=k); c.font=bb; c.border=bd; c.alignment=wrap
c=ws4.cell(row=r,column=2,value=v); c.font=bf; c.border=bd; c.alignment=wrap; ws4.merge_cells(start_row=r,start_column=2,end_row=r,end_column=9); ws4.row_dimensions[r].height=64; r+=1
wb.save(OUT); print("saved",OUT, "official",S_off[3],S_off[4],"prod",S_prod[3],S_prod[4],"idx",S_fin[3],S_fin[4])

2
supporters/.env.example Normal file
View File

@ -0,0 +1,2 @@
PUBLIC_INDEXABLE=false
SITE_URL=https://view-supporters-sample.vercel.app

25
supporters/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
.vercel

4
supporters/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
supporters/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

22
supporters/AGENTS.md Normal file
View File

@ -0,0 +1,22 @@
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)

1
supporters/CLAUDE.md Symbolic link
View File

@ -0,0 +1 @@
AGENTS.md

43
supporters/README.md Normal file
View File

@ -0,0 +1,43 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View File

@ -0,0 +1,12 @@
// @ts-check
import { defineConfig } from 'astro/config';
// 샘플 배포 주소. 도메인 확정 후 SITE_URL 환경변수로 교체한다.
const site = process.env.SITE_URL || 'https://view-supporters-sample.vercel.app';
export default defineConfig({
site,
trailingSlash: 'never',
build: { format: 'file' },
compressHTML: true,
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

4096
supporters/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
supporters/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "supporters",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build && node scripts/check.mjs",
"preview": "astro preview",
"astro": "astro",
"check": "node scripts/check.mjs"
},
"dependencies": {
"astro": "^7.3.1"
},
"allowScripts": {
"esbuild": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Some files were not shown because too many files have changed in this diff Show More